핵심 한 줄
OUTBOX 폴러가 고부하에서 백로그를 무한 축적했다. “poll 주기가 느려서”라 보고 1초로 줄였다 — 더 나빠졌다. “순차 발송이라”라 보고 배치·비동기로 최적화했다 — 오히려 병을 키웠다(발행 42만 건, 실제 처리 1천 건). 진짜 병목은 폴러 내부 효율이 아니라 앱과 공유하는 DB 커넥션 풀이었다.
markPublished가 커넥션을 못 얻어 실패 → 같은 배치를 영원히 재발송하는 라이브락. 가설이 두 번 반증되고서야 정체가 드러났다.
시리즈 — 부하가 드러낸 설계
1. OUTBOX 폴러 — 백로그가 쌓인다
16편의 OUTBOX 모드: 취소 TX3가 cancel_event_outbox에 행을 INSERT하고, 별도 스케줄러(폴러)가 status='PENDING'을 읽어 Kafka로 발행 후 PUBLISHED로 표시. 폴 10초, 배치 1000.
stress를 걸자 outbox가 안 빠졌다. 런 종료 시 PENDING 82,511 / PUBLISHED 984. 폴러가 거의 멈춘 것처럼 보였다.
2. 가설 1 — “poll이 느리다” → 반증
배수율 = 배치/폴주기 = 1000/10s = 100/s. 생성이 230/s니 당연히 밀린다. poll을 1초로 줄이면 1000/s로 올라 감당하겠지 — 재측정.
결과: PENDING이 101,121까지 더 증가. 튜닝이 더 나쁘게 만들었다. poll 주기가 병목이 아니라는 강한 신호.
3. 가설 2 — “순차 발송이라” → 최적화가 병을 키웠다
폴러 로그를 보니 1000건 배치가 ~11초 걸렸다(건당 ~11ms). send().get()을 순차로 도니 상한이 ~90/s. 그래서 최적화:
- markPublished 배치화: 건당
findById+save(커넥션 1000회 왕복) →UPDATE ... WHERE id IN (:ids)한 방 - async 병렬 send: 순차
.get()제거, 발사 후 일괄 대기
재측정 — 그런데 PUBLISHED가 런 내내 0. 그리고 결정적 지표:
producer_send_total = 424,530
order 처리(고유) = 1,000
outbox PENDING = 79,811 (전량)
42만 발송 vs 고유 1천. 최적화가 백로그를 못 닫았을 뿐 아니라, async가 재발송을 더 빠르게 해서 병을 키웠다.
4. 정체 — 라이브락
payment 로그: markPublished가 HikariPool 커넥션 타임아웃. 취소 220rps가 앱 풀(10)을 꽉 쥐어, 폴러가 write 커넥션을 못 얻는다. markPublished 실패 → 오래된 배치가 영영 PENDING → ORDER BY created_at ASC LIMIT 1000이 매 폴 같은 head를 재조회·재발송.
flowchart TD A["폴 시작"] --> B["findPendingBatch<br/>ORDER BY created_at ASC LIMIT<br/>→ 항상 같은 head 1000건"] B --> C["async send → Kafka(중복)"] C --> D{"markPublished<br/>커넥션 확보?"} D -->|"풀 포화 → 타임아웃"| E["예외 → 행 PENDING 유지"] E --> A C -.-> G["order: 같은 1000건 중복 수신<br/>→ dedup → 고유 1000만 처리"]
이건 데드락이 아니다. 폴러는 얼어붙지 않았다 — 42만 번 발송하며 미친 듯이 바쁘다. 그런데 순진전이 0이다.
가설이 두 번 뒤집히고서야 정체가 드러난 흐름:
flowchart LR M["백로그 관측<br/>(PENDING 안 빠짐)"] --> H1["가설1: poll 느림<br/>→ 10s→1s 튜닝"] H1 -->|"PENDING 101k↑"| R1["반증"] R1 --> H2["가설2: 순차 발송<br/>→ batch + async"] H2 -->|"sent 42만 vs order 1천"| R2["반증<br/>(오히려 병 키움)"] R2 --> C["진짜 원인:<br/>공유 커넥션 풀 굶음<br/>= 라이브락"]
5. deadlock / livelock / starvation
| 상태 | CPU | 진전 | |
|---|---|---|---|
| deadlock | 얼어붙음 | 0 | 0 |
| livelock | 활발 | 높음 | 0 |
| starvation | 일부만 대기 | — | 일부 0 |
우리 건은 정확히 livelock(폴러가 head 재발송을 무한 반복) + 꼬리 78k건 starvation(영영 서비스 못 받음). 8편에서 “데드락인 줄 알았던” 게 사실 락 대기였듯, 여기선 “느린 폴러인 줄 알았던” 게 사실 라이브락이었다.
최적화가 진단을 확정했다
async send는 백로그를 못 고쳤지만 재발송을 가속해 42만 건이라는 증거를 만들었다. naive 순차 폴러였다면 재발송이 더 적어 정체가 흐릿했을 것이다. 잘못된 방향의 최적화가, 역설적으로 진짜 병목(풀 경합)을 또렷하게 드러냈다.
6. 핵심 통찰 — 폴러 내부 효율이 아니다
병목은 폴러가 얼마나 효율적으로 일하냐가 아니라 폴러가 앱과 DB 커넥션 풀을 공유한다는 자원 봉투에 있었다. batch·async 어떤 내부 최적화도 이걸 못 고친다 — markPublished는 여전히 커넥션 1개가 필요한데, 부하 중엔 그 1개를 못 얻으니까.
이 실전 버그는 별도 문서로 박제했다(docs/load-test/outbox-poller-livelock.md). 프로덕션에서도 OUTBOX 발행 + 앱 포화가 겹치면 재현 가능한 진짜 버그다.
한 줄
poll 튜닝도, 배치·비동기 최적화도 백로그를 못 닫았다 — 병목은 폴러 효율이 아니라 앱과 공유하는 커넥션 풀이었다. 라이브락은 자원의 양이 아니라 격리 문제다. 그럼 격리하면 된다. → 18편