이 문서를 통해 아래 질문들에 답할 수 있게 됩니다.
- 중복 이벤트는 broker 버그가 아니라 어떤 정상적인 장애 복구 과정에서 생기는가?
- producer retry, consumer ack 실패, outbox 재발행, backfill, 수동 replay가 각각 어떻게 중복을 만드는가?
- 중복 delivery와 중복 side effect는 왜 구분해서 봐야 하는가?
- Java/Spring consumer와 batch job에서 중복을 전제로 어떤 로그와 key를 남겨야 하는가?
개요
중복 이벤트는 이상한 일이 아니다.
비동기 시스템이 누락을 피하려고 재시도하면 자연스럽게 생긴다.
producer는 publish 성공 여부를 확실히 모를 때 다시 보낼 수 있다.
consumer는 DB commit 후 offset commit 전에 죽으면 같은 record를 다시 받을 수 있다.
outbox publisher는 Kafka publish 성공 후 status update 전에 죽으면 같은 row를 다시 발행할 수 있다.
backfill과 수동 replay는 의도적으로 과거 입력을 다시 흘린다.
따라서 설계 질문은 “중복을 절대 막을 수 있는가”가 아니라 “중복이 와도 결과가 안전한가”다.
Producer Retry
producer는 broker 응답을 받지 못하면 실패인지 성공인지 모를 수 있다.
producer send
-> broker append success
-> ack response timeout
-> producer retry이 경우 broker에는 같은 의미의 event가 두 번 들어갈 수 있다.
Kafka producer idempotence가 일부 중복을 줄여도 application event 의미까지 항상 하나로 보장하지는 않는다.
event payload에는 eventId와 business key가 있어야 한다.
consumer는 broker record가 다르더라도 같은 eventId를 같은 입력으로 인식해야 한다.
Consumer Ack 실패
consumer 중복의 대표 시나리오는 business commit 후 ack 실패다.
poll event
-> settlement DB update commit
-> process crash before offset commit
-> restart
-> same event poll again이 경우 누락은 없다.
하지만 같은 event가 다시 들어온다.
consumer가 멱등하지 않으면 정산 row가 중복되거나 금액이 두 번 더해진다.
그래서 business update와 processed event 기록을 같은 transaction에 넣어야 한다.
Rebalance와 Timeout
consumer가 오래 처리하면 broker나 Redis가 consumer를 죽은 것으로 판단할 수 있다.
Kafka에서는 max poll interval을 넘기면 rebalance가 발생할 수 있다.
Redis Stream에서는 pending idle time이 길어져 다른 consumer가 claim할 수 있다.
원래 consumer가 늦게 성공하고, 새 consumer도 같은 메시지를 처리하면 중복 side effect가 생길 수 있다.
timeout과 claim 기준은 business 처리 최대 시간보다 짧게 잡으면 위험하다.
Outbox 재발행
outbox는 dual write 누락을 줄이지만 중복 발행 가능성을 남긴다.
outbox row PENDING
-> Kafka publish success
-> status update failure
-> row remains PENDING
-> publisher republishes이것은 outbox 버그라기보다 at-least-once 발행의 자연스러운 결과다.
outbox row id나 event id를 consumer dedup key로 사용해야 한다.
outbox publisher 지표에는 republish count가 있어야 한다.
CDC 재시작
CDC connector도 재시작과 offset 복구 과정에서 같은 변경을 다시 전달할 수 있다.
특히 connector offset 저장과 Kafka publish 사이에 장애가 나면 duplicate delivery 가능성이 있다.
Debezium을 쓰더라도 downstream consumer는 멱등해야 한다.
CDC event의 source position은 운영 추적 좌표이고, business dedup key는 별도로 필요하다.
Backfill과 Replay
backfill은 과거 데이터를 다시 채우는 작업이다.
수동 replay는 특정 event 범위를 다시 흘리는 작업이다.
둘 다 의도적인 중복 입력을 만든다.
예를 들어 누락된 3일치 정산 mart를 채우려고 OrderPaid event를 다시 publish하면 이미 처리된 event도 섞일 수 있다.
그래서 backfill은 range, checkpoint, dedup key, 검증 SQL을 함께 가져야 한다.
중복 Delivery와 Side Effect
중복 delivery는 같은 입력이 두 번 도착한 것이다.
중복 side effect는 business 결과가 두 번 반영된 것이다.
둘은 다르다.
좋은 시스템은 중복 delivery를 허용하되 중복 side effect를 막는다.
duplicate delivery count = 100
duplicate side effect count = 0이 상태는 정상적인 at-least-once 운영일 수 있다.
반대로 delivery 중복 지표가 없으면 side effect 중복이 생겨도 원인을 늦게 찾는다.
Key 설계
중복을 판단하려면 key가 필요하다.
- eventId: 같은 event를 식별한다.
- aggregateId: 주문, 결제, 사용자 같은 business 단위다.
- commandId: 같은 요청 재시도를 식별한다.
- source offset: Kafka offset, Redis Stream ID, CDC position 같은 운영 좌표다.
- backfill run id: 어떤 재처리 작업에서 들어온 입력인지 식별한다.
eventId와 source offset을 혼동하면 안 된다.
offset은 위치이고 eventId는 business 입력의 정체성이다.
처리 이력 예시
CREATE TABLE processed_events (
event_id varchar(80) PRIMARY KEY,
aggregate_id varchar(80) NOT NULL,
source_type varchar(30) NOT NULL,
source_position varchar(120) NOT NULL,
processed_at timestamp NOT NULL DEFAULT now()
);이 table은 중복 방지와 장애 조사를 동시에 돕는다.
source position을 남기면 어느 Kafka offset이나 Redis Stream ID에서 왔는지 추적할 수 있다.
event id unique constraint는 같은 입력의 중복 side effect를 막는다.
운영 지표
- duplicate delivery count: 같은 event id가 여러 번 들어온 수다.
- duplicate skip count: 처리 이력 때문에 건너뛴 수다.
- duplicate side effect count: business 결과 중복 수다.
- producer retry count: 발행 재시도 수다.
- consumer redelivery count: consumer 재전달 수다.
- replay input count: backfill/replay로 다시 넣은 입력 수다.
중복 지표는 나쁜 지표가 아니다.
중복이 발생했는데 side effect가 0인지 확인하는 지표다.
개인 프로젝트 기준
개인 프로젝트에서는 중복을 직접 발생시키는 테스트를 만든다.
- 같은 event id를 두 번 publish한다.
- DB 저장 후 ack 전 crash를 흉내 낸다.
- outbox status update 실패를 흉내 낸다.
- backfill job을 같은 range로 두 번 실행한다.
각 테스트에서 business 결과가 한 번만 반영되어야 한다.
위험 신호!
- “중복은 broker가 알아서 막는다”고 설명한다.
- event payload에 event id가 없다.
- offset이나 Stream ID만으로 business 중복을 판단한다.
- backfill을 실행하면서 이미 처리된 입력을 고려하지 않는다.
- duplicate delivery와 duplicate side effect를 구분하지 않는다.
확인 질문
확인 질문
- 이 이벤트가 두 번 도착할 수 있는 경로는 무엇인가?
- producer retry, ack 실패, outbox 재발행, CDC 재시작, replay 중 어떤 것인지 답해야 한다.
- 같은 event를 두 번 받으면 어떤 key로 두 번째 입력을 알아보는가?
- event id와 business unique key가 있어야 한다.
- 중복 delivery가 실제 business 중복으로 이어지지 않았음을 어떻게 확인하는가?
- processed table, duplicate skip metric, source-target 검증 query로 답해야 한다.
참고 문서
- Apache Kafka Documentation
- Redis Streams Documentation
- Chris Richardson, Idempotent Consumer
- Martin Kleppmann, Designing Data-Intensive Applications