마이크로서비스에서 Kafka/SQS 같은 비동기 메시징을 도입하면 서비스 간 결합을 낮추고 확장성을 얻는 대신, 데이터 정합성 문제가 자주 튀어나온다.
대표적으로 아래가 반복된다.
- 발행돼야 하는 이벤트가 발행되지 않음(유실)
- 발행되면 안 되는 이벤트가 발행됨(유령 이벤트)
- 배포/장애 타이밍에 간헐적으로 누락/지연이 발생함
- 같은 이벤트가 중복 발행될 수 있음(at-least-once)
이 글에서는 위 문제를 실무적으로 해결하기 위한 패턴인 Transactional Outbox Pattern(트랜잭셔널 아웃박스)를 정리한다.
적용 범위
아래 중 하나라도 해당되면 outbox 적용을 먼저 고민하는 편이 안전하다.
- 이벤트 유실이 비즈니스 장애로 직결됨
- 예: 주문 완료 이후 정산/배송/알림 등 후속 처리
- 동기 호출(HTTP)로 묶기엔 결합/지연이 부담됨
- 배포/장애 타이밍의 간헐 누락이 반복적으로 문제를 만든 적이 있음
반대로 통계/로그성 이벤트처럼 “누락돼도 사람이 복구 가능한 영역”이면, 비용 대비 과한 선택일 수 있다.
문제
핵심은 메시지 브로커 발행과 DB 트랜잭션 커밋이 서로 다른 시스템이라는 점이다.
- 트랜잭션 밖 publish
- 도메인은 커밋됐는데 publish가 실패하면 이벤트 유실
- 트랜잭션 안 publish
- publish는 됐는데 commit이 실패하면 유령 이벤트
- commit 지연 시점엔 컨슈머가 조회하면 아직 상태가 반영되지 않을 수도 있음
핵심 아이디어
outbox는 “브로커에 잘 쏘기”가 아니라, 발행 요청을 DB 트랜잭션에 묶어 기록하는 패턴이다.
- 같은 트랜잭션에서
- 도메인 변경
- outbox row insert(= 발행할 이벤트 기록)
- 이후 릴레이가 outbox를 읽어 publish
- 실패하면 재시도 → 결국 eventual consistency
전체 흐름

구현 방식 1: Polling Relay(배치/워커)로 발행
- 서비스 트랜잭션에서는 outbox에만 저장
- 릴레이(배치/워커/크론)가 outbox를 polling 하며 발행
Producer
@Transactional
public void placeOrder(PlaceOrderCommand cmd) {
orderRepository.save(order);
outboxRepository.save(OutboxEvent.of("OrderPlaced", payload));
}
Relay
@Scheduled(fixedDelay = 1000)
public void relay() {
List<OutboxEvent> events = outboxRepository.findUnpublished(100);
kafkaProducer.send(events);
outboxRepository.markPublished(events); // 또는 delete
}
- 운영이 단순함
- polling 주기만큼 발행 지연이 생길 수 있음
구현 방식 2: TransactionalEventListener로 BEFORE/AFTER 훅 분리
스프링을 쓰면 “커밋 전/후”를 훅으로 분리할 수 있다.
- BEFORE_COMMIT: outbox 기록(같은 트랜잭션)
-
AFTER_COMMIT: 브로커 발행(커밋 확정 이후)
- “이벤트 이후 처리 로직은 리스너에 모은다”는 일관성을 만들기 좋다.

운영 체크리스트
outbox는 구현보다 운영 디테일에서 품질이 갈린다.
- 상태 모델
published=true/false만 두면 원인 파악이 어려워진다.- 최소
init/send_success/send_fail로 나누는 편이 낫다. init이 오래 남으면 “발행 로직이 아예 실행되지 못한 케이스”를 의심하기 좋다.
- 배포/종료(graceful shutdown)
- AFTER_COMMIT +
@Async조합은 배포 타이밍에 작업 유실이 날 수 있다. - 비동기 executor graceful shutdown 설정을 점검한다.
- AFTER_COMMIT +
executor.setWaitForTasksToCompleteOnShutdown(true); // 종료 시 실행 중인 작업이 끝날 때까지 기다림
executor.setAwaitTerminationSeconds(10); // 최대 대기 시간(초)
- 재시도 전략
- 주기/limit/backoff, 재시도 대상 조건(예: 생성 후 N분 경과)과 알람 조건을 정한다.
- Consumer 멱등
- outbox는 보통 at-least-once delivery라 중복 발행이 가능하다.
- message id 기반 중복 제거 또는 upsert/버전 기반 수렴 설계가 필요하다.
- 보관 정책
send_success를 남기면 추적/감사/리플레이에 유리하다.- 대신 TTL/아카이빙/파티셔닝 같은 정리 전략을 같이 가져간다.
정리
outbox의 핵심은 “도메인 변경”과 “이벤트 발행 요청 기록(outbox)”을 같은 DB 트랜잭션에 묶는 것이다.
그리고 운영에서 체감하는 품질(안정성/발행 지연)은 결국 아래에서 갈린다.
- 릴레이(워커) 처리량/주기(= 발행 지연)
- 상태 모델 + 재시도/알람(= 유실/장애 대응)
- 배포/종료(graceful shutdown)
- 컨슈머 멱등