Kafka는 결국 At-Least-Once다: 멱등성(idempotency) + processed_event + dedupe로 “운영 가능한 정확성” 만들기
Kafka를 처음 쓸 때 누구나 한 번은 “Exactly-once(EOS)로 끝내면 되지 않나?”라는 기대를 합니다.
하지만 운영에 들어가면 결론은 꽤 빨리 정리됩니다.
현실의 Kafka 파이프라인은 At-Least-Once를 전제로 설계해야 한다.
그러니 “중복이 생긴다”를 받아들이고, 중복이 결과를 망치지 않게 만드는 쪽이 더 강하다.
이 글은 그 결론을 바탕으로:
- 왜 EOS가 어렵고(특히 Kafka 밖으로 나가는 순간)
- At-Least-Once에서 중복이 어떻게 생기며
- 이를 멱등성 / processed_event / dedupe로 어떻게 설계하는지
를 예시와 함께 기술 블로그 형태로 정리합니다.
TL;DR
- “메시지는 최소 1번은 온다”가 아니라 “같은 메시지가 2번 이상 올 수 있다”가 운영의 기본값입니다.
- 그래서 컨슈머는 반드시:
- 멱등 키(dedup_key / event_id) 를 기준으로
- “이미 처리했는지”를 원자적으로 판단하고
- 중복이면 아무 일도 하지 않는 방식으로 설계해야 합니다.
- 가장 실용적인 표준 패턴은:
- processed_event 테이블(또는 KV/Redis/캐시) + 비즈니스 변경을 같은 트랜잭션으로 묶기
- 또는 UPSERT/조건부 UPDATE 같은 “DB 레벨 멱등 쓰기”입니다.
1) 왜 Kafka에서 EOS(Exactly-once)가 ‘현실적으로’ 어려운가?
Kafka 자체는 트랜잭션/멱등 프로듀서/read_committed 같은 기능을 제공합니다.
그런데 “진짜” 문제는 대부분 여기서 발생합니다.
1.1 Kafka 내부 vs Kafka 밖(외부 시스템)
- Kafka → Kafka: 트랜잭션을 써서 “produce + offsets”를 묶으면 EOS에 가까워질 수 있음
- Kafka → DB / Kafka → 외부 API: 여기서부터는 Kafka 트랜잭션이 “바깥 시스템”을 원자적으로 묶어주지 못합니다
즉, Kafka가 아무리 잘해도 다음을 완전히 없애진 못합니다.
- DB 쓰기는 성공했는데 offset commit이 실패 → 재시작 후 같은 메시지 재처리
- offset commit은 됐는데 DB 쓰기가 실패 → 메시지는 “처리된 걸로” 보이는데 DB에는 반영 안 됨
- 외부 API가 “성공했는지/실패했는지” 애매한 타임아웃 → 재시도하면 중복 호출 가능
결론적으로 운영 설계는 이렇게 가야 합니다.
중복은 생긴다.
그러니 중복이 결과에 영향을 못 주게 만들자.
2) At-Least-Once에서 중복은 어떻게 생기나? (대표 시나리오 3개)
여기서 “중복”은 Kafka가 나쁜 게 아니라, 분산 시스템의 자연스러운 결과입니다.
시나리오 A) DB 반영 성공 → offset commit 실패
- 컨슈머가 메시지를 처리하고 DB에 반영(성공)
- offset commit 직전에 프로세스가 죽거나 네트워크가 끊김
- 재시작하면 같은 메시지를 다시 읽음
- DB에 동일 반영이 2번 발생 (중복)
시나리오 B) 처리 도중 리밸런스/세션 만료
- 폴링 지연, GC stop-the-world, 네트워크 지연 등으로 컨슈머가 그룹에서 떨어지면
- 같은 파티션을 다른 인스턴스가 가져가고
- 어떤 레코드는 “처리됐는지 애매한 상태”로 중복 처리될 수 있습니다.
시나리오 C) 프로듀서/릴레이 재시도(재전송)
- 프로듀서는 네트워크 타임아웃이면 “전송 성공인지 실패인지”를 확신하기 어렵습니다.
- 그래서 재시도를 하면 같은 이벤트가 다시 발행될 수 있습니다.
- 특히 Outbox/Relay 같은 구조는 “재전송”을 전제로 하므로 중복 가능성을 반드시 포함합니다.
3) 핵심 전략: “멱등 키(dedup_key)”를 이벤트 계약에 포함하라
멱등성을 설계하려면 “이 메시지가 이전에 처리한 것과 같은 의미인가?”를 판별할 수 있어야 합니다.
그 판단의 기준이 dedup_key (또는 event_id) 입니다.
3.1 좋은 dedup_key의 조건
- 같은 의미의 이벤트는 항상 같은 키
- 재시도/재전송/리플레이가 와도 키가 변하지 않음
- 업무적으로 자연스럽고(트랜잭션 ID, 엔티티 버전 등) 충돌 가능성이 낮음
예시(권장 방향):
dedup_key = order:{order_id}:{event_type}:{event_version}dedup_key = {tx_id}:{sequence}(업무 트랜잭션 ID + 이벤트 시퀀스)- “시간 버킷”을 넣을 때는 정말 같은 의미인지 주의 (버킷이 달라지면 다른 키가 됨)
4) 표준 패턴 1: processed_event 테이블로 컨슈머를 멱등하게 만들기
가장 보편적이고 강한 패턴은:
(1) dedup_key를 먼저 기록해 ‘처리권’을 확보하고
(2) 비즈니스 반영을 수행하고
(3) 같은 트랜잭션으로 커밋한다
4.1 processed_event 테이블 예시
CREATE TABLE processed_event (
dedup_key VARCHAR(256) PRIMARY KEY,
processed_at DATETIME(3) NOT NULL
);
4.2 처리 흐름(핵심)
dedup_key를 PK로 두고 INSERT를 먼저 시도합니다.- 이미 처리했다면 PK 충돌이 나므로 “중복 이벤트”로 판단하고 스킵합니다.
- INSERT가 성공한 경우에만 비즈니스 반영을 하고 같은 트랜잭션으로 커밋합니다.
의사 코드 (Python 스타일)
def handle_event(event):
dedup_key = event["dedup_key"]
with db.transaction():
inserted = db.execute(
"INSERT INTO processed_event(dedup_key, processed_at) VALUES (?, now(3))",
[dedup_key],
on_conflict="DO_NOTHING",
)
if not inserted:
return # 이미 처리된 이벤트 → 멱등 스킵
# 여기부터는 최초 1회만 실행되어야 하는 비즈니스 반영
db.execute(
"UPDATE account SET balance = balance + ? WHERE id = ?",
[event["amount"], event["account_id"]],
)
포인트: processed_event insert + 비즈니스 변경이 같은 DB 트랜잭션이어야 합니다.
둘이 분리되면 “처리 마킹만 되고 실제 반영은 안 됨” 같은 불일치가 다시 생깁니다.
5) 표준 패턴 2: “DB 자체를 멱등하게” 쓰기 (UPSERT / 조건부 UPDATE)
processed_event 테이블은 범용적이지만, 상황에 따라선 “목표 테이블의 설계”만으로도 멱등을 만들 수 있습니다.
5.1 예시: 주문 상태 전이(중복 이벤트에 안전)
- 이벤트:
ORDER_PAID(order_id, paid_at, dedup_key) - 반영 규칙: 이미 PAID면 다시 PAID로 만들어도 결과는 동일해야 함
UPDATE orders
SET status = 'PAID', paid_at = COALESCE(paid_at, :paid_at)
WHERE id = :order_id;
이 방식은:
- 같은 이벤트가 2번 들어와도 결과가 변하지 않게(멱등) 만들 수 있습니다.
5.2 “증가형 집계”는 더 주의(가장 자주 터짐)
예: 클릭 수/포인트 적립처럼 count = count + 1 형태는 중복에 매우 취약합니다.
이때는 processed_event 같은 “1회성 보장 장치”가 특히 중요합니다.
6) 예시로 보는 설계: “적립 이벤트”가 2번 들어오는 사고를 막기
6.1 요구사항
- 이벤트:
POINT_EARNED(user_id, amount, dedup_key) - 중복 처리되면 돈이 2번 적립되는 치명적인 사고
6.2 멱등 컨슈머 설계(정석)
processed_event에dedup_keyinsert 시도- 성공했으면
user_point테이블 갱신 - 트랜잭션 커밋
- 중복이면 아무것도 하지 않음
이렇게 하면:
- 재시작/리밸런스/리플레이/재전송이 와도
- dedup_key가 동일한 한 결과는 정확히 1번만 반영됩니다.
7) Dedupe의 저장소는 꼭 RDB여야 하나? (선택지와 트레이드오프)
7.1 RDB(Processed table) — 가장 강함
- 장점: 원자성/영속성/리플레이에 강함
- 단점: 쓰기 부하(모든 이벤트마다 insert)
7.2 Redis / 캐시 기반 dedupe — 고속이지만 “정확성” 한계
- 장점: 빠름
- 단점: TTL 만료/eviction/장애 시 중복 방어가 깨질 수 있음
- 돈/정산/주문 같은 도메인에는 위험
7.3 로그 컴팩션(topic compaction)로 key 기반 dedupe?
- 특정 상황(최종 상태만 중요, key 기반 상태 저장)에는 유효하지만
- “한 번만 반영” 같은 트랜잭션성 요구에는 단독 해법으로 부족한 경우가 많습니다.
실무 결론은 보통 이렇습니다.
정확성이 중요한 쓰기(side effect) 는 RDB 기반 멱등이 기본값이고,
“대충 중복이면 괜찮은” 이벤트는 캐시/확률적 dedupe도 고려할 수 있습니다.
8) 운영 체크리스트: 멱등성은 코드만이 아니라 “운영”까지 포함한다
- 이벤트에
dedup_key(또는 event_id)가 항상 포함되는가? - 컨슈머가 dedupe를 원자적으로 처리하는가? (같은 트랜잭션)
- 리플레이/재처리를 수행해도 데이터가 깨지지 않는가? (테스트/리허설)
- dedup 저장소(processed_event)가 커질 때 보관/파티션/TTL 정책이 있는가?
- 중복률/스킵률/DB 충돌률 같은 운영 지표를 관측하는가?
마무리: “중복이 없다”는 꿈보다, “중복이 무해한 시스템”이 더 현실적이다
Kafka 기반 시스템을 운영하다 보면, 결국 목표는 EOS라는 문구가 아니라 이겁니다.
- 메시지가 몇 번 오든
- 순서가 가끔 흔들리든
- 재시작/리밸런스/리플레이가 언제든 발생하든
결과가 틀리지 않는 시스템
그 출발점이 dedup_key이고, 가장 검증된 무기가 processed_event 기반 멱등 컨슈머입니다.
원하시면 다음 편으로:
- dedup_key 설계 가이드(도메인별 템플릿)
- processed_event 테이블 운영(보관기간/파티셔닝/정리 잡)
- “정확성이 중요한 이벤트 vs 덜 중요한 이벤트” 분류 기준 까지 이어서 정리해드릴게요.