- Published on
Kafka Exactly-Once 깨질 때 멱등키·Outbox 패턴
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서론
Kafka를 쓰면 “Exactly-Once(정확히 한 번)” 처리가 된다고 믿기 쉽습니다. 하지만 운영에서 마주치는 현실은 다릅니다. 프로듀서 재시도, 컨슈머 리밸런스, DB 트랜잭션 경계, 외부 API 호출, 장애 복구 과정에서 중복 전송(duplicate) 과 중복 처리(duplicate processing) 는 생각보다 쉽게 발생합니다.
이 글은 다음 질문에 답하는 것을 목표로 합니다.
- Kafka의 Exactly-Once는 어디까지 보장하고, 왜 업무적으로는 깨지는지
- “중복이 발생해도 결과가 한 번만 반영”되게 하는 멱등키(idempotency key) 설계
- DB 변경과 이벤트 발행을 분리하지 않으면서도 안전하게 만드는 Outbox 패턴
- 실무에서 자주 쓰는 구현(스키마/SQL/코드)과 트레이드오프
관련 주제를 더 확장해서 보고 싶다면, 이벤트 중복·순서꼬임을 Outbox+CDC로 푸는 글인 DDD 이벤트 중복·순서꼬임? Outbox+Debezium 해법도 함께 참고하면 좋습니다.
Exactly-Once는 “Kafka 내부”에서만 성립하기 쉽다
Kafka 문서에서 말하는 Exactly-Once는 보통 EOS(Exactly Once Semantics) 를 의미합니다. 핵심은 다음 2가지입니다.
Idempotent Producer: 동일 레코드를 재전송해도 브로커가 중복 기록을 막음(프로듀서 PID/시퀀스 기반)
Transactions: 프로듀서가 여러 파티션/토픽에 쓴 레코드와 컨슈머 오프셋 커밋을 하나의 트랜잭션으로 묶어 read_committed 컨슈머가 “커밋된 것만” 읽게 함
즉, “Kafka 토픽 A를 읽어서 토픽 B에 쓰고 오프셋을 커밋하는 파이프라인”을 Kafka 트랜잭션으로 묶으면, Kafka 내부에서는 중복/유실을 크게 줄일 수 있습니다.
하지만 업무 시스템에서는 보통 Kafka 밖에 상태가 있습니다.
- DB 업데이트
- 결제/메일/푸시 같은 외부 API 호출
- 캐시/서치 인덱스 반영
이 순간부터 Exactly-Once는 “설정”이 아니라 “아키텍처” 문제가 됩니다.
Exactly-Once가 깨지는 대표 시나리오 6가지
아래는 “Kafka는 잘 설정했는데도” 중복/유실이 생기는 흔한 케이스입니다.
1) 컨슈머가 DB 반영 후 오프셋 커밋 전에 죽음
- 메시지 처리(예: 주문 상태 업데이트)는 DB에 반영됨
- 오프셋 커밋 전에 프로세스가 크래시
- 재시작 후 같은 메시지를 다시 읽어 DB가 중복 반영될 수 있음
2) 오프셋을 먼저 커밋하고 DB 반영 중 실패
- 커밋은 되었으니 Kafka는 “처리 완료”로 간주
- DB 반영 실패로 업무적으로는 유실
3) 리밸런스/세션 타임아웃으로 중복 처리
- 처리 시간이 길거나 GC/네트워크 문제로 poll 간격이 벌어짐
- 파티션이 다른 컨슈머로 넘어가며 같은 레코드가 재처리
4) 프로듀서 재시도 + 네트워크 단절
- 프로듀서가 “전송 성공 응답”을 못 받고 재시도
- 브로커에는 이미 기록되어 중복 이벤트가 생길 수 있음(특히 멱등 프로듀서/트랜잭션 미사용 시)
5) Kafka 트랜잭션과 DB 트랜잭션을 하나로 묶을 수 없음
- Kafka는 Kafka 트랜잭션을 제공하지만
- DB와 2PC로 묶기 어렵고(혹은 비용/복잡도), 대부분 피함
- 결과적으로 DB 반영과 이벤트 발행의 원자성이 깨짐
6) 다운스트림이 “최소 한 번(at-least-once)”로 동작
- 상류에서 EOS를 해도, 하류(예: 또 다른 컨슈머)가 DB에 쓰거나 외부 API를 호출하는 순간 다시 중복 가능
결론: Kafka EOS는 중요하지만, 업무 Exactly-Once는 멱등성 + 원자적 이벤트 발행 패턴이 필요합니다.
해법 1: 멱등키(Idempotency Key)로 “중복을 무해하게” 만들기
멱등성은 “같은 요청을 여러 번 처리해도 결과가 한 번 처리한 것과 동일”한 성질입니다. Kafka에서 중복은 완전히 없애기 어렵기 때문에, 실무에서는 보통 중복을 허용하되 결과를 한 번만 반영하도록 만듭니다.
멱등키는 무엇을 써야 하나?
가장 흔한 선택지는 아래 중 하나입니다.
- 이벤트 자체의 고유 ID (event_id / message_id / UUID)
- 비즈니스 키 기반 (orderId + eventType + version)
- Kafka 메타데이터 기반 (topic + partition + offset)
권장 패턴은 다음입니다.
- 업스트림이 event_id를 생성하고, 모든 다운스트림이 이를 멱등키로 사용
- event_id가 없다면 임시로
topic-partition-offset를 쓰되, 재파티셔닝/리플레이 전략까지 고려
DB에 “처리 이력 테이블”을 둔다
컨슈머가 DB에 반영할 때, 먼저 멱등키를 유니크 제약으로 기록합니다. 이미 처리된 키면 아무것도 하지 않습니다.
예시: processed_event 테이블
CREATE TABLE processed_event (
consumer_name TEXT NOT NULL,
event_id TEXT NOT NULL,
processed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (consumer_name, event_id)
);
처리 로직(같은 트랜잭션에서)
-- 1) 멱등키 선점 시도
INSERT INTO processed_event(consumer_name, event_id)
VALUES (:consumerName, :eventId)
ON CONFLICT DO NOTHING;
-- 2) 방금 insert가 성공했을 때만 비즈니스 업데이트 수행
-- (PostgreSQL이면 INSERT 결과 rowcount로 분기)
이렇게 하면 컨슈머가 크래시로 인해 같은 메시지를 다시 읽어도, 두 번째부터는 processed_event의 PK 충돌로 인해 비즈니스 업데이트가 실행되지 않습니다.
외부 API 호출도 멱등하게 만들기
DB 업데이트는 유니크 제약으로 막기 쉽지만, 외부 API는 더 까다롭습니다.
- 가능하면 외부 API가 idempotency-key를 지원하도록 사용(예: 결제/배송 API)
- 지원하지 않으면 우리 쪽에서 호출 로그 테이블을 두고 “이미 호출했는지”를 체크하거나, 호출을 Outbox로 밀어넣고 워커가 재시도 가능하게 설계
해법 2: Outbox 패턴으로 “DB 변경과 이벤트 발행”을 원자화
Outbox 패턴은 간단히 말해:
- 비즈니스 DB 트랜잭션 안에서
- 도메인 상태 변경(예: orders 업데이트)
- 이벤트(outbox 테이블 insert)
- 커밋이 성공하면 outbox 레코드가 남고
- 별도 퍼블리셔가 outbox를 읽어 Kafka로 발행
즉, DB 반영과 이벤트 생성을 하나의 원자적 커밋으로 묶습니다. “DB는 업데이트됐는데 이벤트는 못 보냈다” 또는 “이벤트는 갔는데 DB는 롤백됐다” 같은 불일치를 줄입니다.
Outbox 테이블 설계 예시
CREATE TABLE outbox (
id BIGSERIAL PRIMARY KEY,
aggregate_type TEXT NOT NULL,
aggregate_id TEXT NOT NULL,
event_type TEXT NOT NULL,
event_id TEXT NOT NULL,
payload JSONB NOT NULL,
headers JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
published_at TIMESTAMPTZ,
status TEXT NOT NULL DEFAULT 'NEW'
);
CREATE UNIQUE INDEX ux_outbox_event_id ON outbox(event_id);
CREATE INDEX ix_outbox_status_created ON outbox(status, created_at);
event_id는 멱등키로도 재사용 가능status/published_at로 퍼블리셔가 재시도 가능
애플리케이션 트랜잭션에서 함께 쓰기
예: 주문 생성 시 주문 row + outbox row를 함께 커밋
BEGIN;
INSERT INTO orders(order_id, user_id, status, total_amount)
VALUES (:orderId, :userId, 'CREATED', :amount);
INSERT INTO outbox(aggregate_type, aggregate_id, event_type, event_id, payload)
VALUES (
'Order',
:orderId,
'OrderCreated',
:eventId,
jsonb_build_object('orderId', :orderId, 'userId', :userId, 'amount', :amount)
);
COMMIT;
이제 커밋만 성공하면 “언젠가” 이벤트는 발행됩니다(최소 한 번). 발행이 지연될 수는 있어도, 유실되지는 않게 설계할 수 있습니다.
Outbox 퍼블리셔 구현: 폴링 vs CDC(Debezium)
Outbox를 Kafka로 내보내는 방식은 크게 2가지입니다.
1) 폴링 퍼블리셔(애플리케이션/워커)
- 장점: 단순, 구성 요소 적음
- 단점: 폴링 부하, 락/경합, 배치 튜닝 필요
폴링 퍼블리셔 의사 코드
# pseudo code
while True:
rows = db.query("""
SELECT id, event_id, event_type, payload
FROM outbox
WHERE status = 'NEW'
ORDER BY id
LIMIT 100
FOR UPDATE SKIP LOCKED
""")
for r in rows:
kafka.produce(
topic=map_topic(r.event_type),
key=r.event_id,
value=r.payload,
headers={"event_id": r.event_id, "event_type": r.event_type}
)
db.execute("""
UPDATE outbox
SET status='PUBLISHED', published_at=now()
WHERE id = ANY(:ids)
""", ids=[r.id for r in rows])
sleep(0.2)
핵심 포인트:
FOR UPDATE SKIP LOCKED로 멀티 워커 안전하게 병렬 처리- Kafka 발행 성공 후 상태 업데이트
- 발행 재시도 시 중복 발행 가능 → 컨슈머는 event_id로 멱등 처리
2) CDC 기반(Debezium)
- DB 트랜잭션 로그에서 outbox insert를 캡처해 Kafka로 전송
- 장점: 폴링 부하 없음, 지연이 낮고 안정적
- 단점: 운영 컴포넌트 증가, 커넥터 운영/스키마 진화 고려
이 방식의 디테일은 DDD 이벤트 중복·순서꼬임? Outbox+Debezium 해법에 더 체계적으로 정리되어 있습니다.
“Exactly-Once”에 가까워지려면: 조합 전략이 필요하다
현실적인 권장 조합은 다음과 같습니다.
1) DB를 바꾸는 컨슈머라면: 멱등키 + 트랜잭션
- 컨슈머는 메시지를 받아 DB 트랜잭션을 시작
processed_event에 event_id를 insert 시도- 성공한 경우에만 비즈니스 업데이트
- 커밋
- 오프셋 커밋은 커밋 이후(혹은 Kafka 트랜잭션으로 묶기)
이 패턴은 “중복 처리”를 무력화합니다.
2) DB 변경 후 이벤트 발행이 필요하면: Outbox
- 상태 변경과 outbox insert를 같은 DB 트랜잭션에
- outbox 발행은 최소 한 번으로 두되, 다운스트림은 멱등 처리
3) Kafka Streams/트랜잭션은 ‘Kafka 내부 파이프라인’에 집중
- 토픽 → 토픽 변환, 조인, 집계 등은 EOS가 큰 도움이 됨
- 하지만 외부 DB/외부 API가 끼는 순간 멱등성/Outbox가 더 중요해짐
실무 체크리스트: “멱등/Outbox”가 실패하는 지점
멱등키 품질
- 이벤트 ID가 전 구간에서 유지되는가(로그/트레이싱/헤더 포함)
- 재처리(replay) 시에도 동일한 event_id를 쓰는가
- 같은 비즈니스 사건에 서로 다른 event_id가 생성되지는 않는가
유니크 제약과 트랜잭션 경계
processed_eventinsert와 비즈니스 업데이트가 같은 트랜잭션인가- 충돌 시 예외 처리로 인해 전체 트랜잭션이 롤백/재시도 폭주하지 않는가
Outbox 발행의 재시도/장애 복구
- 발행 실패 시 상태가 NEW로 남아 재시도되는가
- PUBLISHED 업데이트 실패 시(부분 실패) 중복 발행이 가능한데, 컨슈머가 멱등한가
운영 관점 모니터링
- outbox 테이블 적체량(NEW 건수, oldest age)
- 퍼블리셔 처리율/에러율
- 컨슈머의 중복 차단 횟수(멱등 충돌 카운트)
장애로 인한 재시작 루프가 발생하면 메시지 중복이 더 자주 관측됩니다. 컨테이너 환경이라면 재시작 원인도 함께 점검해야 하며, 필요하면 K8s Pod CrashLoopBackOff 원인 7가지와 해결 같은 체크리스트로 기반 문제를 먼저 제거하는 것이 좋습니다.
결론
Kafka의 Exactly-Once는 강력하지만, 많은 경우 “Kafka 안에서만” 완결됩니다. DB 업데이트나 외부 API 호출이 포함되는 순간, 중복/유실은 설계로 다뤄야 합니다.
- 멱등키: 중복 메시지를 “무해”하게 만들어 업무 결과를 한 번만 반영
- Outbox 패턴: DB 상태 변경과 이벤트 생성을 한 트랜잭션으로 묶어 불일치를 최소화
- 결합 전략: Outbox는 최소 한 번 발행, 다운스트림은 멱등 처리로 수렴
이 조합이 실무에서 가장 비용 대비 효과가 좋고, 장애/리밸런스/재시도 같은 현실적인 변수를 견디는 방식입니다.