본문으로 건너뛰기
Forward Engineering

아카이브

지금까지 쓴 글들을 시간순으로 모아봤어요.

2026 40
August 3
July 5
  • 큐가 가득 찼을 때 작업을 잃지 않으려면 — 6,397건 거절에서 배운 backpressure

    브라우저 작업이 포화된 실제 운영 구간에서 3일간 6,397건의 publish가 queue-full로 거절됐습니다. bounded queue·publisher confirm·재예약·stale recovery로 수용 불가를 유실이 아닌 추적 가능한 상태 전이로 바꾼 과정을 정리합니다.

  • 왜 인스턴스를 두 개로 늘릴 수 없는가 — 인메모리 pool의 수평 확장 한계

    reputation-pool-cloud의 인스턴스를 두 개로 늘리면 같은 리소스가 중복 대여되고, JVM별 fencing token이 충돌하며, 전체 snapshot checkpoint가 서로의 변경을 덮어쓸 수 있습니다. 아직 운영 장애가 아니라 현재 코드에서 구성한 설계 반례입니다. lease·snapshot·메모리 예산·rate limit의 정확성 범위를 추적하고 tenant sharding, 상태 외부화, single writer를 비교해 다음 경계를 결정합니다.

  • 데이터를 나눴는데 장애는 함께 났다 — 멀티테넌트 격리와 noisy neighbor 대응

    reputation-pool-cloud에서 API key를 tenant로 해석하고, tenant별 ResourcePool·PostgreSQL row·이벤트 스트림을 격리한 과정을 다룹니다. 데이터가 섞이지 않는 것만으로는 충분하지 않았습니다. 모든 tenant가 한 JVM의 heap과 처리량을 공유했기 때문에 한 tenant의 리소스 폭증과 요청 폭주가 전체 장애로 이어질 수 있었습니다. 전역 메모리 예산, tenant별 token bucket, 복원·삭제 회계와 아직 남은 단일 인스턴스 한계를 실제 코드와 PR을 바탕으로 정리했습니다.

  • 코어를 SaaS에서 떼어냈더니 경계가 하나 더 필요했다 — JDK-only 엔진과 thin host 설계

    reputation-pool의 판단 엔진을 JDK-only 공개 코어로, 인증·멀티테넌시·운영 기능을 Spring Boot SaaS host로 분리한 과정을 다룹니다. 처음에는 gRPC 계약과 어댑터가 reference server 안에 있어 cloud가 코드를 복사해야 했습니다. 이 잘못된 경계를 공개 reputation-pool-grpc 모듈로 다시 나누고, server와 cloud가 같은 계약을 소비하게 만든 이유와 비용을 실제 PR과 코드 구조를 바탕으로 정리했습니다.

  • 32스레드 테스트는 통과했지만 명세가 틀렸다 — Lincheck가 동시성 계약을 세 번 기각한 과정

    reputation-pool의 32스레드 스트레스 테스트를 Lincheck 모델 체킹으로 확장하면서 겪은 기록입니다. Lincheck가 찾아낸 것은 단순한 구현 버그가 아니었습니다. 전역 fencing token 순서, acquire가 반환할 리소스의 정체성, block과 경합할 때의 성공 여부처럼 제가 계약이라고 믿었던 세 가지 가정이 실제 구현이 보장해야 할 계약보다 강했습니다. 실패 trace를 따라 공개 계약과 스케줄링 정책을 분리하고, 선형화로 표현할 수 없는 운영 속성은 별도의 실시간 순서 테스트로 옮긴 과정을 정리했습니다.

