[Pattern] Outbox Pattern: DB + 메시지의 원자성
Outbox pattern, transactional outbox, Debezium, CDC, dual write problem, transactional messaging
정의
Outbox Pattern = DB 변경 + 메시지 발행 의 원자성 보장. 이중 쓰기 (dual write) 문제 의 표준 해결.
문제: Dual Write
sequenceDiagram
autonumber
participant App
participant DB
participant Broker as Kafka/RabbitMQ
App->>DB: BEGIN<br/>INSERT order<br/>COMMIT
DB-->>App: OK
App->>Broker: publish OrderCreated
Note over App,Broker: ✗ App crash, 또는<br/>Broker timeout
Broker--xApp: 응답 없음
Note over DB,Broker: DB 는 INSERT 됨<br/>이벤트는 안 감 → *불일치*
| 시나리오 | 결과 |
|---|---|
| DB OK + Publish OK | 일관성 |
| DB OK + Publish fail | 불일치 (DB 만 변경) |
| DB fail + Publish OK | 불일치 (이벤트만 갔음) |
| 둘 다 fail | OK (취소) |
→ 두 시스템을 한 번에 일관 시키는 건 불가능. 2PC 는 비현실적.
해결: Outbox 테이블
flowchart LR
App -->|단일 트랜잭션| DB
DB --> Orders[(orders)]
DB --> Outbox[(outbox)]
Outbox -.relay.-> Broker[(Broker)]
BEGIN;
INSERT INTO orders (id, ...) VALUES (?, ...);
INSERT INTO outbox (event_type, payload, aggregate_id)
VALUES ('OrderCreated', ?, ?);
COMMIT;
-- 한 트랜잭션 = 원자성!
이후 별도 프로세스 가 outbox 를 읽고 broker 로 publish.
2가지 Relay 방식
1. Polling Publisher
flowchart LR
Outbox[(outbox)] -.SELECT unpublished.-> Relay[Relay process]
Relay -->|publish| Broker
Relay -->|UPDATE published=true| Outbox
while True:
rows = db.query("SELECT * FROM outbox WHERE published=false LIMIT 100")
for row in rows:
broker.publish(row.event)
db.execute("UPDATE outbox SET published=true WHERE id=?", row.id)
time.sleep(0.1)
- 구현 간단.
- DB 부담 (polling).
- Latency ≈ polling 주기.
2. CDC (Change Data Capture)
flowchart LR
DB[(DB)] -->|WAL / binlog| CDC[Debezium / Maxwell]
CDC --> Kafka[(Kafka)]
Kafka -->|topic| Consumers
DB 의 transaction log (PostgreSQL WAL / MySQL binlog) 를 실시간 캡처.
| 도구 | DB |
|---|---|
| Debezium | PG, MySQL, MongoDB, Oracle 등 |
| Maxwell | MySQL |
| AWS DMS | 다양 |
| Striim | 다양 |
IMPORTANT
Outbox + CDC 는 2026 시점 마이크로서비스 메시징의 표준. 더 이상 애플리케이션 코드에서 publish 하지 않는다.
Outbox 테이블 디자인
CREATE TABLE outbox (
id BIGSERIAL PRIMARY KEY,
aggregate_type TEXT NOT NULL, -- 'Order'
aggregate_id TEXT NOT NULL, -- 'order_42'
event_type TEXT NOT NULL, -- 'OrderCreated'
payload JSONB NOT NULL,
occurred_at TIMESTAMPTZ DEFAULT NOW(),
published BOOLEAN DEFAULT false,
published_at TIMESTAMPTZ
);
CREATE INDEX idx_outbox_unpublished
ON outbox(id) WHERE published = false;
TIP
partial index 로 unpublished 만 빠른 SELECT.
처리 후 정리
flowchart TD
P[Outbox 너무 큼?]
P --> O1[옵션 1: published row 즉시 DELETE]
P --> O2[옵션 2: 별도 archive 테이블]
P --> O3["옵션 3: TTL (며칠 보관)"]
CDC 가 binlog stream 위주 라 published 컬럼 자체 필요 없음. Debezium 이 INSERT 이벤트 그대로 전송.
메시지 순서 보장
flowchart LR
Tx1[Tx 1: INSERT outbox id=100] --> Order
Tx2["Tx 2: INSERT outbox id=99 (먼저 시작했지만 commit 늦음)"]
Tx2 --> Order
Note["DB ID 순 ≠ commit 순"]
CAUTION
concurrent transaction 때문에 id 순서 ≠ commit 순서. strict 순서 보장 이 필요하면 aggregate ID 별 partition 으로.
At-least-once + Idempotency
Outbox + relay 는 at-least-once. 중복 발행 가능 (relay 가 publish 후 update 전 crash).
→ consumer 가 idempotent 처리. 자세한 건 idempotency-keys.
Inbox Pattern (반대 방향)
flowchart LR
Broker -->|consume| Inbox[(inbox)]
Inbox -->|단일 트랜잭션| DB
DB -->|이미 처리?| Skip[중복 skip]
수신 측의 멱등 보장. Outbox 와 대칭.
흔한 함정
WARNING
- *
UPDATE published=true와 publish 가 별도 transaction = relay crash 시 중복 발행. at-least-once 인정 + consumer idempotent. - Outbox 의 순서 무시 = 다운스트림이 순서 가정. partition / sequence 명시.
- Outbox 가 너무 큼 = 정리 필요. 보관 정책.
SELECT FOR UPDATE SKIP LOCKED없이 multi worker = 같은 row 동시 처리 → 중복.
관련 위키
이 글의 용어 (6개)
- [Distributed] Kafka: 분산 로그, partition, consumer groupdistributed-systems
- 정의 Apache Kafka = 분산 commit log. 고처리량 (수백만 msg/s), 영속, 수평 확장. event-driven 아키텍처 의 de facto. 핵심 개념: …
- [Distributed] RabbitMQ: exchange, queue, routing keydistributed-systems
- 정의 RabbitMQ = AMQP 0.9.1 기반 traditional message broker. exchange → queue 라우팅, workload distribution…
- [Pattern] Event Sourcing: 상태 대신 이벤트 누적distributed-systems
- 정의 Event Sourcing = 현재 상태 대신 과거 이벤트 시퀀스 를 저장. 현재 상태 = event 모두 replay 한 결과. 핵심 약속 - 과거 모든 변화 기록. 감사…
- [Pattern] Idempotency Keys: 중복 요청 안전 처리distributed-systems
- 정의 Idempotency = 같은 요청을 N번 보내도 결과가 1번과 동일. 분산 시스템 / 결제 / API 의 안전망. [!IMPORTANT] 네트워크는 항상 timeout /…
- [Pattern] Saga: 분산 트랜잭션의 보상 흐름distributed-systems
- 정의 Saga = 여러 service 의 로컬 트랜잭션 을 연결한 긴 흐름. 한 단계 실패 시 이전 단계의 보상 (compensating) 트랜잭션 으로 논리적 롤백. [!IMP…
- [Redis] Pub/Sub vs Streams: 휘발 신호 vs 영속 로그database-internals
- 정의 - Pub/Sub ( / ): 지금 듣고 있는 구독자 에게만 메시지가 전달되는 휘발성 신호. 영속 없음, ACK 없음. fan-out. - Streams ( / / / ):…
이 개념을 다룬 위키 페이지 (11)
- wiki[AWS] EventBridge: 이벤트 버스, 스케줄러, partner sources
- wiki[AWS] Step Functions: 워크플로 오케스트레이션
- wiki[Pattern] CQRS: Command / Query 분리, read model
- wiki[Pattern] Event Sourcing: 상태 대신 이벤트 누적
- wiki[Pattern] Idempotency Keys: 중복 요청 안전 처리
- wiki[Distributed] Kafka: 분산 로그, partition, consumer group
- wiki[Distributed] Message Broker 비교: Kafka / RabbitMQ / NATS / SQS / Redis Streams
- wiki[Distributed] RabbitMQ: exchange, queue, routing key
- wiki[Pattern] Saga: 분산 트랜잭션의 보상 흐름
- wiki[Django] Transactions: atomic, on_commit, savepoint
- wiki[Search] ES Indexing: bulk, refresh, ingest pipeline
💬 댓글