Transactional Outbox 패턴: 이벤트 유실 없이 DB와 메시지 큐 연결하기
Transactional Outbox 패턴은 비즈니스 데이터 변경과 “나중에 발행할 이벤트”를 같은 DB 트랜잭션에 기록해, DB 커밋과 메시지 발행 사이에서 이벤트가 사라지는 구간을 없애는 설계다. broker까지 한 번에 원자적으로 쓰는 기술은 아니다. 원자성의 경계는 business state와 outbox 행까지이며, 그 뒤 전달은 재시도와 중복 처리를 전제로 한다.
이 글은 2026년 8월 5일 기준으로 Microservices.io, AWS, Debezium, PostgreSQL, Apache Kafka의 문서를 같은 crash-window 기준으로 대조한 결과다. 실제 PostgreSQL·Debezium·Kafka 환경에서 장애를 주입하거나 지연과 처리량을 측정하지는 않았다. 따라서 특정 초, TPS, batch 크기를 제시하기보다 어디에서 유실이 사라지고 중복이 남는지, Polling과 CDC를 어떤 조건에서 선택할지를 설명한다.
dual write는 실행 순서를 바꿔도 안전해지지 않는다
주문 상태를 DB에 저장하고 OrderPaid 이벤트를 메시지 큐에 발행하는 요청을 생각해보자. 두 쓰기는 서로 다른 시스템에서 일어나므로 공통 commit 지점이 없다.
- DB를 먼저 커밋하면, commit 직후 프로세스가 죽거나 broker 호출이 실패했을 때 주문은 바뀌지만 downstream은 그 사실을 모른다.
- 이벤트를 먼저 발행하면, 이후 DB 트랜잭션이 rollback됐을 때 존재하지 않는 변경을 consumer가 처리한다.
- DB 트랜잭션 안에서 broker를 호출해도 외부 발행은 DB rollback으로 되돌릴 수 없다. broker 응답을 기다리는 동안 DB lock도 길어진다.
Microservices.io의 패턴 정의와 AWS Prescriptive Guidance는 이 두 방향의 불일치를 같은 문제로 설명한다. 필요한 것은 호출 순서 조정이 아니라 원자성 경계의 재설계다.
원자성은 broker가 아니라 outbox까지 확장한다
기억해야 할 흐름은 transaction → outbox → relay → broker → consumer다.
- 애플리케이션이 business state와 outbox event를 같은 DB connection, 같은 로컬 트랜잭션에서 커밋한다.
- 별도 relay가 커밋된 outbox 행만 읽는다.
- relay가 broker의 성공 acknowledgment를 확인한 뒤 outbox를 완료 처리한다.
- consumer는
event_id를 기준으로 중복을 걸러낸 뒤 business mutation을 반영한다.