May 19
  • [JPA + Spring Mastery 09] 어디서 무엇을 쓸까 — JPA / JPQL / Native SQL 결정 트리, 측정값 4종으로 갈리는 컷

    8 편을 거치며 PersistenceContext / Dirty Checking / Optimistic Lock / N+1 / IDENTITY / self-invocation / Saga·Outbox 를 측정값으로 풀었습니다. 이 마무리편은 그 모든 evidence 를 한 결정 트리 위에 올려놓습니다 — 도메인 단위인지 데이터 단위인지의 첫 갈림길로 시작해, JPA Dirty Checking 이 빛나는 곳 5 가지와 적자인 곳 5 가지, JPQL 의 중간 자리, Native / JdbcTemplate / MyBatis 의 자리를 4 측정값 (Dirty Checking 132× / saveAll IDENTITY ~1000× / OFFSET 1M 570× / bulk JPQL vs Dirty Checking 84×) 으로 컷하고, Eric Evans Aggregate (DDD 2003), Fowler PoEAA Identity Map / Unit of Work (2002), Greg Young CQRS Documents (2010), Vlad Mihalcea High-Performance Java Persistence (2016) 의 학술 anchor 와 카카오페이 readOnly QPS 58% 회고로 묶었습니다. 면접에서 'JPA 어떻게 다뤄봤어요?' 에 URL 한 줄로 답하기 위한 entry point 입니다.

  • Aggregate boundary 를 *권한* 으로 강제 — 한 schema 에 5 user 분리 + cross-domain write 차단 7/7 실측

    Distributed Monolith 의 가장 흔한 함정 — *모든 도메인이 같은 user 로 같은 schema 에 접근* 하니 cross-domain write 가 코드 리뷰에서만 잡힘. W4 P5 에서 같은 schema 에 5 user (be_owner / be_billing / be_persona / be_rule / be_workflow) 분리 + GRANT 로 cross-domain WRITE 를 *런타임* 에 ERROR 1142 로 차단. 7/7 검증 시나리오 모두 의도대로 동작 — 잘못된 코드가 *DB 가 직접* 잡아주는 첫 단계. 이 글은 "왜 같은 schema 안에서 user 분리가 의미 있는가" 의 *진화 전략* (W3 논리적 namespace → W4 user 분리 → W6 별도 DB 인스턴스) 의 두 번째 단계 측정 기록입니다.

  • saveAll() 이 1만 INSERT 가 되는 이유 — IDENTITY + Hibernate batch 비활성화의 구조적 함정

    hibernate.jdbc.batch_size=50 으로 설정해도 1만 row 의 saveAll() 이 1만 INSERT 로 발사되는 이유. GenerationType.IDENTITY 는 매 INSERT 마다 LAST_INSERT_ID() 가 즉시 필요해서 Hibernate 가 batch 를 *구조적으로 비활성화* — Statement.RETURN_GENERATED_KEYS 가 batch 와 호환 안 됨. TABLE 전략 시뮬레이션 (애플리케이션이 ID 미리 발급) 은 batch 묶임 → 200 SQL. raw JDBC batchUpdate + rewriteBatchedStatements=true 는 multi-value INSERT 로 약 10 SQL — 가장 빠름. DZone 의 IDENTITY → SEQUENCE 100배 글은 PostgreSQL 기준 — MySQL 은 SEQUENCE 미지원 (TABLE 로 에뮬레이션). MySQL 환경의 진짜 답은 UUID / TableGenerator pooled-lo / Snowflake / raw JDBC batch 중 선택입니다.

  • JPA N+1 + JOIN FETCH 깊이 함정 4종 — MultipleBagFetchException, Pagination OOM, OneToOne LAZY 트랩

    4-depth 도메인 (owner→merchant→rule→history) 에서 findAll() + 자식 접근하면 21 SQL. JOIN FETCH 한 단계로 1 SQL. 단 *두 collection 동시 fetch* 면 MultipleBagFetchException — Hibernate 가 List(Bag) 두 개의 cartesian product 를 거부. *JOIN FETCH + setMaxResults* 면 HHH000104 경고 + in-memory pagination — 1만 row 다 읽고 20개만 남기는 OOM. *@OneToOne non-owning LAZY* 는 프록시가 null 인지 알 방법이 없어서 *항상 fetch* — LAZY 무시. @BatchSize 가 N+1 → N/K+1 으로 완화하는 표준 처방. JPA 의 fetch 함정은 기능 한 줄이 아니라 *Bag/List/Set + 프록시 메커니즘 + Hibernate 의 cartesian 처리 정책* 의 상호작용입니다.

  • @Transactional(readOnly = true) 한 줄이 응답시간을 132배 줄인 이야기 — JPA Dirty Checking 의 정체 해부

    Updated:

    JPA dirty checking 비용을 1만 row UPDATE 6 시나리오로 분해. 비교의 *기준* 을 분리해서 읽어야 한다 — S1(일반) vs S2(readOnly SELECT) ≈ 132× 는 *readOnly 가 빠진 메서드가 부담하는 비용*, S4(@DynamicUpdate) vs S6(raw JDBC) ≈ 68× 는 *@DynamicUpdate 만으로는 dirty checking dominant 비용을 못 줄인다* 는 의미, S4 vs S5(bulk JPQL) ≈ 50× 는 *모델 자체를 dirty checking 에서 bulk 로 바꿨을 때* 효과, S5 vs S6 ≈ 1.32× 는 JPA 추상화 오버헤드 30% 수준. @DynamicUpdate 는 변경 컬럼 조합마다 SQL string 이 달라져 batch_update 효과를 약화시킬 수 있고 Query Plan Cache 압박을 일으킬 수 있어 *국지적 도구*. loadedState / FlushMode MANUAL / SPR-16956 / bytecode enhancement 까지 Hibernate 6 내부 구조와 함께, 단정 표현은 톤다운한 결론으로 정리한 v2 [실측 — Java/Spring Stage 2, EXP-13].

  • JPA 낙관락 + retry stampede 의 함정 — @Version 만으론 부족한 6 시나리오

    100 worker 가 같은 룰의 priority 를 +1 합니다. @Version 없으면 priority < 100 (Lost Update). @Version 적용하면 OptimisticLockException 만 던지고 처리는 호출자 책임 — 일부만 성공. @Retryable(3) 백오프 0 으로 retry 면 **동시에 retry 가 몰려서** 다시 충돌 (retry stampede). exponential + full jitter 가 retry 를 분산시켜 priority=100 도달. 그리고 측정 도중 만난 자기 Lost Update — 같은 트랜잭션 안에서 SELECT 두 번이 다른 객체 (JDBC) vs 같은 인스턴스 (JPA 1차 캐시 ==). 분산 환경 Lost Update 와는 완전히 별개의 함정. @Transactional + @Retryable AOP 순서, exponential backoff + jitter 의 이론적 근거 (AWS Architecture Blog) 까지 풀었습니다.

  • [JPA + Spring Mastery 01] 1차 캐시 · flush · 트랜잭션 라이프사이클 — readOnly 가 줄이는 set_option, dirty checking 의 진짜 비용

    PersistenceContext 가 Identity Map 패턴 (Fowler PoEAA) 위에 어떻게 작동하는지, ActionQueue 4종이 SQL 발행 순서를 어떻게 결정하는지, AutoFlush 가 query 직전에 무엇을 보고 flush 하는지, dirty checking 이 reflection 과 bytecode enhancement 두 방식에서 얼마나 비용이 다른지 — Hibernate 6 의 DefaultFlushEventListener 부터 카카오페이의 readOnly + set_option QPS 58% 감소 회고까지, 1차 캐시와 트랜잭션 라이프사이클을 line 단위로 분해한 기록입니다. JPA 5 ways 측정 (raw JDBC 0.74 / JPA 0.99~1.95 ms) 의 +0.4ms baseline 비용이 어디서 오는지, 그리고 그 비용을 줄이기 위한 readOnly 의 *3단 효과* (Hibernate flush mode + Spring tx 마커 + MySQL Com_set_option 감소) 를 라인 단위로 풀어봅니다.

  • 프로덕션이 'Check failed: node->IsInUse()' 한 줄로 죽었습니다 (2) — Datadog 프로파일러가 V8 청소부와 race를 만든 자리

    1편에서 V8 GlobalHandles 의 어설션이 깨졌다는 것까지 확인했습니다. 그 어설션을 깬 범인을 추적합니다 — V8 CPU Profiler 의 WeakCodeRegistry, dd-trace 의 1분 타이머, 그리고 6주 전 v5.14에서 새로 기본 경로가 된 lazy stop-and-collect. 마지막으로 우리 팀이 출혈 차단을 위해 무엇을 껐고 항구적으로는 어떻게 처방할 것인가까지 정리합니다.

  • [JPA + Spring Mastery 08] 트랜잭션 분리 패턴 — Saga / Outbox / REQUIRES_NEW, 학술 기원부터 EXP-09b 9 시나리오 실측까지

    트랜잭션 안에서 외부 API 호출하지 마라 - 격언은 들어봤지만 어떻게 풀어야 하는지는 잘 다뤄지지 않습니다. 본 글은 PROPAGATION 7종의 정확한 의미부터 2PC (XA) 의 한계, Garcia-Molina 의 1987년 Sagas 논문, Pat Helland 의 CIDR 2005 Data on the Outside, Vogels 의 ACM Queue 2008 Eventually Consistent 까지 학술 기원을 짚고, 토스 SLASH24 SAGA, 29CM/리디 Outbox 한국 운영 사례를 거쳐, EXP-09b 9 시나리오 실측 매트릭스 - 패턴 A/B/C × OFF/DB_FAIL/EXT_FAIL - 로 검증한 기록입니다. 결제 도메인은 Saga, 알림은 Outbox, 캐시류만 단순 분리 - 이 매핑의 학술 + 운영 + 실측 3박자.

  • [JPA + Spring Mastery 07] Spring AOP self-invocation — @Transactional 이 작동하지 않는 진짜 이유, TransactionInterceptor.invoke 6단계까지 분해

    낙관락 측정에서 successes=100인데 잔액이 그대로 100. 코드 logic 은 멀쩡한데 잔액이 안 줄어들었습니다. 원인은 같은 클래스 내부 호출이 Spring AOP 프록시를 우회한 것 — @Transactional 이 발동하지 않아 flush 가 일어나지 않았습니다. 이 글은 TransactionInterceptor.invoke 의 6단계 / AOP Alliance MethodInvocation.proceed() 가 raw target 을 호출하는 위치 / 같은 함정에 걸리는 6 어노테이션 (@Async / @Cacheable / @Validated / @Retryable / @PreAuthorize) / 4 가지 워크어라운드 (분리 빈 / getBean(self) / AopContext.currentProxy / AspectJ weaving) 를 Spring 6 / Hibernate 6 소스 라인 단위로 분해한 기록.

  • 프로덕션이 'Check failed: node->IsInUse()' 한 줄로 죽었습니다 (1) — V8 GlobalHandles 해부

    프로덕션 컨테이너가 평범한 새벽에 V8 fatal 한 줄을 남기고 죽었습니다. 어플 코드는 단 한 줄도 안 들어간 스택, Datadog 프로파일러의 마지막 호출, 그리고 V8 내부의 'Check failed: node->IsInUse()'. 이게 무슨 뜻인지부터 풀어봅니다 — 1편: 사건 재현과 V8 GlobalHandles의 해부.

  • MySQL 크레딧 차감 락 4종 비교 — 비관락 180ms / 100% 정확, 그리고 측정 도중 발견한 self-invocation 함정

    잔액 100 인 계정에서 100 worker 가 동시에 1씩 차감하는 흔한 시나리오. 4 락 (낙관/비관/MySQL GET_LOCK/Redisson) 의 결과가 모두 다릅니다 — 비관락 180ms / 100% / 잔액 0, 낙관락 549ms (contention 시 재시도 폭증), GET_LOCK 5015ms (advisory lock 의 cost), Redisson 53/100 (단일 인스턴스 한계). 그리고 측정 도중 발견한 self-invocation 함정 — successes=100 인데 잔액 그대로 유지된 case. JPA / Spring 의 진짜 함정은 logic 이 아니라 AOP proxy 우회였습니다. GET_LOCK 의 connection-bound 함정 4 시나리오까지 직접 시연한 기록.

  • RDB Mastery #3 — EXPLAIN ANALYZE 마스터: Push Down 함정과 Index Selection 의 진짜 메커니즘

    EXPLAIN ANALYZE 의 연산자 트리 한 줄을 읽을 줄 알면 옵티마이저의 결정을 직접 검증할 수 있습니다. Filter vs Index Range Scan over 한 단어 차이가 push down 성공 vs 실패. ANSI SQL 표준 row constructor (a,b)<(?,?) 가 MySQL 옵티마이저의 whitelist 패턴에 안 맞아서 push down 실패 — Bug #16247 은 2006년에 등록된 오래된 known limitation (현재 트래커는 duplicate 처리). Index Selection 도 옵티마이저의 cost-based 판단 — Q2 역설 (LIMIT 5 의 작은 수에서 옵티마이저가 잘못된 인덱스 선택해서 인덱스 추가가 느려짐). 100% 의 시간 옵티마이저는 옳지 않습니다. 1,000만 row 에서 측정한 5개 EXPLAIN ANALYZE 출력을 한 줄씩 해석하면서 push down 메커니즘과 cost-based index selection 의 내부를 풀어봅니다.

  • RDB Mastery #2 — MySQL 인덱스의 종류: B-tree / Hash / Covering / Composite / Multi-valued / Functional, 그리고 언제 무엇을 고를 것인가

    InnoDB 의 모든 인덱스가 B-tree 가 아닙니다. Hash (Memory engine), Spatial (R-tree), Full-text (역인덱스), Multi-valued (8.0+, JSON 배열), Functional (8.0.13+, 표현식). 그리고 같은 B-tree 안에서도 clustered vs secondary, covering 여부, composite 의 leftmost prefix, cardinality / selectivity 가 결정의 축이 됩니다. 1,000만 row 환경에서 5종 인덱스를 직접 만들어 cardinality + Q1~Q5 latency 변화로 언제 무엇을 고를지를 측정으로 결정. Q3 covering 2,476배 / Q5 composite 577배 / Q2 역설 (인덱스 추가가 느려지는 케이스 0.66ms → 13.5ms). 인덱스는 공짜가 아닙니다 — 쓰기 비용 5~6배 + storage 1.3GB. 9개 다이어그램으로 끝까지 풀어봅니다.

  • RDB Mastery #1 — InnoDB 인덱스 내부 구조: No-Index 부터 다중 인덱스까지 B-tree 가 그리는 진짜 그림

    인덱스를 안 걸어도 InnoDB 안에서는 이미 B-tree 입니다. PK 가 clustered index = 테이블 자체. Secondary index 는 PK 를 가리키는 별도 B-tree. Covering index 는 PK 까지 안 가도 답이 있는 인덱스. Reverse scan 은 leaf 의 양방향 linked list 를 거꾸로 walk. OFFSET 이 건너뛸 수 없는 이유는 B-tree 가 row 카운터를 안 가지기 때문. Cursor 가 빠른 이유는 WHERE 가 binary search primitive 를 트리거하기 때문. 다중 인덱스 = 같은 테이블에 N 개 B-tree. 1,000만 row 환경에서 [실측] Q3 covering 2,476배 / Q5 composite 577배 / OFFSET 1M 171ms / cursor 0.30ms — 다이어그램 10개로 끝까지 풀어봤습니다.

  • JVM Thread Dump로 분해한 HikariCP 풀 고갈 — TIMED_WAITING (parked) 의 진짜 의미

    풀 고갈 알람이 울렸을 때 애플리케이션 코드만 들여다보면 답이 안 나옵니다. jstack으로 받아본 thread dump가 진짜 증거 — 모든 worker thread가 HikariCP 안에서 TIMED_WAITING (parked) 상태로 멈춰 있습니다. JVM Thread State 머신, LockSupport.parkNanos, ConcurrentBag·SynchronousQueue 의 동작, 그리고 트랜잭션-안-외부-호출 풀 고갈 [실측] (timeout 5s 100% / 1s 16.7%)이 thread dump 한 줄과 정확히 어떻게 매핑되는지 — 운영 중 풀 고갈을 dump 한 장으로 진단하는 방법을 라인 단위로 풀어봤습니다. 추가로 시니어 관점 보강: Incident Commander 의 비기술 4 책임, 침묵의 함정, 5 Whys 로 본 사고 RCA, Blameless postmortem, DBA 와 max_connections 협상 대화 패턴 — 기술적 fix 옆의 *조직* 동선까지 자체 체크리스트 11 카드로.

  • MySQL No-Offset Cursor 페이지네이션 — 1,000만 row에서 OFFSET 1M이 171ms / Cursor 0.30ms, 그 사이의 500배 함정 한 줄까지 측정으로 풀어봤습니다

    1,000만 row 환경에서 OFFSET 1M이 171ms / No-Offset Cursor가 0.30ms — 약 570배 차이를 측정으로 재현했습니다. 그런데 No-Offset 코드를 어떻게 쓰느냐에 따라 또 한 번 500배가 갈라집니다. ANSI SQL 표준의 row constructor `(a,b)<(?,?)` 는 의미상 OR 분리 형태와 같지만 MySQL 옵티마이저가 index range로 push down 못 합니다 (154ms — OFFSET과 거의 동일). EXPLAIN ANALYZE 의 Filter: vs Covering index range scan over 한 줄 차이가 본질입니다 — 프로덕션 회고와 학습 환경 재현을 같이 풀어봤습니다.

  • MySQL InnoDB 격리수준 — phantom read 를 4 격리수준 모두 [실측]하고 RR 이 ANSI 표준보다 강한 이유를 메커니즘으로 분해했습니다

    ANSI SQL 표준의 RR 은 phantom read 차단을 보장하지 않습니다. 그런데 MySQL InnoDB 의 RR 은 차단합니다. 이 흔한 주장을 직접 측정으로 굳혔습니다 — RU/RC 는 phantom 발생 (A1=0 → INSERT → A2=1), RR 은 차단 (A2=0), SERIALIZABLE 은 INSERT 자체 wait (1.56초). 그리고 왜 MySQL RR 이 ANSI 표준보다 강한지 — consistent read snapshot / gap lock / MVCC undo log 세 메커니즘으로 분해해서 결제 도메인에는 RR 만으로 충분하다는 결론을 측정값으로 굳혔습니다.

  • 트랜잭션 안에서 외부 API 호출 — 풀 고갈을 직접 재현하고, 단순 분리·Saga·Outbox 세 처방을 측정으로 비교했습니다

    Spring + HikariCP 환경에서 트랜잭션 안의 외부 API 호출이 풀을 고갈시키는 메커니즘을 raw JDBC로 재현한 뒤, 단순 분리 / Saga / Outbox 세 가지 처방을 60 worker × 9 chaos 시나리오로 직접 측정했습니다. 단순 분리가 정합성을 깨는 순간을 60건 어긋남으로 잡아내고, Saga의 3중 안전망이 어떻게 차례로 동작하는지, Outbox의 사용자 응답 72ms와 처리 완료 평균 93초가 *같은 측정값에서 어떻게 갈리는지*까지 라인 단위로 풀어봤습니다.

January 13