- Published on
Kafka Exactly-Once 깨질 때 Outbox 패턴 구현
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
Kafka의 Exactly-Once(이하 EOS)는 많은 팀이 기대하는 것보다 범위가 좁습니다. 브로커와 프로듀서, 컨슈머의 특정 조합에서 “중복 없이 한 번만 처리된 것처럼 보이게” 만들어주지만, 현실의 서비스는 DB 쓰기와 이벤트 발행이 함께 움직입니다. 이 경계에서 작은 타이밍 차이로 메시지 유실 또는 중복이 발생하고, 그 순간 “Kafka가 EOS인데 왜 깨지지?”라는 질문이 나옵니다.
이 글에서는 EOS가 깨지는 대표 시나리오를 재현해보고, 가장 실무적인 해법인 Outbox 패턴을 트랜잭션 관점으로 구현합니다. 예시는 MySQL을 기준으로 하지만 Postgres에도 동일하게 적용됩니다.
Kafka Exactly-Once는 어디까지 보장하나
Kafka의 EOS는 보통 다음 기능 조합을 의미합니다.
- 프로듀서 idempotence: 재시도 시 중복 레코드 생성 억제
- 트랜잭션 프로듀서: 여러 파티션에 걸친 쓰기를 하나의 트랜잭션으로 커밋
- read committed 소비: 커밋된 트랜잭션만 읽기
- 컨슈머 오프셋 커밋을 트랜잭션에 묶기: 처리와 오프셋 커밋의 원자성
하지만 여기서 중요한 전제가 있습니다.
- “Kafka 내부”의 쓰기와 읽기, 오프셋 커밋까지가 주 대상
- “외부 시스템(DB, Redis, 외부 API)”에 대한 원자적 연동은 별도 설계가 필요
즉, 애플리케이션이 DB 커밋 과 Kafka publish 를 함께 해야 하는 순간, Kafka EOS만으로는 원자성을 만들기 어렵습니다.
EOS가 깨지는 전형적인 시나리오
가장 흔한 구조는 다음과 같습니다.
- 주문 생성 트랜잭션에서 DB에 주문 저장
- 같은 요청 흐름에서
order-created이벤트를 Kafka로 발행
문제는 “둘 중 하나만 성공”하는 구간이 반드시 생긴다는 점입니다.
시나리오 A: DB 커밋 성공, Kafka 발행 실패
- DB에는 주문이 생김
- 이벤트가 발행되지 않아 다운스트림(결제, 알림, 재고)이 영원히 모름
- 재시도 로직이 없다면 유실
시나리오 B: Kafka 발행 성공, DB 커밋 실패
- 이벤트는 나갔는데 DB에는 주문이 없음
- 다운스트림이 조회하면 404, 또는 보상 트랜잭션 필요
시나리오 C: 프로듀서 재시도/타임아웃으로 인한 중복
- 네트워크 타임아웃으로 애플리케이션은 실패로 판단하고 재시도
- 실제로는 브로커에 기록되어 중복 이벤트 발생
Kafka idempotence가 어느 정도 막아주지만, 키/파티션/시퀀스 조건과 프로듀서 인스턴스 재생성 등으로 완전한 “비즈니스 중복 방지”와는 거리가 있습니다.
Outbox 패턴이 해결하는 것
Outbox 패턴의 핵심은 간단합니다.
- 비즈니스 데이터 변경과 “발행할 이벤트”를 같은 DB 트랜잭션에 저장
- 별도의 퍼블리셔가 Outbox 테이블을 읽어 Kafka로 발행
- 발행 성공을 Outbox 상태로 기록
이렇게 하면 최소한 다음이 보장됩니다.
- DB 커밋이 됐다면 이벤트도 “언젠가” 발행됨(유실 방지)
- 발행이 중복되더라도 이벤트에 고유 ID를 부여해 컨슈머에서 멱등 처리 가능
정리하면 Outbox는 “원자적 발행”이 아니라 “원자적 기록 + 재시도 가능한 발행”을 만듭니다.
스키마 설계: Outbox 테이블
MySQL 예시입니다.
CREATE TABLE outbox_events (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
event_id CHAR(36) NOT NULL,
aggregate_type VARCHAR(50) NOT NULL,
aggregate_id VARCHAR(100) NOT NULL,
event_type VARCHAR(100) NOT NULL,
payload JSON NOT NULL,
headers JSON NULL,
status VARCHAR(20) NOT NULL DEFAULT 'PENDING',
available_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
published_at DATETIME(3) NULL,
publish_attempts INT NOT NULL DEFAULT 0,
last_error TEXT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
UNIQUE KEY uq_outbox_event_id (event_id),
KEY idx_outbox_status_available (status, available_at, id)
);
설계 포인트:
event_id: 전역 고유 ID(UUID). 중복 발행이 일어나도 컨슈머 멱등 키로 사용status:PENDING,PUBLISHED,FAILED같은 상태available_at: 지연 발행(백오프) 구현에 유용- 인덱스:
status + available_at + id로 폴링 성능 확보
추가로, 주문 테이블 같은 도메인 테이블에는 “이벤트 발행 여부”를 굳이 넣지 않는 편이 깔끔합니다. Outbox가 발행 상태의 단일 소스가 됩니다.
애플리케이션 트랜잭션: 도메인 변경과 Outbox 기록을 함께
Spring Boot + JPA 스타일 의사 코드입니다.
@Transactional
public Order createOrder(CreateOrderCommand cmd) {
Order order = orderRepository.save(new Order(cmd));
OutboxEvent event = OutboxEvent.pending(
UUID.randomUUID().toString(),
"Order",
order.getId().toString(),
"order.created",
toJson(Map.of(
"orderId", order.getId(),
"amount", order.getAmount(),
"createdAt", order.getCreatedAt()
)),
toJson(Map.of(
"schemaVersion", 1,
"traceId", MDC.get("traceId")
))
);
outboxRepository.save(event);
return order;
}
여기서 중요한 점은 Kafka 프로듀서를 호출하지 않는다는 것입니다. 동일 트랜잭션에서 DB에 “이벤트를 발행해야 한다”는 사실만 안전하게 기록합니다.
만약 현재 서비스에서 데드락이나 락 경합이 잦다면 Outbox 추가로 트랜잭션 시간이 늘어날 수 있습니다. 이때는 인덱스/트랜잭션 범위를 점검해야 하고, 데드락 로그로 실제 원인을 추적하는 방법도 도움이 됩니다. 참고: MySQL InnoDB 데드락 로그로 원인 쿼리 추적
퍼블리셔 구현 1: 폴링 기반(가장 단순, 가장 흔함)
폴링 퍼블리셔는 다음을 반복합니다.
PENDING이면서available_at이 지난 레코드를 N개 가져오기- Kafka로 발행
- 성공 시
PUBLISHED업데이트, 실패 시 재시도 정보 업데이트
동시성 제어: 한 이벤트를 여러 워커가 집지 않게
MySQL에서는 SELECT ... FOR UPDATE SKIP LOCKED 를 지원하는 버전에서 가장 깔끔합니다(8.0 이상).
START TRANSACTION;
SELECT id, event_id, event_type, payload, headers
FROM outbox_events
WHERE status = 'PENDING'
AND available_at <= NOW(3)
ORDER BY id
LIMIT 100
FOR UPDATE SKIP LOCKED;
-- 애플리케이션에서 발행 처리
COMMIT;
이렇게 하면 여러 퍼블리셔 인스턴스가 떠 있어도 서로 락을 피해 다른 행을 처리합니다.
발행 및 상태 업데이트 예시
public void publishBatch() {
List<OutboxEvent> events = outboxRepository.lockNextPending(100);
for (OutboxEvent e : events) {
try {
ProducerRecord<String, String> record = new ProducerRecord<>(
"order-events",
e.getAggregateId(),
e.getPayload()
);
record.headers().add("event_id", e.getEventId().getBytes(UTF_8));
record.headers().add("event_type", e.getEventType().getBytes(UTF_8));
kafkaTemplate.send(record).get(); // 동기 대기: 단순 구현
outboxRepository.markPublished(e.getId(), Instant.now());
} catch (Exception ex) {
outboxRepository.markFailedWithBackoff(
e.getId(),
ex.toString(),
nextAvailableAt(e.getPublishAttempts())
);
}
}
}
운영에서는 send().get() 같은 동기 대기 대신 배치/비동기 + 콜백으로 처리량을 높이되, “DB 업데이트가 누락되지 않게” 주의해야 합니다.
퍼블리셔 구현 2: CDC 기반(Debezium 등)
폴링이 단순하지만, 트래픽이 커지면 DB에 주기적 부하가 생깁니다. 이때 Outbox 테이블을 CDC로 스트리밍하는 방식이 자주 쓰입니다.
- 애플리케이션은 Outbox에 insert만 수행
- Debezium이 binlog에서 Outbox insert를 감지
- Kafka로 바로 토픽에 적재
장점:
- 폴링 제거로 DB 부하 감소
- 지연 감소
단점:
- 운영 복잡도 증가(커넥터, 스키마, 재처리 전략)
- binlog 보관/권한/스키마 변경 관리 필요
팀의 운영 성숙도에 따라 폴링에서 시작해 CDC로 확장하는 접근이 안전합니다.
컨슈머 멱등 처리: “정확히 한 번처럼” 보이게 만드는 마지막 조각
Outbox는 “적어도 한 번 발행(at-least-once)”을 만들기 쉽습니다. 그렇다면 중복은 어디서 제거하나? 컨슈머에서 제거합니다.
가장 실전적인 방식은 event_id 기반 처리 기록 테이블을 두는 것입니다.
CREATE TABLE consumed_events (
event_id CHAR(36) PRIMARY KEY,
consumed_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3)
);
컨슈머 로직은 다음과 같습니다.
@Transactional
public void onMessage(ConsumerRecord<String, String> record) {
String eventId = header(record, "event_id");
boolean first = consumedEventRepository.tryInsert(eventId);
if (!first) {
return; // 이미 처리한 이벤트
}
// 실제 비즈니스 처리(예: 주문 상태 업데이트, 적립금 지급 등)
applyBusiness(record.value());
}
tryInsert 는 유니크 키 충돌을 이용해 멱등을 달성합니다. 이 방식은 컨슈머 인스턴스가 늘어나도 안전합니다.
운영에서 자주 터지는 함정들
1) Outbox 테이블 무한 증가
해결:
PUBLISHED데이터는 일정 기간 후 아카이빙 또는 삭제- 파티셔닝(월 단위) 고려
- 삭제 배치가 본 트래픽에 영향을 주지 않게 오프피크 실행
2) 발행 순서가 중요한데 깨짐
- Outbox를
ORDER BY id로 처리하면 “대체로” 유지되지만, 토픽 파티션과 키 설계가 더 중요 - 같은 aggregate(예: orderId)는 동일 키로 보내 동일 파티션에 고정해야 순서가 보장됨
3) 퍼블리셔가 DB 락을 오래 잡음
FOR UPDATE SKIP LOCKED로 “가져오기”만 짧게 트랜잭션을 잡고, 발행은 트랜잭션 밖에서 수행한 뒤 상태 업데이트를 별도 트랜잭션으로 하는 변형도 가능- 단, 이 경우 “가져온 뒤 크래시” 시 이벤트가 다시 처리되므로 멱등이 더 중요해짐
4) 커넥션 풀 고갈
Outbox 폴링이 촘촘하면 DB 커넥션을 지속적으로 점유합니다. 특히 스케줄러 스레드 수, 배치 크기, 재시도 폭주가 겹치면 HikariCP 고갈로 이어질 수 있습니다. 참고: Spring Boot HikariCP 커넥션 고갈 원인·해결 9가지
“Kafka EOS + Outbox”를 같이 쓰면 더 좋아지나
가능합니다. 다만 목적이 다릅니다.
- Outbox: DB 변경과 이벤트 발행의 정합성(유실 방지) 확보
- Kafka EOS: Kafka 내부에서의 중복/트랜잭션 가시성 제어
예를 들어 Outbox 퍼블리셔가 여러 토픽에 쓰거나, 쓰기와 오프셋 커밋을 묶는 복잡한 파이프라인이라면 Kafka 트랜잭션이 도움이 됩니다. 하지만 대부분의 “주문 생성 후 이벤트 발행” 문제는 Outbox만으로도 체감 안정성이 크게 올라갑니다.
최소 구현 체크리스트
- Outbox 테이블에
event_id유니크 +status인덱스 - 도메인 트랜잭션에서 Outbox insert까지 포함
- 퍼블리셔는
SKIP LOCKED등으로 안전한 병렬 처리 - 실패 시
available_at기반 백오프 + 재시도 횟수 제한 - 컨슈머는
event_id기반 멱등 처리(유니크 키) PUBLISHED정리 정책(보관 기간, 파티셔닝)
마무리
Kafka EOS는 강력하지만 “DB와 Kafka를 한 번에” 묶어주지는 않습니다. 그 경계에서 발생하는 유실/중복을 실무적으로 제어하려면 Outbox 패턴이 가장 검증된 선택지입니다.
핵심은 두 가지입니다.
- 같은 DB 트랜잭션에서 “변경”과 “발행할 이벤트”를 함께 기록
- 발행은 재시도 가능한 별도 컴포넌트로 분리하고, 컨슈머는 멱등으로 마감
이 조합을 갖추면 장애, 타임아웃, 재시도, 배포 중단 같은 현실적인 변수 속에서도 이벤트 기반 아키텍처의 신뢰도를 크게 끌어올릴 수 있습니다.