핵심 한 줄

지금껏 취소의 생성 축(rps·커밋·풀)만 쟀다. payment.cancelled는 취소 100% 성공률로 “발행은 잘 된다”고 확인했지만(16편), 그건 프로듀서 얘기다. order 컨슈머가 실제로 따라잡는지(lag), 최종 상태가 수렴하는지(e2e), 실패가 DLQ로 잘 빠지는지는 한 번도 안 쟀다. 이번에 order_db를 미러해 소비 축을 처음 쟀고 — lag→0·DLQ델타0·e2e정합을 확인했다. 그리고 미러 데이터 버그 하나가 우발적 장애 주입이 되어, 계획 없이 컨슈머의 실패 처리 경로(retry→DLQ)를 실증했다.

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

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


1. 사각지대 — 생성은 쟀고 소비는 안 쟀다

취소 부하 실측은 내내 payment 쪽을 봤다. rps, 커밋 수, 커넥션 풀, 홉 지연. payment.cancelled 발행도 “취소 100% 성공률”로 간접 확인했다 — 설계상 TX3 커밋과 send가 커플링돼 있으니(발행 실패면 롤백) 성공률이 곧 발행 성공률이다.

그런데 이벤트는 누군가 소비해야 의미가 있다. order-service가 payment.cancelled를 받아 주문 상태를 동기화한다. 여기엔 생성 축에 안 잡히는 질문들이 있다:

flowchart LR
  subgraph MEASURED["쟀던 것 — 생성 축"]
    P["payment: 취소 rps·커밋·풀·발행 성공률"]
  end
  subgraph BLIND["안 봤던 것 — 소비 축"]
    L["consumer lag<br/>(따라잡나?)"]
    D["DLQ/retry<br/>(실패는 어디로?)"]
    E["e2e 정합<br/>(order==payment?)"]
  end
  P -->|"payment.cancelled"| BLIND

비동기라 처리량엔 안 보이지만 정합성엔 보인다. 컨슈머가 밀리면 주문 상태가 늦게 수렴하고, 실패 이벤트가 조용히 쌓이고, 최악엔 order와 payment가 영영 어긋난다. 이걸 재려면 order_db가 실제 데이터를 들고 있어야 한다 — payment_item에서 orders·order_item을 미러했다(두 DB는 별도 인스턴스라 앱 밖에서 복제).

2. 버그가 카오스 실험이 됐다

미러 SQL에서 orders.status를 'CREATED'로 넣었다. 그런데 OrderStatus enum엔 CREATED가 없다(PAID·CONFIRMED·CANCELLED…). 컨슈머가 주문을 읽어 enum으로 매핑하는 순간 터졌다:

No enum constant com.example.order.domain.entity.OrderStatus.CREATED

의도한 건 아니었다. 그런데 이게 완벽한 장애 주입이 됐다. 컨슈머가 매 이벤트에서 실패하니, 시스템의 실패 처리 경로가 부하 하에서 그대로 드러났다:

flowchart TD
  C["order 컨슈머: 이벤트 처리"] -->|"enum 매핑 실패"| R["RetryRouter"]
  R -->|"retry 토픽 재발행"| RT["payment.cancelled.retry"]
  RT --> C2["retry 컨슈머 재시도"]
  C2 -->|"또 실패, retryCount++"| R
  C2 -->|"retryCount=3"| DLQ["payment.cancelled.DLQ"]

측정값이 이 경로를 못 박았다. 66,822건이 최종 DLQ로 빠졌고, retry 토픽엔 174,485건이 재발행됐다(각 실패가 최대 3회 재시도되며 증폭). 계획된 카오스 실험 없이, 미러 오타 하나가 “실패는 무한 재시도가 아니라 3회 후 DLQ로 격리된다”를 실증했다.

좋은 장애는 '구별 가능'하게 실패한다

DLQ·retryCount·processed_cancel_event가 없었다면 이 실패는 그냥 “order가 조용히 안 맞음”으로 뭉갰을 거다. 실패를 1급 신호(retry 토픽 깊이, DLQ 오프셋)로 만들어 뒀기에, 오타조차 진단 가능한 사건이 됐다.

3. 고치고 — 깨끗한 소비 축을 재다

status를 유효 enum(PAID)으로 고치자 두 가지가 일어났다. retry 토픽에 쌓여 있던 이벤트들이 재시도에서 살아나 처리됐고(retryCount<3이었던 것들), 새 이벤트는 깨끗이 흘렀다. 미러를 바로잡고 config를 하나 더 돌려 오염 없는 소비 축을 쟀다:

지표결과
consumer lagpayment.cancelled 전량 소비 → 0
DLQ 델타0 (실패 0)
retry 델타0 (재시도 0)
e2e 정합processed 101,062 ≈ orders CANCELLED 101,072 ≈ order_item CANCELLED 101,102

processed_cancel_event의 UK가 dedup = exactly-once 효과를 보장한다. 발행이 at-least-once(재발행·중복 가능)여도, 소비가 멱등이면 order 상태는 정확히 한 번만 전이된다. 17편의 라이브락에서 42만 발송이 order 1,000으로 수렴했던 것도 같은 원리 — 컨슈머 멱등이 발행 중복을 흡수한다.

두 얼굴을 다 봤다

한 번의 실측에서 건강한 소비(lag→0·정합·exactly-once)와 장애 처리(retry→DLQ 격리)를 둘 다 관측했다. 전자는 정상 경로를, 후자는 버그가 우연히 그려 준 실패 경로를 — 소비 축의 양면이 한 판에 드러났다.

한 줄

발행이 100%여도 소비는 별도로 재야 한다 — 생성 축의 건강이 소비 축의 정합을 보장하지 않으니까. 그리고 실패를 신호로 만들어 두면, 오타 하나도 카오스 실험이 된다. → 21편