DB 커밋은 됐는데 Kafka 이벤트가 사라졌다 — PostgreSQL + Kafka로 Dual-Write 문제를 Transactional Outbox 패턴으로 해결하기
오전 2시 30분에 울리는 PagerDuty 알람은 참 묘한 기억을 남깁니다. 저도 딱 그런 상황을 겪었습니다. 마이크로서비스를 처음 설계할 때, DB에 주문을 저장하고 바로 Kafka로 OrderCreated 이벤트를 발행했습니다. 평소엔 멀쩡했는데, 어느 날 밤 Kafka 브로커가 30초 정도 죽었다가 살아났고 — 대시보드를 보니 주문 340건이 DB에는 들어갔는데 재고 서비스와 배송 서비스는 그 주문들을 전혀 모르고 있었습니다. 재고는 빠지지 않았고, 배송 요청은 생성되지 않았습니다. 이게 바로 Dual-Write 문제입니다. DB 저장과 이벤트 발행, 두 개의 서로 다른 리소스에 쓰기를 동시에 시도할 때 어느 하나가 실패하면 데이터 불일치가 생기는 구조적 문제입니다.
이 글은 그 문제를 PostgreSQL ACID 트랜잭션 안에서 뿌리부터 막는 Transactional Outbox 패턴을 직접 구현하는 방법을 다룹니다. Polling Publisher 방식 TypeScript 코드부터 Debezium CDC 연동, FOR UPDATE SKIP LOCKED 전략, WAL 모니터링까지 — 운영하면서 실제로 부딪혔던 함정들을 함께 정리했습니다. 이 글을 다 읽고 나면 Polling Publisher와 CDC 두 방식 중 자신의 상황에 맞는 것을 판단하고, 오늘 당장 기존 PostgreSQL 스키마에 outbox를 붙여볼 수 있습니다.
핵심 개념
Dual-Write 문제가 왜 피할 수 없는가
분산 시스템에서 "DB 저장"과 "이벤트 발행"은 본질적으로 서로 다른 두 리소스에 대한 쓰기입니다. PostgreSQL 트랜잭션과 Kafka 발행을 하나의 원자적 단위로 묶는 표준 방법은 존재하지 않습니다.
XA 트랜잭션(2PC)을 쓰면 이론적으로 가능하지만, Kafka는 JTA 방식의 XA 코디네이터를 지원하지 않습니다. Kafka의 자체 트랜잭션 API는 Kafka 내부(프로듀서 → 브로커) 원자성만 보장하고, PostgreSQL 트랜잭션과는 묶이지 않습니다. 결국 성능과 운영 복잡도만 높아지고 실질적인 보장은 받지 못하는 구조가 됩니다.
그러면 가능한 시나리오는 이렇게 됩니다.
| 시나리오 | 결과 |
|---|---|
| DB 커밋 성공 → Kafka 발행 실패 | 이벤트 유실, 소비자 서비스가 모름 |
| Kafka 발행 성공 → DB 롤백 | 이벤트는 갔는데 데이터가 없음 (유령 이벤트) |
| 네트워크 타임아웃 | 성공인지 실패인지조차 불명확 |
Trendyol도 정확히 이 문제를 경험했습니다. RabbitMQ에 "fire and forget" 방식으로 메시지를 던지다가 네트워크 장애 순간에 수천 건의 이벤트가 사라졌고, 그게 계기가 되어 Outbox 패턴을 도입했습니다.
전체 시스템 구조 한눈에 보기
Outbox 패턴을 도입하면 아키텍처가 이렇게 바뀝니다.
┌──────────────────────────────────────────────────┐
│ 애플리케이션 │
│ createOrder(), createPayment() │
└──────────────────────┬───────────────────────────┘
│ 단일 ACID 트랜잭션
▼
┌──────────────────────────────────────────────────┐
│ PostgreSQL │
│ ┌─────────────┐ ┌────────────────────┐ │
│ │ orders │ + │ outbox_events │ │
│ └─────────────┘ └──────────┬─────────┘ │
└─────────────────────────────────────┼────────────┘
│
┌───────────────────────┤
│ │
▼ ▼
┌───────────────────┐ ┌─────────────────────────┐
│ Polling Publisher │ │ Debezium CDC │
│ (500ms 주기 폴링) │ │ (WAL 실시간 스트리밍) │
└─────────┬─────────┘ └────────────┬────────────┘
└──────────────┬──────────┘
▼
┌───────────────────────┐
│ Kafka │
│ order.events │
│ payment.events │
└───────────┬───────────┘
│
┌─────────────┴────────────┐
▼ ▼
┌──────────────────┐ ┌───────────────────────┐
│ 재고 서비스 │ │ 배송 서비스 │
└──────────────────┘ └───────────────────────┘핵심 원리: 이벤트를 Kafka로 직접 날리는 대신, 같은 DB 트랜잭션 안에서 outbox 테이블에 먼저 기록합니다. 비즈니스 데이터와 이벤트 레코드가 함께 커밋되거나 함께 롤백되므로, 원자성은 PostgreSQL ACID가 보장해줍니다. Kafka 발행은 별도 relay 프로세스가 맡습니다.
outbox 테이블 설계
CREATE TABLE outbox_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
aggregate_type VARCHAR(255) NOT NULL, -- 비즈니스 단위 타입: "Order", "Payment"
aggregate_id VARCHAR(255) NOT NULL, -- 해당 단위의 고유 ID (Kafka 파티션 키로 사용)
event_type VARCHAR(255) NOT NULL, -- 이벤트 종류: "OrderCreated", "PaymentCompleted"
payload JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
published_at TIMESTAMPTZ, -- NULL = 미발행, NOT NULL = 발행 완료
retry_count INT NOT NULL DEFAULT 0,
failed_at TIMESTAMPTZ -- 최근 발행 실패 시각
);
-- 미발행 이벤트 조회 성능을 위한 부분 인덱스
CREATE INDEX idx_outbox_unpublished
ON outbox_events (created_at ASC)
WHERE published_at IS NULL;aggregate_type과 aggregate_id는 DDD(Domain-Driven Design)에서 빌려온 용어인데, 쉽게 말하면 "어떤 비즈니스 단위(주문, 결제)인지"와 "그 단위의 ID"입니다. aggregate_id는 나중에 Kafka 메시지 키로 사용되어 같은 주문에 대한 이벤트는 항상 동일한 파티션으로 라우팅됩니다.
published_at과 NULL 두 가지 상태만으로는 실무에서 부족한 순간이 옵니다. 발행 시도 중 프로세스가 죽거나, 계속 재시도하다 포기해야 하는 상황을 위해 retry_count와 failed_at을 초기 설계에 넣어두면 나중에 dead letter 처리나 알람 기준을 만들기가 훨씬 수월합니다.
Message Relay의 두 가지 방향
| 방식 | 메커니즘 | 지연 | 복잡도 | 전환 기준 |
|---|---|---|---|---|
| Polling Publisher | 스케줄러가 주기적으로 outbox 테이블 조회 | 100ms ~ 수 초 | 낮음 | 팀이 작고 빠르게 도입이 필요할 때 |
| CDC (WAL Tailing) | Debezium이 PostgreSQL WAL 실시간 읽기 | 수십 ms | 높음 | 초당 500건 이상 이벤트 또는 지연 500ms 이하 요구 시 |
실전 적용
Polling Publisher 구현하기
Polling Publisher는 추가 인프라 없이 애플리케이션 레벨에서 구현할 수 있어, 대부분의 팀에서 첫 번째 선택으로 삼기에 딱 좋습니다. 핵심은 여러 인스턴스가 동시에 같은 이벤트를 처리하지 않도록 행 수준 잠금을 거는 것입니다.
1단계: 비즈니스 로직과 outbox 삽입을 같은 트랜잭션에
// TypeScript + pg 라이브러리
import { Pool } from 'pg';
interface CreateOrderDto {
userId: string;
totalAmount: number;
}
async function createOrder(pool: Pool, orderData: CreateOrderDto): Promise<string> {
const client = await pool.connect();
try {
await client.query('BEGIN');
const orderResult = await client.query(
`INSERT INTO orders (user_id, total_amount, status)
VALUES ($1, $2, 'PENDING')
RETURNING id`,
[orderData.userId, orderData.totalAmount]
);
const orderId = orderResult.rows[0].id;
// 같은 트랜잭션 안에서 outbox에 이벤트 삽입
await client.query(
`INSERT INTO outbox_events
(aggregate_type, aggregate_id, event_type, payload)
VALUES ($1, $2, $3, $4)`,
[
'Order',
orderId,
'OrderCreated',
JSON.stringify({
orderId,
userId: orderData.userId,
totalAmount: orderData.totalAmount,
}),
]
);
await client.query('COMMIT');
return orderId;
} catch (err) {
await client.query('ROLLBACK');
console.error('[createOrder] 주문 생성 실패, 롤백:', err);
throw err;
} finally {
client.release();
}
}2단계: FOR UPDATE SKIP LOCKED로 경쟁 없이 폴링
FOR UPDATE SKIP LOCKED를 처음 짤 때 저도 이 구문의 중요성을 과소평가했다가, 두 인스턴스가 동시에 같은 이벤트를 집어가는 상황을 재현해보고 나서야 필수라는 걸 실감했습니다. 이 구문이 없으면 여러 인스턴스가 동일한 행을 동시에 처리하려다 충돌하거나 데드락이 발생할 수 있습니다.
import { Pool } from 'pg';
import { Producer } from 'kafkajs';
async function runPollingPublisher(pool: Pool, producer: Producer): Promise<void> {
const client = await pool.connect();
try {
await client.query('BEGIN');
const result = await client.query(`
SELECT id, aggregate_type, aggregate_id, event_type, payload
FROM outbox_events
WHERE published_at IS NULL
ORDER BY created_at ASC
LIMIT 100
FOR UPDATE SKIP LOCKED
`);
if (result.rows.length === 0) {
await client.query('ROLLBACK');
return;
}
// kafkajs sendBatch API
await producer.sendBatch({
topicMessages: result.rows.map(row => ({
topic: `${row.aggregate_type.toLowerCase()}.events`,
messages: [
{
key: row.aggregate_id, // 파티션 키 — 같은 주문의 이벤트는 항상 같은 파티션
value: JSON.stringify(row.payload),
},
],
})),
});
const ids = result.rows.map(r => r.id);
await client.query(
`UPDATE outbox_events
SET published_at = NOW()
WHERE id = ANY($1::uuid[])`,
[ids]
);
await client.query('COMMIT');
} catch (err) {
await client.query('ROLLBACK');
// published_at이 갱신되지 않으므로 다음 폴링에서 자동 재시도
console.error('[PollingPublisher] 발행 실패, 다음 주기에 재시도:', err);
} finally {
client.release();
}
}
// setInterval 대신 재귀 setTimeout — 이전 실행이 끝나야 다음이 시작됨
async function schedulePolling(pool: Pool, producer: Producer): Promise<void> {
await runPollingPublisher(pool, producer).catch(err => {
console.error('[PollingPublisher] 처리되지 않은 에러:', err);
});
setTimeout(() => schedulePolling(pool, producer), 500);
}
schedulePolling(pool, producer);| 코드 포인트 | 이유 |
|---|---|
FOR UPDATE SKIP LOCKED |
이미 잠긴 행은 건너뜀 — 다중 인스턴스가 같은 이벤트를 중복 처리하는 것을 원천 차단 |
aggregate_id를 메시지 key로 |
같은 주문의 이벤트가 항상 같은 파티션 → aggregate 내 순서 보장 |
| Kafka 발행 실패 시 ROLLBACK | published_at이 갱신되지 않으므로 다음 폴링에서 자동 재처리 |
재귀 setTimeout |
setInterval은 이전 실행이 끝나지 않아도 다음 실행을 시작 — 동시 실행 방지를 위해 재귀 패턴 사용 |
LIMIT 100 |
한 번에 너무 많은 행을 잡으면 DB 트랜잭션 시간이 길어지고 Kafka 발행 타임아웃 위험 증가 |
순서 보장의 범위:
aggregate_id기반 파티셔닝은 같은 주문 내의 이벤트 순서를 보장하지만, 서로 다른 주문 사이의 전역 순서는 보장하지 않습니다. "OrderCreated가 PaymentCompleted보다 전역으로 항상 먼저 오는가"를 보장해야 한다면 별도 설계가 필요합니다.
Debezium CDC로 WAL 실시간 연동하기
Polling Publisher가 잘 돌아가다가 이벤트 처리량이 초당 수백 건을 넘어서면, 500ms 간격 폴링이 병목이 되기 시작합니다. 이때 선택지로 떠오르는 것이 CDC(Change Data Capture) 방식입니다. Debezium이 PostgreSQL의 WAL(Write-Ahead Log)을 직접 읽어 이벤트를 수십 밀리초 안에 Kafka로 스트리밍합니다.
논리 복제 개념 먼저 잡기
논리 복제(Logical Replication): PostgreSQL이 데이터 변경사항을 SQL 수준으로 기록하는 기능입니다. 바이너리 복제와 달리 특정 테이블만 선택할 수 있어 Debezium이 outbox 테이블만 감시할 수 있습니다.
Replication Slot은 Debezium이 어디까지 WAL을 읽었는지를 추적하는 PostgreSQL 내부 커서입니다. 이 커서 이전의 WAL은 삭제되지 않으므로, Debezium이 오래 중단되면 WAL이 쌓여 디스크가 가득 찰 수 있습니다.
PostgreSQL 논리 복제 활성화
-- postgresql.conf 설정 (또는 ALTER SYSTEM으로 온라인 변경)
ALTER SYSTEM SET wal_level = 'logical';
ALTER SYSTEM SET max_replication_slots = 5;
ALTER SYSTEM SET max_wal_senders = 5;
-- 설정 후 PostgreSQL 재시작 필요Debezium Connector 설정
{
"name": "outbox-connector",
"config": {
"connector.class": "io.debezium.connector.postgresql.PostgresConnector",
"database.hostname": "postgres",
"database.port": "5432",
"database.user": "debezium",
"database.password": "secret",
"database.dbname": "mydb",
"topic.prefix": "mydb",
"table.include.list": "public.outbox_events",
"plugin.name": "pgoutput",
"slot.name": "debezium_slot",
"transforms": "outbox",
"transforms.outbox.type": "io.debezium.transforms.outbox.EventRouter",
"transforms.outbox.table.field.event.id": "id",
"transforms.outbox.table.field.event.key": "aggregate_id",
"transforms.outbox.table.field.event.type": "event_type",
"transforms.outbox.table.field.event.payload": "payload",
"transforms.outbox.route.by.field": "aggregate_type",
"transforms.outbox.route.topic.replacement": "${routedByValue}.events",
"tombstones.on.delete": "false"
}
}Outbox Event Router SMT(Single Message Transform): Debezium이 outbox 테이블의 INSERT 이벤트를 감지하면 이 변환기가
aggregate_type값에 따라 적절한 Kafka 토픽으로 라우팅합니다.Order면order.events,Payment면payment.events로 자동 분기됩니다.
CDC 방식에서 published_at은 어떻게 관리되나?
Polling Publisher 방식과 달리, CDC를 쓸 때는 published_at을 직접 업데이트할 필요가 없습니다. Debezium이 WAL의 LSN(Log Sequence Number)을 replication slot에 추적하므로, 재시작 후에도 어디까지 처리했는지 Debezium이 자체적으로 알고 있습니다. 애플리케이션 레벨에서 발행 상태를 별도로 관리하지 않아도 된다는 것이 CDC의 실질적인 장점 중 하나입니다.
CDC 방식의 전체 흐름
[PostgreSQL WAL]
└─ logical replication slot (pgoutput)
└─ Debezium PostgreSQL Connector (Kafka Connect)
└─ Outbox Event Router SMT
├─ order.events
├─ payment.events
└─ delivery.events대신 Kafka Connect 클러스터와 Debezium 커넥터를 운영해야 하는 부담이 생깁니다. 팀 규모와 인프라 여건을 솔직하게 따져보고 선택하는 것이 좋습니다.
운영 자동화 — 정리 스케줄러와 WAL 모니터링
솔직히 이 부분은 처음엔 대충 넘어갔다가 나중에 후회했습니다. outbox 테이블은 이벤트가 발행된 후에도 레코드가 남아 계속 쌓이는데, 몇 주 지나니까 테이블이 수 GB로 불어나 있었습니다. 쿼리 성능이 슬슬 흔들리는 걸 체감하고 나서야 정리 job을 붙였습니다.
발행 완료 이벤트 정리
-- 7일 지난 발행 완료 이벤트 정리 (pg_cron 또는 외부 cron job)
DELETE FROM outbox_events
WHERE published_at IS NOT NULL
AND published_at < NOW() - INTERVAL '7 days';WAL 보존량 모니터링 (CDC 사용 시 필수)
Debezium이 예상치 못하게 중단되면 replication slot이 WAL 삭제를 막아 디스크를 가득 채울 수 있습니다.
-- WAL 보존량 확인
SELECT
slot_name,
active,
pg_size_pretty(
pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)
) AS wal_behind
FROM pg_replication_slots;wal_behind가 수 GB를 넘어가면 경보가 울리도록 설정해두는 것을 권장합니다. Debezium이 장시간 중단될 것 같으면 slot을 명시적으로 삭제하거나 일시 중지하는 운영 절차를 미리 문서화해두면 새벽 알람이 줄어듭니다.
장단점 분석
장점
| 항목 | 내용 |
|---|---|
| 원자성 보장 | DB ACID를 그대로 활용 — 이벤트와 데이터가 항상 함께 커밋 또는 롤백 |
| 재시도 자동화 | Relay 실패 시 outbox 레코드가 남아 있어 다음 실행에서 자동 재처리 |
| 브로커 독립성 | Kafka 외에 RabbitMQ, SQS 등 다양한 브로커와 동일하게 연동 가능 |
| 기존 인프라 활용 | Polling 방식은 추가 인프라 없이 현재 DB만으로 시작 가능 |
| 순서 보장 | aggregate_id를 파티션 키로 사용하면 동일 엔티티의 이벤트 순서 유지 |
단점 및 주의사항
| 항목 | 내용 | 대응 방안 |
|---|---|---|
| At-least-once 발행 | 정확히 한 번이 아닌 최소 한 번 — 중복 발행 가능 | 소비자에서 멱등성 처리 필수 (idempotency key) |
| aggregate 내 순서만 보장 | 같은 aggregate_id끼리만 순서 보장 — 전역 순서는 불가 |
전역 순서가 필요한 경우 별도 설계 필요 |
| DB 쓰기 증폭 | 모든 이벤트마다 outbox에 추가 쓰기 발생 | 인덱스 최적화, 정리 스케줄러 운영 |
| outbox 테이블 증가 | 발행 완료 레코드가 계속 축적 | 주기적 삭제 job 필수 |
| WAL 디스크 위험 (CDC) | Debezium 중단 시 replication slot이 WAL 삭제 차단 | pg_replication_slots 모니터링, 슬롯 비활성화 절차 수립 |
| 인프라 복잡도 (CDC) | Kafka Connect + Debezium 운영 부담 | 팀 규모와 요구사항에 맞는 방식 선택 |
멱등성(Idempotency): 동일한 작업을 여러 번 수행해도 결과가 변하지 않는 성질입니다. 이벤트가 중복 발행될 수 있으므로, 소비자 서비스는 이미 처리한 이벤트 ID를 기록(DB 또는 Redis)해 중복 처리를 방지해야 합니다.
실무에서 가장 흔한 실수
-
FOR UPDATE SKIP LOCKED누락: Polling Publisher를 여러 인스턴스로 실행할 때 이 구문을 빼면, 같은 이벤트를 여러 인스턴스가 동시에 처리해 중복 발행이 급격히 늘어납니다. 처음엔 단일 인스턴스로 잘 돌아가다가 스케일아웃하는 순간 문제가 터지는 게 이 함정입니다. -
setInterval로 폴링 스케줄링: 이전 실행이 끝나지 않아도 다음 실행이 시작되어 동시 실행 문제가 생깁니다. 재귀setTimeout패턴으로 이전 실행이 완전히 끝난 후에만 다음 실행이 시작되도록 하는 것이 안전합니다. -
replication slot 방치: CDC 환경에서 Debezium이 장시간 중단됐을 때 slot을 삭제하지 않으면 WAL이 무제한 쌓입니다. 운영 절차에 "Debezium 비활성화 시 slot 삭제 또는 일시 중지" 단계를 미리 명시해두면 야간 장애 대응이 훨씬 수월합니다.
-
outbox 정리 job 미운영: 발행 완료된 이벤트를 영구 보관할 이유가 없는데도 정리 job 없이 두면, 몇 달 후 테이블 크기가 쿼리 성능에 영향을 주기 시작합니다. 처음부터
pg_cron설정을 같이 넣어두는 것을 권장합니다. -
서비스 내부 통신에도 Outbox 적용: Outbox 패턴은 서비스 경계를 넘는 이벤트에 적합합니다. 같은 서비스 내부 모듈 간 통신에까지 적용하면 불필요한 복잡도만 늘어납니다. 꼭 필요한 경계에만 적용하는 것이 현명합니다.
마치며
Transactional Outbox 패턴은 Dual-Write 문제를 DB 트랜잭션의 원자성을 빌려 해결하는, 단순하지만 강력한 접근법입니다.
Polling Publisher로 시작해서 초당 500건 이상의 이벤트를 처리해야 하거나 지연이 500ms 이하여야 하는 요구사항이 생기면 CDC로 전환하는 것이 현실적인 경로입니다. 두 방식 모두 at-least-once 보장이 전제이므로, 소비자 쪽 멱등성 설계는 항상 함께 고려하는 것이 좋습니다.
지금 바로 시작해볼 수 있는 3단계:
-
outbox 테이블 추가: 기존 PostgreSQL 스키마에
outbox_events테이블을 추가하고, 부분 인덱스(WHERE published_at IS NULL)를 함께 생성하면 조회 성능 걱정 없이 시작할 수 있습니다. -
Polling Publisher 연결:
FOR UPDATE SKIP LOCKED를 사용하는 폴링 쿼리를 재귀setTimeout패턴으로 실행해볼 수 있습니다. Trendyol의 PollingOutboxPublisher 오픈소스를 참고하면 검증된 구현을 바로 살펴볼 수 있습니다. -
소비자 멱등성 추가: 이벤트
id를 처리 완료 테이블(또는 Redis)에 기록하고, 중복 수신 시 조기 리턴하는 로직을 소비자 서비스에 추가하는 것을 권장합니다.
이 세 단계를 완료하고 나면, Kafka 브로커가 30초 죽었다가 살아나도 DB에 들어간 주문은 반드시 이벤트로 발행됩니다. 오전 2시 30분 알람이 조금은 줄어들 수 있을 겁니다. 프로덕션에 올리기 전에는 통합 테스트 환경에서 Kafka를 강제로 중단하는 시나리오를 한 번 돌려보는 것을 권장합니다. 이 패턴이 실제로 동작한다는 확신은 코드가 아니라 장애 시뮬레이션에서 오기 때문입니다.
참고 자료
- Transactional Outbox Pattern | microservices.io
- Outbox Event Router | Debezium 공식 문서
- PostgreSQL Connector | Debezium 공식 문서
- Transactional Outbox Pattern | AWS Prescriptive Guidance
- The Transactional Outbox Pattern | Confluent Developer
- Transactional Outbox: Database-Kafka Consistency | Conduktor
- Transactional Outbox Pattern 실전 | SeatGeek Engineering
- Outbox Pattern Story at Trendyol | Trendyol Tech
- Outbox Performance Boost: Faster & More Reliable | Trendyol Tech
- PollingOutboxPublisher | GitHub
- Outbox, Inbox patterns and delivery guarantees explained | event-driven.io
- Revisiting the Outbox Pattern | Decodable Blog
- Stop Overusing the Outbox Pattern | Squer
- A Use Case for Transactions: Adapting to Transactional Outbox Pattern | Spring Blog