ParityPay로 검증하는 결제 정합성 8 - Kafka에서 중복·유실·순서 역전을 직접 만들어 보기: 멱등 소비자와 Outbox가 막는 것과 못 막는 것
엔지니어링 요약
Problem
5편의 실험은 전부 우리 쪽에서 만든 장애였다. 소비자를 죽이고 발행기를 여러 대 띄웠다. 브로커 쪽에서 나는 장애—커밋 방식, acks 설정, 파티션 키, 처리할 수 없는 레코드—에 대해서는 '대비되어 있다'는 문장만 있었다.
Decision
실험 전용 프로필로 커밋 방식·acks·파티션 키를 바꿔 같은 조건에서 네 가지를 재현했다. 자동 커밋 소비자 SIGKILL, acks=all/1/0에서 브로커 SIGKILL, 랜덤 파티션 키로 승인·취소 흩어 보내기, 파싱 불가 레코드 1건 주입. 재현되지 않은 실행은 '증명하지 못함'으로 세고 기본값은 건드리지 않았다.
Result
자동 커밋으로도 유실 0—이 코드베이스에서 유실을 가르는 것은 커밋 방식이 아니라 처리와 커밋의 순서였다. acks=1·0에서는 'PUBLISHED인데 브로커에 없음'이 정확히 발행 배치 단위(100·200건)로 생겼고 acks=all에서는 0. 랜덤 파티션 키는 결제의 32~33%를 뒤집어 전부 3,600원씩 과지급했다. poison 1건은 파티션을 1.3~1.6초 멈춘 뒤 조용히 버려졌고, 그 경로가 13번째 결함이 됐다. DLT와 실패 이력·경보·재처리 API를 붙인 뒤 정지는 126~486ms로 줄고 레코드는 남는다.
5편에서 at-least-once는 버그가 아니라 전제라고 썼고, 그 전제 위에서 멱등 소비자가 실제 중복 1,934건을 흡수하는 것을 봤다. 그런데 5편의 장애는 전부 우리가 만든 것이었다. 소비자 프로세스를 죽였고, 발행기를 네 대 띄웠다. 브로커 쪽에서 나는 장애에 대해서는 설정값과 “대비되어 있다”는 문장이 있을 뿐이었다. acks=all이니까 유실이 없고, 파티션 키가 Aggregate ID니까 순서가 지켜지고, 자동 커밋이 아니니까 안전하다.
이 글은 그 문장들을 하나씩 깨뜨려 본 기록이다. 네 가지를 재현했다. 자동 커밋으로 바꾼 소비자를 죽이면 정말 유실이 나는가. acks를 낮추고 브로커를 죽이면 Outbox의 PUBLISHED 표시가 거짓이 되는가. 파티션 키를 잘못 두면 실제 소비자에서 돈이 틀리는가. 파싱할 수 없는 레코드 하나가 파티션을 얼마나 막는가. 네 실험 중 셋은 “대비되어 있다”가 맞았고, 하나는 대비된 적이 없는 경로를 열었다.
실험의 공통 조건
실험용 설정은 전부 experiment-kafka 프로필과 EXPERIMENT_* 환경변수로만 바뀐다. 기본값은 그대로다. 저장소를 읽는 사람이 실험 설정을 설계로 오해하지 않게 하기 위해서다. 랜덤 파티션 키는 코드를 바꾸지 않고 Outbox의 partition_key 열에 직접 적재해 만들었다. 그 열이 곧 브로커의 키이므로, “잘못된 producer 경로”가 코드 변경 없이 생긴다.
브로커는 Redpanda 단일 노드(--smp=1 --memory=1G), 파티션 3, 컨테이너 리소스 제한 없음. 하니스는 load-tests/kafka-failure-experiment.py 하나이고 duplicate·loss·order·poison 서브커맨드로 나뉜다. 5편의 M-005 관례를 그대로 따라, 재현하려던 장애가 생기지 않은 실행은 “증명하지 못함”으로 표시하고 성공으로 세지 않는다.
실험 1: 자동 커밋으로 바꾸면 유실이 나는가
흔히 “자동 커밋을 켜면 유실된다”고 한다. M-005는 기본 설정(spring-kafka 컨테이너가 poll 배치를 다 처리한 뒤 커밋)에서 SIGKILL이 중복만 만들고 유실은 없다는 것을 보였다. 같은 조건에서 Kafka 클라이언트 자동 커밋(enable.auto.commit=true, 100ms)으로 바꿔 무엇이 달라지는지 셌다(M-015).
소비자 2대, TopUpCompleted 20,000건, 소비 시작 3초 뒤 한 대를 SIGKILL. 세션 시간 45초가 지나 재분배되면 살아남은 쪽이 이어받는다.
| 커밋 방식 | run | 거래내역 행 | 실제 중복 전달 | 유실 |
|---|---|---|---|---|
| BATCH (기본) | 1 | 20,000 | 124 | 0 |
| BATCH (기본) | 2 | 20,000 | 905 | 0 |
| BATCH (기본) | 3 | 20,000 | 0 — 증명하지 못함 | 0 |
| 자동 커밋 100ms | 1 | 20,000 | 1,909 | 0 |
| 자동 커밋 100ms | 2 | 20,000 | 0 — 증명하지 못함 | 0 |
| 자동 커밋 100ms | 3 | 20,000 | 30 | 0 |
자동 커밋으로도 유실은 없었다. 6회 전부 거래내역이 20,000행이고 같은 참조가 두 줄 생긴 경우도 없다. 이유는 spring-kafka의 처리 방식에 있다. 리스너는 poll이 돌려준 레코드를 같은 스레드에서 순서대로 처리하고 그 뒤에야 다음 poll()을 부르는데, Kafka 클라이언트의 자동 커밋은 바로 그 다음 poll() 안에서 일어난다. 커밋되는 오프셋은 언제나 이미 처리가 끝난 배치의 것이다. “자동 커밋 = 유실”이 성립하려면 처리가 poll 스레드 밖(스레드 풀, 비동기)으로 나가야 하고, 이 코드베이스에는 그런 소비자가 없다.
달라진 것은 중복의 양뿐이다. 자동 커밋은 100ms 간격이라 커밋 시점이 배치 경계와 어긋나고, 그 차이만큼 미커밋 구간이 달라진다. 1,909건이 한 번 나왔지만 3회로는 분포를 말할 수 없다. 어느 쪽이든 멱등 소비자가 흡수했다. 6회 중 2회는 중복을 만들지 못했는데, 죽는 순간이 배치 사이면 미커밋 구간이 없기 때문이다.
이 실험에서 하니스 실수도 하나 나왔다. 처음에는 “30초 동안 행이 안 늘면 끝”으로 판정했더니 유실 2,818~2,987건이 나왔다. 세션 시간 45초가 지나기 전에 살아남은 인스턴스를 내려 버린 것이었고, 판정 창을 90초로 늘리자 0이 됐다. 실험 스크립트의 판정 창이 대상 시스템의 타임아웃보다 짧으면 없는 유실을 만든다. 4편의 “측정 도구도 틀린다”에 한 줄 추가됐다.
결론은 “커밋 방식이 유실을 가른다”가 아니라 “처리와 커밋의 순서가 가른다”이고, 그 순서는 리스너가 동기적이라는 사실이 지킨다. 그 전제가 깨지면(비동기 처리 도입) 자동 커밋은 즉시 유실이 되고, 그때는 이 실험을 다시 돌려야 한다.
실험 2: acks를 낮추면 Outbox의 PUBLISHED가 거짓이 되는가
ADR-005는 “브로커가 기록한 뒤에만 PUBLISHED”라고 주장하고 근거로 acks=all을 든다. 그 주장을 브로커를 죽여서 확인했다(M-016). 발행기 1대, N건 적재, PUBLISHED가 kill-at건에 닿는 순간 Redpanda 컨테이너를 docker kill -s KILL로 죽였다가 7초 뒤 다시 올린다. 발행기가 남은 것을 재시도해 PENDING이 0이 될 때까지 기다린 뒤, 토픽을 워터마크만큼 그대로 읽어 eventId 집합을 만들고 DB의 PUBLISHED 집합과 비교한다. acks가 all이 아니면 멱등 프로듀서를 함께 끈다(켤 수 없다).
| acks | 실행 | “PUBLISHED인데 브로커에 없음” |
|---|---|---|
| all (기본) | 3 | 0 · 0 · 0 |
| 1 | 6 | 100 · 0 · 0 · 100 · 100 · 200 |
| 0 | 7 | 99 · 0 · 0 · 0 · 100 · 200 · 0 |
0인 실행은 전부 “증명하지 못함”이다. 어느 설정에서도 브로커 중복은 0이었다.
acks=all에서는 3회 전부 0건이다. 20,000건이 PUBLISHED이고 20,000건이 브로커에 있다. 브로커가 죽어 있던 7초 동안 보낸 것은 TimeoutException으로 실패해 PENDING으로 남았고, 브로커가 돌아온 뒤 재시도로 나갔다. 멱등 프로듀서라 재시도가 중복을 만들지 않았다.
acks=1은 6회 중 4회, acks=0은 7회 중 3회 유실이 생겼다. 유실은 정확히 100·200건, 발행 배치 크기(100)의 배수다. 브로커가 “받았다”고 답한(1) 또는 소켓에 썼다는 이유로 성공으로 본(0) 배치가 프로세스와 함께 사라졌고, Outbox는 그것을 PUBLISHED로 적었다. DB는 발행됐다고 말하고 브로커에는 없다. 이것을 알아낼 방법은 나중에 브로커를 읽어 대조하는 것뿐이고, 운영에서는 아무도 그렇게 하지 않는다. 소비자 입장에서 그 이벤트는 존재하지 않는다. 거래내역에도 정산에도 오지 않고, 5편의 두 겹 방어선은 도착하지 않은 이벤트에 대해 아무것도 할 수 없다.
acks=1에서 유실이 난 것은 Redpanda 단일 노드가 리더 반영 시점에 응답하고 디스크 반영은 뒤로 미루기 때문이다. 노드가 여럿이면 복제본이 살아 있을 수 있지만, 그것도 acks=all의 보장이지 acks=1의 보장이 아니다. 재현은 타이밍에 달려 있다. kill 순간에 확인 대기 중인 배치가 있어야 하고, 발행이 빠르면(20,000건 조건) 그 창이 좁다. 10,000건 조건에서 더 자주 잡힌 것은 창의 크기 차이로 보이지만 따로 재지는 않았다.
결론: Outbox 패턴은 발행 확인이 진짜일 때만 안전하고, 확인의 진위는 acks가 정한다. 5편에서 “ACK 받은 것만 PUBLISHED로 표시한다”를 가장 중요한 결정이라고 썼는데, 그 ACK가 무엇을 뜻하는지는 브로커 설정이 정한다는 것을 이 실험이 보여줬다.
실험 3: 파티션 키를 잘못 두면 돈이 틀리는가
5편 결함 F는 발행기가 여러 대일 때 순서가 깨져 정산이 틀리는 것을 고쳤고, 그 뒤 “같은 결제의 이벤트는 같은 파티션”이 순서의 근거가 됐다. 파티션 키를 결제 ID가 아닌 값으로 두면 무슨 일이 나는지, 결함 F가 단위 테스트로 고정한 조건(10,000원 결제·4,000원 취소 → 판매자 3,600원 과지급)이 실제 브로커와 실제 소비자에서 금액으로 재현되는지 봤다(M-017).
결제 2,000건에 대해 OrderConfirmed(정산 가능 10,000원)와 그 1µs 뒤 PaymentCancellationCompleted(4,000원)를 적재한다. 대조군은 둘 다 결제 ID를 키로, 실험군은 서로 다른 랜덤 UUID를 키로. 발행기 1대, 정산 소비자 1대(파티션 3개를 한 스레드가 번갈아 읽음). 결제별 정산 항목 합이 올바르면 10,000 − 1,000(수수료) − 4,000 + 400(수수료 환급) = 5,400원, 취소가 버려지면 9,000원이다.
| 파티션 키 | run | 도착 순서 역전 | 버려진 취소 | 9,000원(과지급) | 과지급 합계 |
|---|---|---|---|---|---|
| Aggregate ID (기본) | 1–3 | 0 · 0 · 0 | 0 | 0 | 0원 |
| 랜덤 | 1 | 646 | 646 | 646 | 2,325,600원 |
| 랜덤 | 2 | 664 | 664 | 664 | 2,390,400원 |
| 랜덤 | 3 | 653 | 653 | 653 | 2,350,800원 |
랜덤 키에서 결제의 32~33%가 뒤집혔고, 뒤집힌 결제는 전부 정확히 3,600원씩 과지급됐다. 세 번 모두 도착 순서 역전 = 버려진 취소 = 9,000원 결제 수로 세 숫자가 일치한다. 2,000건에 235만 원이고, 결제가 많아지면 비례한다. 1/3이 뒤집히는 이유는 두 이벤트가 서로 다른 파티션에 갈 확률이 2/3이고, 그중 절반이 취소 쪽 파티션을 먼저 읽는 순서에 걸리기 때문이다. 소비자가 한 스레드로 파티션을 번갈아 읽으므로 “어느 파티션이 먼저”는 우연이다.
소비자는 “확정 전 취소”와 “역순 도착”을 구분할 수 없다. 둘 다 “이 결제의 정산 항목이 아직 없다”로 보인다. 그래서 순서는 소비자가 아니라 파티션 키와 발행기가 지켜야 한다. 소비자 쪽에서 막으려면 취소를 보류했다가 확정이 오면 적용하는 상태 기계가 필요하고, 그것은 새 불변조건과 새 복구 경로를 만든다(5편에서 발행 측 직렬화를 택한 이유).
이 실험에서 가장 불편한 사실은 따로 있다. 불변조건 지표는 이것을 잡지 못한다. 원장은 균형이 맞고, 취소도 정상 전기됐다. 틀린 것은 정산 항목이 빠진 것이고, 빠진 것은 원장 안의 어떤 합계로도 드러나지 않는다. 파티션 키는 설정이 아니라 정확성의 일부이며, 잘못 두면 시스템은 아무 오류 없이 판매자에게 돈을 더 보낸다.
실험 4: 파싱할 수 없는 레코드 하나가 파티션을 얼마나 막는가
DLT(dead-letter topic)도 재시도 토픽도 없다. 그 상태에서 파싱할 수 없는 레코드 하나가 파티션에 들어오면 같은 파티션의 뒤 이벤트가 얼마나 멈추는지, 그 레코드는 어떻게 되는지 그대로 기록했다(M-018). 정상 이벤트 100건을 흘려 소비자를 붙인 뒤, 파티션 1에 this is not a json envelope 한 줄을 넣고, 이어서 정상 이벤트 10,000건을 적재한다. 정지 시간은 poison 뒤에 발행된 레코드의 (소비 시각 − 발행 시각)을 파티션별로 비교해 잰다.
| run | 유실 | 파티션 1 첫 레코드 지연 | 파티션 0 | 파티션 2 | DLT |
|---|---|---|---|---|---|
| 1 | 0 | 1,570ms | 155ms | 63ms | 없음 |
| 2 | 0 | 1,339ms | 70ms | 13ms | 없음 |
| 3 | 0 | 1,289ms | 102ms | 11ms | 없음 |
정지는 약 1.3~1.6초였고 그 뒤 파티션은 다시 흘렀다. spring-kafka의 기본 오류 핸들러가 FixedBackOff(0ms, 9회)로 10회 즉시 재시도한 뒤 로그 한 줄을 남기고 그 레코드를 건너뛴다. 재시도마다 리스너의 트랜잭션이 열렸다 롤백되고 같은 poll에 있던 뒤 레코드들이 다시 fetch된다. 그것이 1초대의 정체다. 정상 이벤트는 하나도 잃지 않았다.
문제는 poison 레코드 자체가 사라진다는 것이다. ERROR 로그 한 줄로만 남고 지표도 경보도 없다. 이번 실험의 poison은 우리 봉투가 아닌 쓰레기라 잃어도 된다. 그러나 같은 경로를 우리 이벤트가 역직렬화 버그나 스키마 불일치로 탈 수 있다. 그러면 Outbox에는 PUBLISHED로 남고 브로커에도 있는데 소비자가 10번 실패하고 버린 이벤트가 되고, 거래내역·정산에서 조용히 빠진다. 원장은 맞으므로 불변조건 지표가 잡지 못하고, 대사도 외부 기록과 비교하는 것이라 잡지 못한다. 소비자 쪽 “정확히 한 번”의 반대편, 한 번도 아님이 감시되지 않는다.
이것이 결함 M이고, 결함 목록의 13번째다. 지금까지의 소비자 시험은 전부 올바른 봉투로 했다. 처리할 수 없는 봉투를 넣어 본 것이 이번이 처음이라 이제야 보였다.
조치는 Outbox 쪽과 같은 모양으로 맞췄다. Outbox에는 이미 FAILED 상태와 운영자 재처리 API가 있었으므로 소비자 쪽에도 같은 것을 뒀다.
- 재시도를 다 쓴 레코드는
dead_letter_event표와paritypay.events.dlt토픽에 남기고, 그 기록이 확인된 뒤에야 오프셋을 넘긴다(DeadLetterRecoverer). 기록 전에 오프셋이 넘어가면 DLT 자체가 유실 지점이 된다. - 파싱 실패는 재시도하지 않는다. 열 번 다시 읽어도 같은 바이트다. 일시 오류만 1초 간격으로 2회.
- 카운터
paritypay.consumer.dead_letters와 게이지dead_letters_open을 내보내고, 게이지가 0이 아니면 critical 경보다. 불변조건 지표와 같은 자리이고 같은 규칙이다. 평소 0이어야 하고, 0이 아니면 그 자체가 장애다. - 운영자 API
GET/POST /api/v1/admin/dead-letters[/{id}/retry]. 같은 키와 payload를 원 토픽에 재발행하고 감사 로그를 남긴다.
고친 뒤 같은 실험을 3회 다시 돌렸다. 파티션 1 첫 레코드 지연은 1,289~1,570ms에서 126~486ms로 줄어 다른 파티션과 구분되지 않는다. 정상 이벤트 유실 0/10,100, DLT 행 2건·레코드 2건(소비자 그룹 둘이 각각 하나씩). 통합 테스트 2건이 이 경로를 고정한다. 결함 A~M 열세 건이 전부 수정된 상태다.
막는 것과 못 막는 것
네 실험을 한 표로 놓으면 어떤 장치가 어떤 장애를 맡는지가 드러난다.
| 장애 | 막는 장치 | 실측 |
|---|---|---|
| 소비자 종료로 인한 중복 (커밋 방식 무관) | 멱등 소비자 (consumed_event + 업무 유니크 키) | 중복 30~1,909건 도착, 결과는 정확히 1회 |
| 소비자 종료로 인한 유실 | 처리 후 커밋 순서 (동기 리스너) | 유실 0. 비동기 처리 도입 시 재검증 필요 |
| 브로커 종료로 인한 유실 | acks=all + 멱등 프로듀서 | all 0건, 1·0은 배치 단위 유실. Outbox는 이것을 막지 못한다 |
| 순서 역전 | 파티션 키 = Aggregate ID + 발행기의 파티션 키별 선두 선점 | 랜덤 키 시 33% 역전, 3,600원 × N 과지급. 소비자와 불변조건 지표는 막지 못한다 |
| 처리 불가 레코드 | 실험 시점에는 없음 (10회 즉시 재시도 후 폐기) → DLT + 실패 이력 + 경보 + 재처리 API | 1.3~1.6초 정지, 레코드 소실, 감시 없음(결함 M) → 126~486ms, 레코드 보존 |
멱등 소비자와 Outbox는 자기 자리에서는 잘 동작했다. 못 막은 것은 그 두 장치의 범위 밖에 있었다. 유실 방지는 acks가, 순서는 파티션 키가 맡고 있었고, 처리 불가는 아무것도 맡고 있지 않았다. 마지막 칸은 실험이 끝난 뒤에야 채워졌다.
한계
- 네트워크 단절(응답만 유실) 조건이 없다. 브로커를 죽였으므로 재시도가 전부 실패로 끝났고, 그래서 멱등 프로듀서를 끈
acks=1·0에서도 브로커 중복이 0이었다. 응답만 유실되는 조건이었다면 중복이 생겼을 것이다. - 단일 노드다. ISR 감소, 리더 교체, 복제본 지연은 재현할 수 없다.
acks=1의 유실이 다중 노드에서 어떻게 달라지는지는 이 실험 밖이다. - 3회 반복으로는 분포를 말할 수 없다. 중복 건수(30~1,909), 유실 재현율(4/6, 3/7)은 “생긴다·안 생긴다”까지만 말한다.
- 비동기 소비자가 없다. 실험 1의 결론은 리스너가 동기적이라는 전제 위에 있다.
실무 경험과의 관계
MDS의 SQS 파이프라인에서 중복은 메시지 ID를 처리 결과와 같은 트랜잭션에 기록해서 막았고, 순서는 큐가 아니라 데이터의 상태 전이로 다뤘고, 실패는 재시도 상한과 DLQ로 격리했다. 세 가지 모두 이 글의 실험과 짝이 있다.
중복은 같다. 실험 1의 멱등 소비자가 그 글의 메시지 ID 기록이다. 순서는 다르다. MDS에서는 앨범 수정이 등록보다 먼저 와도 상태가 맞지 않으면 처리하지 않고 재시도로 넘기면 됐다. 소비자가 “아직 아니다”를 판단할 수 있는 도메인이었다. 실험 3의 정산 소비자는 그 판단을 할 수 없다. “확정 전 취소”와 “역순 도착”이 같은 모양이기 때문이다. 상태 전이로 순서를 흡수할 수 있는지는 도메인이 정하고, 없으면 발행 측이 지켜야 한다. 그 글을 쓸 때는 이 구분을 몰랐다.
실패 격리는 반대 방향이다. SQS 글에서 “DLQ 이후 절차가 없으면 격리가 아니라 유실”이라고 썼는데, 실험 4의 소비자에는 DLQ 자체가 없었다. 실무에서 당연히 있던 것이 개인 프로젝트에서는 빠져 있었고, 올바른 봉투로만 시험하는 동안 빠진 것이 보이지 않았다. 결함 M은 새로운 종류의 문제가 아니라 실무에서 이미 알던 것을 이 프로젝트에 옮기지 않은 것이다. 옮기고 나서 한 가지가 더 붙었다. DLT에 적었다는 확인을 받은 뒤에야 오프셋을 넘긴다는 규칙이다. 격리 장치 자체가 유실 지점이 되지 않게 하는 조건이고, SQS 글에는 없던 문장이다.
정리
- 자동 커밋은 이 코드베이스에서 유실을 만들지 않았다. 유실을 가르는 것은 커밋 방식이 아니라 처리와 커밋의 순서이고, 동기 리스너가 그 순서를 지킨다. 전제가 바뀌면 실험도 다시 해야 한다.
acks=1·0에서 Outbox는 브로커에 없는 이벤트를 PUBLISHED로 적는다. Outbox는 발행 확인이 진짜일 때만 안전하고, 확인의 진위는acks가 정한다.- 파티션 키는 정확성의 일부다. 랜덤 키는 결제의 1/3을 뒤집었고 뒤집힌 결제마다 3,600원이 더 나갔다. 소비자도 불변조건 지표도 이것을 잡지 못한다.
- 처리할 수 없는 레코드는 10회 즉시 재시도 뒤 조용히 사라졌다. “한 번도 아님”을 감시하는 장치가 없었고, 그것이 13번째 결함이다. DLT·실패 이력·경보·재처리 API를 붙였고, DLT 기록이 확인된 뒤에만 오프셋을 넘긴다.
- 재현되지 않은 실행은 성공이 아니다. 판정 창이 대상의 타임아웃보다 짧으면 없는 유실이 보인다.
댓글
아직 댓글이 없습니다