관계형 DB에서는 다음과 같은 구조로 이해할 수 있다. 이 SQL은 설계를 설명하기 위한 개념 예시이며 이번 조사에서 직접 실행한 코드는 아니다.
BEGIN;
UPDATE orders
SET status = 'PAID', version = version + 1
WHERE id = :order_id;
INSERT INTO outbox_events (
event_id, aggregate_type, aggregate_id, aggregate_version,
event_type, schema_version, payload, occurred_at
) VALUES (
:event_id, 'Order', :order_id, :next_version,
'OrderPaid', :schema_version, :payload, :occurred_at
);
COMMIT;
ORM repository 두 개를 차례로 호출했다는 사실만으로는 충분하지 않다. 한쪽이 별도 connection이나 자동 commit을 사용하면 business state만 남거나 outbox만 남을 수 있다. NoSQL에서도 원자적 쓰기 범위를 확인해야 한다. Azure Cosmos DB의 Transactional Outbox 예시는 transactional batch의 ACID 범위를 하나의 logical partition으로 제한한다.
outbox에는 역할이 다른 식별자를 구분해 둔다.
event_id는 재전송에도 바뀌지 않는 중복 제거 키다.aggregate_id는 같은 주문이나 계정의 이벤트를 같은 broker partition으로 보내는 routing·ordering 키다.aggregate_version또는 sequence는 consumer가 누락과 역전을 탐지하는 기준이다.event_type과schema_version은 이벤트 계약의 종류와 진화를 식별한다.payload는 downstream에 필요한 immutable snapshot이며, 비밀정보나 불필요한 개인정보를 복제하지 않아야 한다.occurred_at은 business event의 발생 시각이다. broker에 실제 발행된 시각과 혼동하지 않는다.
여섯 개 crash window가 보장 범위를 드러낸다
Outbox가 해결하는 문제와 남기는 문제는 장애 시점을 따라가면 선명해진다.
| 실패 시점 | 남아 있는 상태 | 재시작 뒤 결과 | 필요한 방어 |
|---|---|---|---|
| business/outbox commit 전 | 둘 다 rollback | 발행하지 않음 | 같은 DB 트랜잭션인지 확인 |
| commit 후 relay가 읽기 전 | business와 outbox 모두 존재 | relay가 나중에 발행 | backlog와 oldest age 감시 |
| relay가 broker로 보내기 전 | outbox 미완료 | 재시도해 발행 | claim lease와 retry |
| broker가 받았지만 relay가 ack를 못 받음 | broker 수신 여부 불확실 | 재시도로 중복 가능 | 고정 event ID와 멱등 처리 |
| broker ack 후 완료 표시 전 | broker에는 존재, outbox는 미완료 | 명확한 중복 발행 | consumer inbox·deduplication |
| consumer DB commit 후 broker ack 전 | consumer 효과는 이미 반영 | broker가 재전달 | processed ID와 mutation의 원자적 commit |
첫 번째와 두 번째 구간에서 얻는 보장은 분명하다. DB 트랜잭션이 commit되면 발행 의도가 outbox에 남고, rollback되면 business state와 함께 사라진다. 반면 relay가 broker에 보낸 뒤 outbox 완료 기록 전에 죽는 구간은 없앨 수 없다. Microservices.io와 AWS 가이드도 중복 메시지를 전제로 멱등 consumer를 요구한다.
따라서 “이벤트 유실 없이”라는 표현은 dual write 사이의 유실 구간을 outbox와 재시도로 닫는다는 뜻으로 한정해야 한다. DB, relay, broker, consumer, 외부 API를 하나의 전역 exactly-once 경계로 묶는다는 뜻은 아니다.
relay 중복은 consumer의 로컬 트랜잭션에서 막는다
consumer는 메시지를 받자마자 business mutation부터 실행해서는 안 된다. consumer_name + event_id에 unique constraint가 있는 inbox 또는 processed table을 두고, 처음 본 이벤트의 processed 기록과 business mutation을 같은 로컬 DB 트랜잭션에서 커밋해야 한다. 그 뒤 broker ack나 offset commit을 수행한다.
consumer가 DB commit 직후 죽더라도 broker는 같은 메시지를 다시 전달할 수 있다. 이때 unique constraint가 두 번째 mutation을 막는다. 이것이 at-least-once delivery를 중복 없는 business effect에 가깝게 만드는 경계다.
다만 email 발송이나 결제 API처럼 consumer DB 밖에서 발생한 부수 효과는 이 트랜잭션으로 되돌릴 수 없다. 외부 provider가 지원하는 idempotency key에 원래 event_id를 전달하거나, consumer가 자신의 outbox에 외부 호출 의도를 다시 기록해 다음 단계로 분리해야 한다.
Kafka의 idempotent producer도 이 책임을 대신하지 않는다. Kafka 4.0 producer 설정에서 enable.idempotence=true는 producer retry 때문에 같은 record가 stream에 중복 기록되는 것을 막는 범위의 기능이다. 문서가 명시한 acks=all, retries>0, max.in.flight.requests.per.connection<=5 조건에서 partition 전송 순서를 보존하지만, DB commit이나 consumer의 외부 효과까지 원자화하지는 않는다.
Polling과 CDC는 정확성이 아니라 relay 운영 방식의 선택이다
두 방식 모두 application의 outbox transaction과 consumer 멱등성을 유지한다. 바뀌는 것은 커밋된 outbox를 읽는 방법이다.
| 판단 축 | Polling Publisher | CDC / log tailing | 선택 기준 |
|---|---|---|---|
| 초기 변경 | outbox table, worker, index로 시작 | connector, logical replication 설정과 권한 필요 | 최소 변경이면 Polling |
| 지연 | poll interval과 batch 대기 포함 | commit log를 계속 읽어 polling 대기를 제거 | 실제 commit-to-broker p95/p99로 판단 |
| DB 부담 | 빈 조회, row lock, 상태 update와 cleanup | WAL 보존, connector와 slot 운영 부담 | query/write 부하와 WAL disk를 각각 측정 |
| 처리량 확장 | worker·batch 증가 시 lock 경합과 순서 충돌 | stream 처리에 유리하지만 task·partition 병목 존재 | aggregate 기준으로 병렬화 |
| 순서 | 여러 worker가 처리 순서를 바꿀 수 있음 | source commit order를 얻기 쉽지만 routing에서 다시 보존해야 함 | 어느 쪽도 전역 순서를 자동 보장하지 않음 |
| 장애 복구 | outbox 행과 처리 상태를 직접 확인 | offset, replication slot, WAL, failover slot 복구 | 팀이 운영할 수 있는 runbook 선택 |
Polling은 가장 작은 변경으로 정확성을 먼저 완성한다
일반 SQL DB에서 빠르게 시작해야 하고 허용 지연이 극단적으로 짧지 않다면 Polling이 자연스러운 첫 선택이다. 짧은 DB 트랜잭션에서 제한된 batch를 claim하고 claimed_until 같은 lease를 기록한 뒤 commit한다. broker 네트워크 호출 동안 row lock을 계속 잡지 않으며, worker가 죽으면 lease 만료 후 다른 worker가 회수한다.
PostgreSQL의 FOR UPDATE SKIP LOCKED는 queue-like table에서 다른 worker가 잡은 행을 기다리지 않고 건너뛰게 해 경합을 줄인다. 그러나 PostgreSQL 16 SELECT 문서는 일관되지 않은 view를 제공한다고 경고한다. 이는 lock 경합 회피 기능이지 ordering 기능이 아니다.
같은 aggregate의 E1과 E2를 서로 다른 worker가 가져가면 E2가 먼저 broker에 도착할 수 있다. aggregate ID별 worker나 shard를 직렬화하거나, 앞선 sequence의 완료를 확인한 뒤 다음 이벤트를 claim해야 한다. broker key를 aggregate_id로 고정하고 consumer가 version gap과 오래된 이벤트를 탐지하는 방어도 함께 필요하다.
CDC는 짧은 지연과 높은 처리량을 위해 운영 범위를 넓힌다
CDC는 DB commit log를 읽어 outbox INSERT를 stream으로 바꾼다. PostgreSQL logical decoding은 logical replication slot을 통해 SQL 변경을 외부 consumer에 제공하며, Debezium Outbox Event Router는 outbox 변경을 Kafka key와 payload로 변환한다.
Polling interval이 사라져 짧은 지연에 유리하지만 “실시간”을 자동 보장하지는 않는다. connector queue, Kafka Connect, broker batching의 지연이 남는다. 성능 우위도 공통 수치로 단정할 수 없으므로 실제 workload에서 commit-to-broker 지연과 처리량을 측정해야 한다.
Debezium PostgreSQL connector 문서에 따르면 replication slot은 connector가 중단된 동안에도 필요한 WAL을 보존한다. slot이 오래 지연되면 디스크 사용량과 catalog bloat가 커질 수 있다. connector마다 고유 slot을 사용해야 하며, upgrade나 failover에서 기존 slot 위치를 잃는 상황에 대비한 복구 절차도 필요하다.
Debezium을 도입한다면 connector가 outbox table만 capture하도록 범위를 제한하고, aggregateid가 Kafka message key가 되는지 확인해야 한다. Outbox Event Router는 outbox 변경을 INSERT로 기대하고 DELETE를 자동 필터링하므로 보존·정리 정책도 별도로 정해야 한다.
순서는 aggregate 단위로 좁혀야 확장할 수 있다
“이벤트 순서를 지킨다”는 요구는 범위부터 정의해야 한다.
- aggregate별 순서는 같은 주문의
Created → Paid → Shipped를 보존하되 서로 다른 주문 사이의 순서는 요구하지 않는다. 가장 일반적이며 병렬화하기 쉬운 정의다. - partition별 순서는 broker가 제공하는 실용적 범위다.
aggregate_id를 key로 사용하면 같은 aggregate가 같은 partition으로 이동한다. - 전역 순서는 모든 이벤트에 하나의 total order를 요구한다. 단일 발행 경로와 낮은 병렬성이라는 비용이 따르므로 명확한 business requirement가 있을 때만 선택한다.
timestamp만으로 순서를 판단하면 clock skew, 동일 timestamp, transaction commit 순서 차이를 구분하기 어렵다. outbox의 aggregate_version이나 DB가 정한 monotonic sequence를 기준으로 삼고, consumer가 version 역전과 누락을 탐지해야 한다.
처리량을 높이려고 worker, batch, partition을 늘리면 hot key와 aggregate 순서 문제가 드러난다. 반대로 전역 순서를 위해 단일 worker와 partition을 사용하면 처리량 상한이 낮아진다. 정확성을 먼저 완성하고, 관측된 병목을 기준으로 병렬화 범위를 넓혀야 한다.
최소 변경 도입안은 Polling에서 시작해 측정으로 전환한다
기존 서비스에 적용할 때는 다음 순서가 안전하다.
- 기존 service DB에 append-oriented outbox table을 추가한다.
- application transaction에 outbox
INSERT를 포함하고, 재시도에도 같은event_id와aggregate_version을 사용한다. - 작은 batch의 Polling relay를 별도 worker로 운영한다.
- broker acknowledgment 뒤 완료 처리하되 중복 발행을 정상적인 재시도 경로로 취급한다.
- consumer의 processed-event unique constraint와 business mutation을 같은 트랜잭션에 넣는다.
- backlog, oldest unpublished age, retry, duplicate suppression을 dashboard와 alert로 만든다.
- 측정 결과 Polling이 latency SLO를 지속적으로 위반하거나 DB 부하의 병목이 되고, 팀이 slot·WAL·failover를 운영할 수 있을 때 CDC로 전환한다.
도입 완료를 relay 프로세스의 생존 여부로 판단해서는 안 된다. 최소한 outbox_backlog_count, oldest_unpublished_age, commit-to-publish p50·p95·p99, publish 오류와 retry, lease 만료·회수, consumer 중복 억제, 처리 실패와 DLQ를 함께 관측해야 한다. CDC를 쓴다면 connector status, source offset lag, replication slot의 confirmed_flush_lsn·restart_lsn, WAL 디스크 증가도 운영 지표에 포함한다.
배포 전 staging에서는 transaction commit 직전·직후 애플리케이션 종료, broker publish 직후 relay 종료, ack timeout 뒤 동일 event ID 재발행, consumer DB commit 뒤 ack 전 종료를 검증해야 한다. 다중 worker의 aggregate 순서도 확인한다. CDC라면 connector 중단 중 WAL 증가와 재시작 catch-up, DB failover·upgrade 뒤 slot 연속성까지 시험해야 한다.
도입 판단: exactly-once보다 실패 구간의 소유권을 정한다
이 패턴은 DB 변경은 성공했는데 이벤트만 사라지는 dual write 문제를 작은 구조 변경으로 다루고 싶은 시스템에 적합하다. 특히 consumer까지 멱등하게 설계하고 backlog와 재시도를 운영 지표로 소유할 수 있을 때 효과가 있다. 반대로 전역 exactly-once나 모든 이벤트의 total order가 필수라면 Outbox 하나로 요구사항을 충족한다고 가정해서는 안 된다.
첫 구현은 Polling, 고정 event_id, consumer deduplication으로 정확성을 닫는 편이 합리적이다. 이후 실제 lag와 DB 부하가 목표를 넘을 때 CDC를 검토한다. 자신의 DB·broker·허용 지연·운영 역량을 위 선택표에 대입한 뒤, 사용하는 제품의 공식 문서에서 replication, ordering, idempotence 제한을 확인해야 한다.
참고 링크
- Microservices.io — Pattern: Transactional outbox
- AWS Prescriptive Guidance — Transactional outbox pattern
- Debezium Documentation — Outbox Event Router
- Debezium Documentation — PostgreSQL connector
- PostgreSQL Documentation — Logical Decoding
- PostgreSQL 16 Documentation — SELECT locking clause
- Apache Kafka 4.0 Documentation — Producer Configs
- Microsoft Azure Architecture Center — Transactional Outbox with Azure Cosmos DB