Transactional Outbox in Practice: Prologue
TOC
Overview
백엔드 서비스에서 DB 저장과 Kafka 발행을 한 흐름으로 묶고 싶을 때가 있다. 하지만 RDB commit과 Kafka ack는 같은 트랜잭션으로 움직이지 않는다.
이번 시리즈는 이 간극을 분산 트랜잭션으로 덮지 않고, 발행 의도를 DB에 먼저 저장하는 Transactional Outbox 패턴으로 풀어가는 과정을 정리한다.
문제의 시작
예를 들어 사용자 등록 정보를 DB에 저장한 뒤 Kafka로 후속 처리를 요청하는 흐름이 있다고 하자. DB 저장은 성공했는데 메시지 발행이 실패하거나, 반대로 메시지만 먼저 나가면 운영 데이터는 쉽게 어긋난다.
이 문제는 코드 순서를 조금 바꾼다고 사라지지 않는다. 서로 다른 시스템의 commit 시점이 다르기 때문에 생기는 구조적 문제다.
Outbox 어원
Outbox는 outgoing box의 줄임말로, 밖으로 나갈 메시지를 잠시 보관하는 함을 뜻한다.
사전적으로도 outbox는 “아직 보내지 않은 이메일 메시지를 보관하는 폴더” 또는 “처리 후 외부로 보낼 문서를 담는 책상 위 트레이”를 의미한다. 이름 그대로 이 패턴은 메시지를 즉시 보내는 대신 일단 DB의 outbox 테이블에 담아두고, 나중에 꺼내 보내는 흐름을 따른다.
Transactional Outbox의 핵심
핵심은 메시지를 즉시 발행하지 않는 것이다. 비즈니스 데이터와 함께 outbox row를 같은 DB 트랜잭션에 저장하고, 실제 발행은 별도 폴러가 나중에 처리한다.
sequenceDiagram
box Application
participant Prod as Producer
participant Poller as Poller
participant Cons as Consumer
end
participant DB
participant MQ as Message Queue
Note over Prod,DB: [1편] 원자적 저장
Prod->>DB: 비즈니스 데이터 INSERT
Prod->>DB: Outbox row INSERT (PENDING)
Note right of Prod: 같은 @Transactional
Note over Poller,MQ: [2편] 발행
loop 폴링
Poller->>DB: PENDING row 조회 (FOR UPDATE SKIP LOCKED)
DB-->>Poller: rows
Poller->>MQ: Kafka 발행
Poller->>DB: 상태 PENDING → SENT
end
Note over MQ,Cons: [3편] 소비
MQ-->>Cons: 메시지 수신 (at-least-once)
Cons->>DB: 멱등 상태 전이 (WAIT → SCS / FAIL)
이렇게 하면 저장과 발행 의도는 원자적으로 묶이고, Kafka 발행 실패는 재시도 가능한 별도 단계로 분리된다.
연재 계획
- [프롤로그] DB와 MQ 사이의 균열, 그리고 Outbox라는 선택 (현재 글)
- [1편] Atomic Persist and Enqueue: 저장과 발행 의도를 같은 트랜잭션에 묶기
- [2편] Thin Poller and At-least-once: 얇은 폴러와 중복 발행을 받아들이는 설계
- [3편] Idempotent Consumer: 중복 메시지를 흡수하는 소비자 만들기
- [4편] Operating in Production: 멀티 인스턴스와 운영 이슈 정리
약속 (Our Principle)
- 원자성: 비즈니스 데이터와 발행 의도는 같은 DB 트랜잭션에 묶는다.
- 분리: 발행과 소비는 각각 별도 책임으로 다룬다.
- 멱등성: at-least-once 발행으로 생기는 중복은 컨슈머가 흡수한다.
Next Step
다음 포스트에서는 Atomic Persist and Enqueue를 다룬다. Outbox 패턴의 출발점인 “저장과 발행 의도 적재”를 어떤 코드 경계 안에 둬야 하는지 정리한다.