- Published on
Kafka Exactly-Once가 깨질 때 - Outbox+사가
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서로 다른 서비스가 Kafka로 이벤트를 주고받는 MSA에서 “Exactly-Once면 중복도 유실도 없겠지”라고 기대하기 쉽습니다. 하지만 운영 환경에서 마주치는 장애(프로세스 크래시, 네트워크 단절, 리밸런싱, DB 데드락, 재처리 등)는 Kafka의 EOS(Exactly-Once Semantics) 경계를 가차 없이 드러냅니다.
이 글은 Kafka Exactly-Once가 언제/왜 깨지는지를 먼저 현실적으로 정리하고, 그 다음 Outbox 패턴으로 ‘DB 변경 + 이벤트 발행’을 원자적으로 만들고, 마지막으로 사가(Saga)로 분산 트랜잭션을 비동기 보상으로 수습하는 전체 그림을 제시합니다.
Kafka Exactly-Once, 어디까지가 “Exactly-Once”인가
Kafka 문서에서 말하는 EOS는 대개 다음 조건을 전제로 합니다.
- Kafka Producer의 idempotence + transactional producer로 중복 발행을 줄인다.
- Kafka Consumer의 offset commit을 트랜잭션에 포함해 “처리 결과와 커밋”의 원자성을 만든다.
- Kafka Streams 같은 프레임워크는 내부적으로 이를 더 잘 엮어준다.
하지만 여기에는 중요한 함정이 있습니다.
- EOS는 Kafka 내부(토픽/파티션/오프셋) 관점에서 강합니다.
- 반면, 우리가 진짜 원하는 것은 보통 “DB 상태 변경 + 외부로 이벤트 발행 + 다운스트림 반영”까지 포함한 end-to-end exactly-once입니다.
- 이 end-to-end 영역에는 Kafka가 통제하지 못하는 구성요소(예: RDB 트랜잭션, 외부 API 호출, 캐시, 서드파티 결제)가 들어옵니다.
즉, Kafka가 제공하는 EOS는 “필요조건”일 수는 있어도, “충분조건”이 되기 어렵습니다.
Exactly-Once가 깨지는 대표 시나리오
1) DB 커밋은 됐는데 이벤트 발행이 실패
가장 흔한 형태입니다.
- 주문 생성 트랜잭션이 DB에 커밋됨
- 직후 Kafka publish에서 타임아웃/네트워크 오류
- 결과: DB에는 주문이 있는데 이벤트가 없다(유실)
반대로,
- Kafka publish 성공
- DB 커밋 직전에 애플리케이션 크래시
- 결과: 이벤트는 있는데 DB에는 주문이 없다(유령 이벤트)
Kafka transactional producer를 쓴다 해도, DB 트랜잭션과 Kafka 트랜잭션을 하나로 묶을 수는 없습니다(2PC를 하지 않는 이상).
2) 컨슈머 재처리로 인한 중복 반영
컨슈머가 메시지를 처리한 뒤 offset commit 전에 죽으면, 같은 메시지가 다시 옵니다.
- “처리는 됐는데 커밋 전 크래시” → at-least-once 재처리
- 다운스트림 DB 업데이트가 멱등하지 않으면 중복 반영
3) 파티션 리밸런싱, 지연, 순서 역전
Kafka는 파티션 내 순서는 보장하지만,
- 파티션 키가 잘못 설계되어 동일 aggregate가 여러 파티션으로 흩어지거나
- 재시도/지연으로 이벤트 도착 순서가 뒤집히면
사가 보상 로직이 꼬이거나, “이미 취소된 주문이 다시 승인되는” 식의 역전이 생깁니다.
4) 운영에서 자주 만나는 DB 데드락/락 경합
Outbox를 도입하면 DB 쓰기가 늘고, 폴링/배치/락 경합이 생길 수 있습니다. 특히 InnoDB에서는 잘못된 인덱스/갱신 순서로 데드락이 발생하면 재시도가 늘면서 중복 이벤트 생성 또는 지연 폭증으로 이어질 수 있습니다.
데드락 추적과 해결은 별도 글에서 더 자세히 다뤘습니다: MySQL InnoDB Deadlock 원인 쿼리 추적·해결 가이드
해결 전략 1: Outbox 패턴으로 “DB 변경 + 이벤트”를 붙인다
Outbox 패턴의 핵심은 단순합니다.
- 비즈니스 트랜잭션에서
- 도메인 상태 변경(예: orders 테이블)
- 이벤트 레코드 기록(outbox 테이블)
- 같은 DB 트랜잭션으로 커밋
그 다음 별도 퍼블리셔가 outbox를 읽어 Kafka로 발행합니다.
이렇게 하면 “DB는 커밋됐는데 이벤트 발행이 실패” 문제를 구조적으로 없애고, 실패 시에도 outbox에 남아 재시도 가능합니다.
Outbox 테이블 설계 예시
CREATE TABLE outbox_event (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
aggregate_type VARCHAR(50) NOT NULL,
aggregate_id VARCHAR(100) NOT NULL,
event_type VARCHAR(100) NOT NULL,
payload JSON NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'NEW',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
published_at TIMESTAMP NULL,
-- 멱등/중복 방지를 위한 키(선택)
dedup_key VARCHAR(200) NULL,
UNIQUE KEY uk_dedup (dedup_key)
);
CREATE INDEX ix_outbox_status_id ON outbox_event(status, id);
status기반으로 NEW만 퍼블리시dedup_key로 “같은 이벤트를 두 번 쓰지 않기”를 DB에서 강제(업무에 따라 선택)
Spring/JPA에서 비즈니스 트랜잭션에 Outbox 기록
@Service
public class OrderService {
private final OrderRepository orderRepository;
private final OutboxRepository outboxRepository;
@Transactional
public Long placeOrder(PlaceOrderCommand cmd) {
Order order = Order.create(cmd);
orderRepository.save(order);
OutboxEvent evt = OutboxEvent.newEvent(
"Order", String.valueOf(order.getId()),
"OrderPlaced",
Map.of("orderId", order.getId(), "amount", order.getAmount()),
"OrderPlaced:" + order.getId() // dedup_key 예시
);
outboxRepository.save(evt);
return order.getId();
}
}
여기서 중요한 포인트는 KafkaProducer를 이 트랜잭션 안에서 호출하지 않는 것입니다. 호출해도 되긴 하지만(결국 분리 트랜잭션), 운영에서 장애 시나리오가 더 복잡해집니다.
해결 전략 2: Outbox 퍼블리셔(릴레이) 구현 패턴
Outbox에서 Kafka로 옮기는 방식은 크게 2가지입니다.
- Polling Publisher: 일정 주기로 NEW 이벤트를 가져와 발행
- CDC(Change Data Capture): Debezium 등으로 binlog를 읽어 Kafka로
여기서는 구현이 단순하고 흔한 Polling 방식을 예로 들겠습니다.
폴링 퍼블리셔의 핵심 요구사항
- 여러 인스턴스가 떠도 중복 발행을 최소화
- 발행 성공/실패에 따라 상태 변경
- 장애 시 재시도 가능
- 순서가 중요한 aggregate는 정렬/키 설계로 보완
“선점”을 위한 업데이트 기반 락(간단 버전)
-- NEW 상태 중 일부를 선점
UPDATE outbox_event
SET status = 'PUBLISHING'
WHERE status = 'NEW'
ORDER BY id
LIMIT 100;
-- 선점된 레코드 조회
SELECT * FROM outbox_event
WHERE status = 'PUBLISHING'
ORDER BY id;
이 방식은 단순하지만 “누가 선점했는지”가 없어서 인스턴스 간 경합이 있으면 개선이 필요합니다. 실무에선 보통 locked_by, locked_at 같은 컬럼을 두고, TTL 기반으로 락 만료를 처리합니다.
Kafka 발행 및 상태 전이(의사 코드)
@Scheduled(fixedDelay = 500)
public void publish() {
List<OutboxEvent> batch = outboxRepository.lockNextBatch("instance-1", 100);
for (OutboxEvent e : batch) {
try {
kafkaTemplate.send("order-events", e.getAggregateId(), e.getPayloadJson()).get();
outboxRepository.markPublished(e.getId());
} catch (Exception ex) {
outboxRepository.markFailed(e.getId(), ex.getMessage());
}
}
}
여기서 .get()으로 동기 대기하는 것은 처리량을 깎습니다. 실무에서는 비동기 전송 후 콜백에서 상태 변경하거나, 배치 전송/트랜잭션 프로듀서를 섞어 처리량을 확보합니다. 다만 어떤 형태든 **“발행 성공을 DB에 기록”**하는 단계가 핵심입니다.
Outbox만으로는 부족하다: 분산 트랜잭션은 결국 사가로 간다
Outbox는 “이벤트 유실/유령 이벤트”를 크게 줄여주지만, 다음 문제는 남습니다.
- 주문 서비스는 주문을 만들고 이벤트를 냈다.
- 결제 서비스가 결제에 실패했다.
- 재고 서비스가 재고 차감에 실패했다.
이건 단순히 메시지 전달의 문제가 아니라, 여러 서비스의 상태를 하나의 비즈니스 흐름으로 맞추는 문제입니다. 여기서 사가(Saga)가 필요합니다.
사가의 본질:
- 각 서비스는 로컬 트랜잭션만 보장한다.
- 전체 정합성은 이벤트 기반 단계 진행 + 실패 시 보상 트랜잭션으로 맞춘다.
사가 패턴: 오케스트레이션 vs 코레오그래피
1) 오케스트레이션(중앙 조정자)
- Saga Orchestrator가 “다음 단계”를 명령
- 장점: 흐름이 명확, 관측/재처리 쉬움
- 단점: 오케스트레이터가 복잡/중앙화
2) 코레오그래피(이벤트에 반응)
- 각 서비스가 이벤트를 구독하고 다음 이벤트를 발행
- 장점: 결합도 낮음
- 단점: 흐름 추적이 어렵고, 실패/보상 설계가 까다로움
실무에서는 복잡한 비즈니스(결제/환불/부분취소/쿠폰) 일수록 오케스트레이션이 유리한 경우가 많습니다.
Outbox + 사가 결합: “정확히 한 번” 대신 “정확히 한 상태”를 만든다
분산 환경에서 진짜 목표는 메시지가 정확히 한 번 소비되는 것이 아니라,
- 최종 상태가 정확히 한 번만 반영되는 것(멱등)
- 중복/재처리를 허용하되 결과가 안정적인 것
입니다.
이를 위해 Outbox + 사가에서 자주 쓰는 3가지 장치를 같이 둡니다.
- 커맨드/이벤트에 CorrelationId(=SagaId) 부여
- 각 서비스는 Inbox(Processed Message) 테이블로 멱등 처리
- 보상 트랜잭션은 “되돌리기”가 아니라 새 상태로 전이(취소/환불/복구)
Inbox(멱등) 테이블 예시
CREATE TABLE inbox_processed (
message_id VARCHAR(100) PRIMARY KEY,
consumer VARCHAR(100) NOT NULL,
processed_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
컨슈머는 메시지를 처리하기 전에 message_id로 중복 여부를 확인하고, 처리 후 기록합니다.
컨슈머 멱등 처리(간단 예시)
@Transactional
public void onMessage(OrderPlaced event) {
if (inboxRepository.exists(event.messageId(), "payment")) {
return; // 이미 처리됨
}
paymentService.authorize(event.orderId(), event.amount());
inboxRepository.save(event.messageId(), "payment");
}
이렇게 하면 Kafka가 at-least-once로 재전송해도, 비즈니스 효과는 exactly-once에 가깝게 됩니다.
사가 흐름 예시: 주문-결제-재고
정상 흐름
- OrderService: 주문 생성 + Outbox(OrderPlaced)
- PaymentService: OrderPlaced 수신 → 결제 승인 + Outbox(PaymentApproved)
- InventoryService: PaymentApproved 수신 → 재고 차감 + Outbox(StockReserved)
- OrderService: StockReserved 수신 → 주문 상태 CONFIRMED
결제 실패 흐름(보상)
- PaymentService가 결제 실패 → Outbox(PaymentFailed)
- OrderService가 PaymentFailed 수신 → 주문 상태 CANCELLED
재고 실패 흐름(보상)
- InventoryService가 차감 실패 → Outbox(StockReserveFailed)
- PaymentService가 StockReserveFailed 수신 → 결제 취소(환불/승인취소) + Outbox(PaymentCancelled)
- OrderService가 PaymentCancelled 수신 → 주문 CANCELLED
여기서 중요한 점은 “보상”이 단순 롤백이 아니라 새로운 이벤트로 상태를 진행시킨다는 것입니다.
운영 관점 체크리스트: EOS 환상에서 벗어나기
1) 리트라이 폭주를 통제하라
사가에서는 실패가 연쇄적으로 전파될 수 있습니다. 특히 gRPC/HTTP 호출을 섞는 하이브리드 구조라면 데드라인/리트라이 정책이 잘못될 때 폭주가 나기 쉽습니다. 관련해서는 다음 글의 원칙이 그대로 적용됩니다: gRPC MSA에서 데드라인·리트라이 폭주 막는 법
- 재시도는 지수 백오프 + 지터
- 서킷 브레이커
- 타임아웃은 “짧게, 전체 예산”으로
2) Outbox 퍼블리셔 장애 시나리오를 문서화
- PUBLISHING 상태에서 죽으면?
- 락 TTL은?
- FAILED 이벤트는 재시도 큐로 보낼지, 운영자가 수동 조치할지?
3) 관측 가능성(Observability)을 “사가 단위”로 만든다
- SagaId/CorrelationId를 로그, 트레이싱, 이벤트 payload에 포함
- “주문 1건”의 전체 이벤트 타임라인을 한 화면에서 재생 가능해야 함
4) DB 성능/락을 먼저 점검하라
Outbox/Inbox 테이블은 쓰기 빈도가 높습니다.
- (status, id) 인덱스
- 오래된 published 레코드 아카이빙/파티셔닝
- 데드락 시 재시도 정책(무한 재시도 금지)
데드락이 간헐적으로 보이면 원인 쿼리부터 찾아야 합니다: MySQL InnoDB Deadlock 원인 쿼리 추적·해결 가이드
결론: “Exactly-Once” 대신 “멱등 + 보상 + 재처리 가능”
Kafka의 Exactly-Once는 분명 강력하지만, 그 효력은 Kafka가 통제하는 경계 안에서 가장 빛납니다. 서비스 DB, 외부 결제, 재고, 배송 같은 현실 요소가 섞이면 end-to-end exactly-once는 쉽게 무너집니다.
그래서 실전 아키텍처는 보통 다음 조합으로 수렴합니다.
- Outbox로 “DB 변경과 이벤트 생성”을 원자적으로 만들고
- (필요 시) Inbox로 컨슈머 멱등을 보장하며
- 사가로 분산 트랜잭션을 단계화하고 실패를 보상 이벤트로 정리한다
결국 목표는 메시지 처리 횟수가 아니라, 장애/재처리/중복이 있어도 최종 비즈니스 상태가 안정적으로 수렴하는 시스템을 만드는 것입니다.