결제가 완료되면 '결제 완료' 이벤트를 큐에 발행하고, 정산 서비스가 받아서 장부에 기록한다. 네트워크가 흔들리거나 컨슈머가 재시작되면 메시지는 사라질 수도(유실), 두 번 도착할 수도(중복) 있다. 유실을 감수할지, 중복을 받아들이고 내가 막을지, 브로커가 약속하는 'exactly-once'를 믿을지 정해야 한다.
언제: 돈·재고·상태 전이처럼 한 건이라도 사라지면 안 되는 메시지 전부 — 사실상 거의 모든 업무 이벤트. 브로커는 ack를 못 받으면 재전송하게 두고(at-least-once), 중복은 컨슈머가 이벤트 ID에 unique 제약을 걸어 거른다. 이 조합이 현실에서 쓰는 '사실상의 exactly-once'다.
비용: 컨슈머마다 멱등 처리를 직접 구현해야 한다 — 처리 이력(또는 스테이징) 테이블, unique 제약, 충돌 시 무시 로직. 재전송 때문에 순서가 섞일 수 있고, 멱등 키 테이블이 계속 자라서 보관 기간(TTL) 정리도 챙겨야 한다.
언제: 읽기→처리→쓰기가 전부 Kafka 안에 닫혀 있는 스트림 파이프라인일 때 — 토픽을 읽어 집계해서 다시 토픽에 쓰는 매출 합계 같은 것. 중복 하나가 숫자를 바로 틀리게 만드는 연산이고, 경계 밖으로 안 나가는 게 확인됐을 때만.
비용: 'exactly-once'는 Kafka 토픽 사이에서만 성립하는 약속이고, DB나 외부 API로 한 발짝 나가는 순간 깨진다 — 그래서 거짓말이라 불린다. 트랜잭션 오버헤드로 처리량이 떨어지고 설정이 늘어나며, 그 약속을 믿고 컨슈머 멱등 처리를 빼는 순간이 제일 위험하다.
기본은 at-least-once에 멱등 컨슈머다. 돈이 걸린 이벤트는 유실이 중복보다 훨씬 비싸니, 브로커는 재전송하게 두고 중복은 내가 거른다. SK AX에서 제조 실적을 정산으로 넘길 때 이벤트를 스테이징 테이블에 unique 키로 받아 배치 워커가 멱등하게 처리했는데, 그게 정확히 이 패턴이고 월 20건이던 수동 재처리가 0이 됐다. 반대로 메트릭·로그처럼 한두 건 빠져도 그래프만 살짝 흔들리는 데이터는 at-most-once로 그냥 흘리고, 브로커 exactly-once는 Kafka 안에 닫힌 집계 구간에만 쓴다.
- 같은 이벤트가 장부에 두 번 반영되는 사고가 한 건이라도 나옴 — 브로커 설정 문제가 아니라 컨슈머 멱등 키(unique 제약)가 빠졌다는 신호
- 리밸런스·재배포 때마다 중복이 수천 건씩 몰려와 dedupe 조회 때문에 컨슈머 p99가 200ms를 넘고 컨슈머 랙이 10분 이상 벌어짐
- Kafka 안에 닫힌 집계 파이프라인인데 재시작할 때마다 합계가 어긋나 주 1회 이상 수동 보정 중 — 그 구간만 EOS(exactly_once_v2)로 올릴 신호
- 유실돼도 되는 메트릭까지 at-least-once로 보내느라 큐 깊이가 10만 건을 넘고 브로커 비용만 커짐 — at-most-once로 강등할 신호
🔧Kafka (acks=all, enable.idempotence=true)AWS SQS + DLQDB unique constraint + staging table (idempotent consumer)Kafka Streams (processing.guarantee=exactly_once_v2)consumer-lag / duplicate-rate 알람
“I assume at-least-once and make consumers idempotent — exactly-once is a promise I only trust inside one Kafka pipeline.”
checking microphone…