핵심 한 줄

OUTBOX 폴러가 고부하에서 백로그를 무한 축적했다. “poll 주기가 느려서”라 보고 1초로 줄였다 — 더 나빠졌다. “순차 발송이라”라 보고 배치·비동기로 최적화했다 — 오히려 병을 키웠다(발행 42만 건, 실제 처리 1천 건). 진짜 병목은 폴러 내부 효율이 아니라 앱과 공유하는 DB 커넥션 풀이었다. markPublished가 커넥션을 못 얻어 실패 → 같은 배치를 영원히 재발송하는 라이브락. 가설이 두 번 반증되고서야 정체가 드러났다.

시리즈 — 부하가 드러낸 설계

1막 · 실측으로 잡고 증명하다 00 · 05 · 08 · 11 … 2막 · 관측을 코드로, 코드를 재측정으로 12 · 13 · 14 · 15 3막 · 발행을 떼어내다 16 · 17 · 18 · 19 4막 · 생성 너머: 소비·현실·정직성 20 · 21


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얼어붙음00
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편