Outbox 패턴으로 Kafka Exactly-Once에 가까워지기
이번 포스트에서는 Debezium(CDC)을 사용하지 않고,
Outbox 패턴을 활용해 effectively exactly-once를 달성하는 구조를 정리해보려고 합니다.
분산 시스템에서
“메시지를 정확히 한 번 처리한다(Exactly-Once)” 는 요구사항은 매우 흔하지만,
이를 end-to-end로 완벽하게 보장하는 것은 현실적으로 거의 불가능합니다.
Kafka, DB, 외부 API는 각각 독립적인 시스템이며
이들을 하나의 트랜잭션으로 묶을 수 없기 때문입니다.
그래서 실무에서는 보통 다음을 목표로 합니다.
중복은 허용하되,
최종 비즈니스 결과(effect)는 정확히 한 번만 반영되도록 설계한다.
1. 문제 배경
DB update → Kafka produce
이 구조에서는 다음 문제가 발생할 수 있습니다.
- DB는 반영됐지만 Kafka 발행 실패 → 이벤트 유실
- Kafka는 발행됐지만 DB 트랜잭션 롤백 → 잘못된 이벤트 전파
- 네트워크 장애로 성공/실패 여부를 알 수 없음
➡️ DB와 Kafka 사이의 정합성이 깨짐
2. Outbox 패턴 개요
Outbox 패턴의 핵심 아이디어는 단순합니다.
이벤트 발행을 DB 트랜잭션의 일부로 만든다.
즉,
- 비즈니스 데이터 변경
- 발행할 이벤트 기록
을 같은 트랜잭션 안에서 처리합니다.
Kafka 발행은 이후 **비동기 Relay(배치/스케줄러)**가 담당합니다.
3. 전체 아키텍처 흐름
1) Producer (Application)
- 비즈니스 로직 수행
- Outbox 테이블에 이벤트 INSERT
- 하나의 DB 트랜잭션으로 커밋
이 단계에서:
- Kafka 장애와 무관하게 이벤트는 DB에 안전하게 저장된다
- 애플리케이션 장애가 발생해도 이벤트 유실은 없다
2) Outbox Relay (폴링 기반)
- Outbox 테이블에서
PENDING상태 이벤트 조회 - Kafka로 메시지 발행
- 성공 시
SENT상태로 변경 - 실패 시 재시도
이 과정에서:
- 네트워크 오류, Kafka 장애로 발행 실패 가능
- 타임아웃으로 인한 중복 발행 가능성 존재
- 대신 최소 한 번(at-least-once) 발행은 보장
3) Consumer
- Kafka 메시지 소비
eventId기준으로 이미 처리된 이벤트인지 확인- 처음 처리하는 이벤트만 비즈니스 로직 수행
- 처리 이력 DB에 기록
메시지는 중복 소비될 수 있지만,
DB 멱등 처리로 비즈니스 결과는 정확히 한 번만 반영됩니다.
4. Outbox 테이블 예시
id(PK)event_id(전역 유일)payloadstatus(PENDING,SENT)created_at
실무에서는 Outbox row를 즉시 DELETE하기보다
SENT 상태로 남긴 뒤 TTL 또는 배치로 정리하는 방식이 더 안전합니다.
(감사 로그, 재처리, 리플레이 대응)
5. Consumer 멱등 처리 방식
Consumer는 반드시 중복 처리를 방지해야 합니다.
대표적인 방법:
processed_event테이블에event_id를 PK로 저장- 또는 비즈니스 테이블에
event_id UNIQUE제약 추가
이렇게 하면:
- 동일 메시지가 여러 번 와도
- DB 반영은 단 한 번만 발생
6. 이 구조가 보장하는 수준
| 구간 | 보장 수준 |
|---|---|
| DB → Outbox | Exactly-once |
| Outbox → Kafka | At-least-once |
| Kafka → Consumer | At-least-once |
| 최종 비즈니스 결과 | Effectively exactly-once |
즉,
전달은 최소 한 번,
결과는 정확히 한 번
7. 왜 “완전한 Exactly-Once”가 아닐까?
- Kafka 발행은 중복될 수 있다
- Consumer는 재처리될 수 있다
- 네트워크 장애로 성공 여부를 확정할 수 없다
따라서 물리적인 의미의 Exactly-once는 아닙니다.
하지만,
eventId기반 멱등 처리- 유니크 제약
- 처리 이력 관리
를 통해 비즈니스 관점에서는 Exactly-once와 동일한 효과를 얻게됩니다.
8. 주의사항
-
eventId설계가 가장 중요- 재시도/재발행에도 동일해야 함
- 전역 유일성 필수
-
중복 발행은 정상 동작
- Consumer 멱등 처리는 선택이 아니라 필수
-
외부 API 포함 시
- Idempotency-Key 지원 여부가 핵심
- 없으면 호출 로그 + 재시도/보상 트랜잭션 필요
마치며
분산 시스템에서
물리적으로 정확한 Exactly-once를 end-to-end로 보장하는 것은 현실적으로 불가능합니다.
대신 실무에서는 이벤트 발행 과정에서의 유실을 막고, 중복 전달은 허용하되 비즈니스 결과는 한 번만 반영되도록 설계하는 것이 더 중요합니다.
이를 위해
Outbox 패턴으로 이벤트 발행을 최소 한 번 보장하고,
Consumer에서 eventId 기반 멱등 처리를 적용함으로써
end-to-end로는 결과가 정확히 한 번만 반영되도록 설계할 수 있습니다.
Exactly-once는 설정의 문제가 아니라,
어디에서 중복을 허용하고 어디에서 막을 것인지에 대한 설계의 문제입니다.
