콘텐츠로 이동

CDC & Outbox 패턴

분류: Layer 8 - 데이터베이스 심화

CDC(Change Data Capture) 는 데이터베이스의 변경 이력(INSERT/UPDATE/DELETE)을 WAL/binlog 같은 트랜잭션 로그에서 읽어 외부 시스템으로 전파하는 기술이고, Outbox 패턴 은 비즈니스 데이터 저장과 이벤트 발행을 한 DB 트랜잭션 안에서 원자적으로 묶기 위해 outbox 테이블에 이벤트를 함께 기록하고 별도 relayer가 발행을 책임지도록 분리하는 설계 패턴이다. 둘은 자주 결합되어 “Outbox table → Debezium(CDC) → Kafka” 가 분산 시스템의 신뢰성 있는 이벤트 발행 표준이 된다.


2.1 dual write problem — Outbox가 푸는 핵심 문제

섹션 제목: “2.1 dual write problem — Outbox가 푸는 핵심 문제”

분산 시스템에서 “DB 변경 + 메시지 발행”을 별도 작업으로 처리하면 partial failure가 필연이다.

// 안티패턴 — 두 시스템에 따로 쓰는 dual write
async function placeOrder(input: PlaceOrderDto) {
const order = await db.order.insert(input); // (1) PG INSERT
await kafka.publish("order.created", order); // (2) Kafka publish
return order;
}

가능한 실패 시나리오:

시나리오결과영향
(1) 성공 → (2) 네트워크DB에는 있는데 메시지 없음다운스트림(알림, 정산, 검색)이 주문 존재 모름
(1) 성공 → (2) 실패DB에는 있는데 메시지 없음위와 동일
(2) 성공 → (1) rollback메시지는 발행됐는데 DB는 없음존재하지 않는 주문에 대한 처리 시도 → 다운스트림 오류
(1) 성공 → 앱 크래시DB에는 있는데 메시지 없음(1)과 동일

2PC(XA)는 답이 아니다: PG–Kafka 환경에서 XA가 사실상 불가능하고(Kafka는 XA 미지원), 가능하더라도 가용성과 throughput을 심각하게 깎는다. 분산 트랜잭션을 회피하면서도 eventual consistencyat-least-once 를 보장하는 패턴이 필요하다 — 그것이 Outbox다.

2.2 시니어 백엔드/플랫폼 엔지니어의 핵심 도구

섹션 제목: “2.2 시니어 백엔드/플랫폼 엔지니어의 핵심 도구”

CDC + Outbox는 시니어 도약 시점에 자주 마주치는 인프라 패턴이다:

  • 마이크로서비스 간 데이터 동기화: 한 서비스 DB 변경을 다른 서비스 / Kafka / EventBridge / SQS로 전파.
  • 검색·임베딩 인덱스 동기화: PG의 documents 테이블 변경 → embedding 워커 → pgvector / OpenSearch / Elasticsearch.
  • 데이터 웨어하우스 stream: PG → Snowflake / BigQuery / ClickHouse 실시간 동기화. 배치 ETL 대체.
  • audit log compliance: 금융·의료·법률 도메인의 감사 이력 자동 수집.
  • L9 SAGA, CQRS/Event Sourcing의 인프라 기반: Saga 보상 이벤트 발행, ES 이벤트 스토어에서 read model로 stream.

특히 4년차에서 시니어로 도약할 때, “데이터 정합성”과 “분산 시스템 신뢰성” 두 영역의 교차점을 다룰 수 있어야 하는데 CDC + Outbox가 그 교차점이다.

2.3 프론트엔드 → 백엔드 학습자에게

섹션 제목: “2.3 프론트엔드 → 백엔드 학습자에게”

프론트엔드에서 익숙한 “낙관적 업데이트(optimistic update) + 서버 reconciliation” 패턴과 구조가 비슷하다. UI는 일단 화면을 바꾸고 백그라운드에서 서버와 맞추고, 실패 시 보상 처리한다. Outbox는 “DB에 일단 적고, 백그라운드 relayer가 외부 시스템과 맞추고, 실패 시 retry로 보상”한다. 동작 시점만 다르지 eventual consistency를 admit하고 보정으로 채운다 는 발상은 같다.


WAL(Write-Ahead Log)은 PG 트랜잭션의 1차 기록이다. 디스크 데이터 파일을 직접 수정하기 전에 변경 사항을 먼저 WAL에 기록하고 fsync 한다. 이것이 크래시 복구의 근거이자 CDC의 원천이다.

┌─ 클라이언트 ─┐ ┌──────── PostgreSQL ────────┐
│ INSERT INTO │ → │ 1) WAL buffer 기록 │
│ orders ... │ │ 2) commit 시 WAL fsync │
└─────────────┘ │ 3) checkpoint 시 data file │
│ 반영 │
└──────────────────────────────┘
│ logical decoding
┌──────────────────────────────┐
│ Replication Slot │
│ - slot_name: debezium │
│ - restart_lsn: 0/1A2B3C4D │
│ - confirmed_flush_lsn: ... │
└──────────────────────────────┘
Debezium

핵심 구성요소:

  • Logical Replication: WAL을 “row 단위 SQL-level 변경”으로 디코딩한다. raw bytes를 그대로 흘려보내는 physical replication과 다르다.
  • Replication Slot: consumer가 어디까지 읽었는지(restart_lsn / confirmed_flush_lsn) 추적한다. PG는 slot이 추적하는 위치 이후의 WAL을 절대 지우지 않는다 — 이게 안전성의 원천이자 운영 함정의 원천이다.
  • pgoutput 플러그인: PG 10+ 표준 logical decoding output plugin. 이전엔 wal2json, decoderbufs 같은 외부 플러그인이 필요했지만 이제는 built-in.
  • wal_level = logical: postgresql.conf 또는 RDS parameter group에서 설정. 변경 후 재시작 필요.
  • max_wal_senders / max_replication_slots: walsender 프로세스 수와 동시 slot 수 한도. RDS 기본 10/10. CDC 연결마다 1씩 점유.

Debezium은 Kafka Connect 위에서 동작하는 source connector다. PostgreSQL 외에 MySQL, MongoDB, Cassandra, Oracle, SQL Server 등을 지원한다.

┌─ PostgreSQL ─┐ logical ┌─ Debezium ─┐ Kafka ┌─ Kafka ─┐
│ WAL │ ─decoding─▶ │ Connector │ ─Connect▶ │ Topic │
│ Slot │ │ (Snapshot │ │ │
│ │ │ + Stream) │ └─────────┘
└──────────────┘ └────────────┘
│ offset store
┌─────────────┐
│ Kafka topic │
│ (offsets) │
└─────────────┘

라이프사이클:

  1. Initial Snapshot: 첫 기동 시 기존 테이블 row를 모두 읽어 Kafka로 발행. 부하 큰 단계.
  2. Streaming: 이후엔 WAL을 따라가며 변경 발생 즉시 발행.
  3. Tombstone: DELETE 이벤트 발행 후 같은 키로 null 메시지를 한 번 더 보내 Kafka log compaction이 작동하게 한다.

snapshot.mode 4가지:

모드동작사용 시점
initial최초 1회 snapshot → streaming (기본)대부분의 신규 connector
neversnapshot 건너뛰고 현재 WAL부터 streaming별도로 backfill 했거나 과거 데이터 불필요
always매 재시작마다 snapshot거의 사용 안 함. 대용량 테이블에 위험
when_neededoffset 없거나 invalid 시에만 snapshotslot 재생성 후 자동 복구가 필요한 운영 환경
initial_onlysnapshot 후 종료 (streaming 없음)일회성 backfill

key.converter / value.converter: Avro(Schema Registry 필요) / JSON(스키마 inline) / Protobuf. Schema Registry를 안 쓰면 메시지 크기가 커지고 backward compatibility 추적이 어렵다.

3.3 Outbox 패턴 — 테이블 스키마와 흐름

섹션 제목: “3.3 Outbox 패턴 — 테이블 스키마와 흐름”

가장 일반적인 outbox 테이블 스키마:

CREATE TABLE outbox (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
aggregate_type TEXT NOT NULL, -- "Order", "User"
aggregate_id TEXT NOT NULL, -- "ORD-2026-001"
event_type TEXT NOT NULL, -- "OrderCreated", "OrderShipped"
payload JSONB NOT NULL, -- 도메인 이벤트 본문
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- aggregate별 시간순 조회용 (디버깅/감사)
CREATE INDEX outbox_aggregate_created_idx
ON outbox (aggregate_type, aggregate_id, created_at);

핵심은 비즈니스 트랜잭션 안에서 outbox에 함께 INSERT 하는 것:

// NestJS + TypeORM 예시
async placeOrder(input: PlaceOrderDto): Promise<Order> {
return this.dataSource.transaction(async (tx) => {
// 1) 비즈니스 데이터
const order = await tx.getRepository(Order).save({ ...input });
// 2) 같은 트랜잭션 안에 outbox INSERT
await tx.getRepository(OutboxRow).save({
aggregateType: "Order",
aggregateId: order.id,
eventType: "OrderCreated",
payload: {
orderId: order.id,
amount: order.totalAmount,
userId: order.userId,
items: order.items,
},
});
return order;
});
// ▲ 두 INSERT가 한 COMMIT에 묶여 — 둘 다 성공하거나 둘 다 rollback
}

이후 별도 프로세스가 outbox에서 읽어 Kafka로 발행한다 — 이게 relayer 역할. 구현 방식은 두 갈래:

  • Polling relayer: SELECT ... WHERE processed_at IS NULL FOR UPDATE SKIP LOCKED LIMIT 100 로 폴링.
  • CDC relayer (Debezium): outbox 테이블 자체를 CDC가 감지해 자동 발행.

CDC가 outbox 테이블의 INSERT 이벤트를 그대로 Kafka에 발행하면, 다운스트림은 “outbox row 자체”를 받게 된다({id, aggregate_type, aggregate_id, event_type, payload}). 다운스트림 입장에선 이게 어색하다 — 진짜 받고 싶은 건 payload 안의 도메인 이벤트다.

Debezium은 이걸 풀어주는 Outbox Event Router라는 SMT(Single Message Transform)를 제공한다:

# debezium-pg-connector.properties (Kafka Connect)
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.payload=payload
transforms.outbox.route.by.field=aggregate_type
transforms.outbox.route.topic.replacement=domain.${routedByValue}.events

이 설정으로:

  • aggregate_type=Order → 토픽 domain.Order.events로 라우팅
  • 메시지 key = aggregate_id (Kafka partition 분배 + 같은 aggregate 이벤트 순서 보존)
  • 메시지 value = payload 그 자체 (outbox row의 메타데이터는 헤더로 이동)

다운스트림은 outbox 테이블 존재를 모른 채 깔끔한 도메인 이벤트만 본다.

3.5 Delivery Semantics — at-least-once의 현실

섹션 제목: “3.5 Delivery Semantics — at-least-once의 현실”

CDC + Outbox는 기본적으로 at-least-once 다. Debezium이 재시작하거나 Kafka 쪽 commit이 timing 문제로 누락되면 같은 이벤트가 재발행될 수 있다.

Exactly-once는 이론상 불가능: Two Generals Problem이 분산 시스템의 한계로 알려져 있다. 메시지가 발행됐는지 ack가 사라지면, 보낸 쪽은 재시도할 수밖에 없다.

실무 답은 “effectively once” = at-least-once + idempotent consumer 다:

// consumer 측 inbox 패턴
async function handleOrderCreated(event: OrderCreatedEvent) {
const inserted = await db.processedEvents
.insert({ eventId: event.eventId })
.onConflict("eventId")
.ignore(); // 이미 있으면 무시
if (inserted.rowCount === 0) return; // 중복
// 실제 처리
await emailService.sendOrderConfirmation(event);
}

핵심은 consumer 측에서 dedup 키(event_id)로 멱등성을 강제하는 것이다. producer 쪽에서 exactly-once를 만들려고 노력하는 것보다, consumer 측에서 idempotency를 보장하는 게 훨씬 견고하다 — 인프라 어디서 중복이 발생하든 막을 수 있기 때문이다.

이를 Inbox Pattern이라 부르며, Outbox(producer 쪽)와 짝을 이룬다.

3.6 새 발행 패턴을 만났을 때의 5가지 질문 (일반 공식)

섹션 제목: “3.6 새 발행 패턴을 만났을 때의 5가지 질문 (일반 공식)”

Outbox/CDC를 익히고 나면, 다른 메시지·이벤트 발행 인프라(Kafka Streams Outbox, NATS JetStream, Pulsar Functions, Iceberg CDF, ksqlDB Materialized View, EventBridge Pipes, Listen/Notify 등)를 마주쳤을 때 같은 다섯 가지 질문으로 빠르게 평가할 수 있다. 새 인프라의 평가 체크리스트로 그대로 재사용 가능한 일반 공식이다.

#질문답이 명확하지 않으면 의심할 것
1원자성은 어디서 보장되는가? (DB tx / 2PC / saga / eventual)dual write problem(§2.1) 재발 가능성
2발행 책임이 어디로 분리되는가? (producer 내장 / 별도 relayer / CDC / consumer-driven)producer crash 시 메시지 유실 가능성
3delivery guarantee는? (at-most-once / at-least-once / effectively-once)exactly-once 약속하면 거의 거짓말(§3.5)
4consumer 측 멱등성은 어떻게 보장하는가? (event id dedup / content hash / UPSERT / 자연 idempotent action)“두 번 받아도 안전한가?”(§3.5·§8.6)
5실패 시 어떤 흔적이 남는가? (retry queue / DLQ / compensating event / audit log)silent failure · 디버깅 불가(§8.5)

같은 5칸을 4개 다른 발행 인프라에 적용한 비교:

발행 인프라① 원자성② 발행 분리③ delivery④ 멱등성⑤ 실패 흔적
Kafka Producer 직접 publish (안티패턴)없음producer 내장at-least-once직접 구현없음
Outbox + Debezium (이 문서)DB txDebezium relayerat-least-onceInbox 패턴(§8.6)DLQ + outbox audit
AWS EventBridge Pipes (DDB Streams → Bridge)DDB single-table txPipes (managed)at-least-onceidempotent targetDLQ to SQS
pg_notify + LISTENDB txconsumer-drivenat-most-once (subscriber 없으면 유실)N/A없음

①과 ⑤가 가장 자주 비는 자리다. 새 발행 인프라를 평가할 때 먼저 그 두 칸을 채울 수 있는지부터 확인하면 빠르다. 5칸 중 비는 자리가 정확히 그 인프라의 운영 함정 후보가 된다.

이 5질문 공식은 본 문서의 다음 섹션에 그대로 적용되어 있다 — §2.1 dual write 시나리오는 ①의 부재 사례, §3.5 effectively once는 ③+④의 결합 규칙, §8.5 schema evolution은 ⑤의 silent break 패턴, §8.6 Inbox 패턴은 ④의 구체 구현이다.


가장 흔한 사례. 한 번의 주문 생성이 여러 다운스트림 작업을 trigger한다.

PG.orders + PG.outbox (한 트랜잭션)
▼ Debezium
Kafka: domain.Order.events
├─▶ notification-service (이메일/SMS)
├─▶ inventory-service (재고 차감)
├─▶ analytics-pipeline (BigQuery)
├─▶ search-indexer (OpenSearch)
└─▶ recommendation-service (벡터 임베딩)

Outbox 없이는 “5개 다운스트림 중 일부에만 발행됨” 상태가 가능하다. Outbox로는 “발행 자체가 일어났음 → 적어도 한 번씩은 도착” 이 보장된다.

PG 변경을 Elasticsearch / OpenSearch / pgvector로 실시간 반영.

// Kafka consumer
await kafkaConsumer.subscribe({ topic: "domain.Document.events" });
for await (const msg of kafkaConsumer) {
const event = JSON.parse(msg.value);
switch (event.type) {
case "DocumentCreated":
case "DocumentUpdated":
const embedding = await embeddingClient.embed(event.payload.body);
await vectorStore.upsert({
id: event.payload.id,
vector: embedding,
metadata: { title: event.payload.title },
});
break;
case "DocumentDeleted":
await vectorStore.delete(event.payload.id);
break;
}
}

배치 reindex(밤마다 전체 다시 색인)에 비해 지연 수 초 이내 로 검색 결과가 최신화된다. LLM/IR 도메인에서 RAG 파이프라인의 표준 패턴.

4.3 사례 3: Data Lake / Warehouse stream

섹션 제목: “4.3 사례 3: Data Lake / Warehouse stream”

PG의 OLTP 변경을 ClickHouse / Snowflake / BigQuery / S3 Parquet으로 실시간 stream. 배치 ETL(pg_dump → S3 → Snowflake COPY)을 대체한다.

  • 장점: 지연 분 단위 → 초 단위, 데이터 freshness 극적 개선
  • 단점: 데이터 모델 정합성을 stream에서 유지하기 어려움 (schema evolution, type coercion)

4.4 사례 4: SAGA의 보상 이벤트 발행

섹션 제목: “4.4 사례 4: SAGA의 보상 이벤트 발행”

L9 saga-pattern에서 다루는 분산 트랜잭션에서, Outbox는 각 단계의 이벤트 발행 신뢰성을 책임진다.

[OrderService] place_order
├─ PG: orders INSERT + outbox INSERT (transaction)
└─ Kafka: order.created → [PaymentService]
[PaymentService] reserve_payment
├─ PG: payments INSERT + outbox INSERT
└─ Kafka: payment.reserved → [InventoryService]
... 실패 시 보상 트랜잭션도 outbox로 발행 ...

이 구조 없으면 “Payment은 됐는데 Inventory에 이벤트 안 갔다 → 영원히 stuck” 상태가 가능하다.

4.5 사례 5: 시간여행 디버깅 / 감사 로그

섹션 제목: “4.5 사례 5: 시간여행 디버깅 / 감사 로그”

outbox 테이블 자체를 영구 보관 하면 이벤트 스트림이 audit log 역할을 한다. 금융·의료·법률 도메인 compliance 요건을 채울 수 있다(L9 cqrs-event-sourcing의 Event Store와 사실상 같은 구조).


  • L8 transaction-basics: Outbox는 트랜잭션 원자성을 전제로 작동 한다. MVCC, isolation level, deadlock 처리가 outbox INSERT 신뢰성을 결정한다.
  • L8 db-replication-sharding: Debezium은 PG의 logical replication 인프라(pg_replication_slots, walsender) 위에서 동작한다. replication 운영 지식이 그대로 CDC 운영 지식이다.
  • L9 saga-pattern: Saga의 각 단계가 Outbox로 이벤트를 발행. 보상 이벤트도 같은 방식.
  • L9 cqrs-event-sourcing: ES의 event store가 outbox와 구조적으로 동일. read model 동기화에 CDC를 그대로 사용.
  • L6 eda-basics: EDA 인프라 핵심. EDA에서 “유실 없이 발행”을 책임지는 모듈이 정확히 outbox.
  • AWS RDS PostgreSQL + Debezium 운영: rds.logical_replication = 1, wal_sender_timeout, replication slot 모니터링.
  • MSK(Managed Kafka) + Connect: Kafka Connect 클러스터의 fault tolerance, scale-out, dead letter queue 운영.
  • 관측성(L6): replication lag, slot WAL size, Debezium connector status를 CloudWatch / Prometheus로 alarm. 슬랙 알림 + 런북 연결.

5.3 LLM / IR 도메인 접점 (윈디와의 페어 포인트)

섹션 제목: “5.3 LLM / IR 도메인 접점 (윈디와의 페어 포인트)”

이 토픽이 시니어 도약 + AI 영역과 만나는 자연스러운 다리:

PG.documents 변경 (사용자가 글 수정)
└─ outbox INSERT (DocumentUpdated event)
└─ Debezium → Kafka
└─ embedding-worker (Python)
├─ Gemini embedding API 호출
├─ pgvector / Turbopuffer에 upsert
└─ search 결과 즉시 최신화

윈디가 보고 있는 영역(임베딩, 벡터 검색, RAPTOR)에서 신선도 가 필수일 때, CDC + Outbox가 가장 표준적인 인덱스 동기화 메커니즘이다. 6개월 학습 후반부에 윈디와 페어로 이 파이프라인을 사내 도메인에 적용하면 시너지가 크다.


개념 A개념 B차이점
Outbox 패턴직접 publishOutbox는 DB 커밋과 이벤트 발행이 한 트랜잭션. 직접 publish는 dual write 문제.
CDCOutboxCDC는 기술 (DB 변경을 캡처). Outbox는 패턴 (이벤트 발행 설계). 둘은 자주 결합하지만 독립적.
CDC log-basedCDC trigger-basedlog-based(Debezium)는 WAL을 읽어 비침투적. trigger-based는 INSERT/UPDATE 트리거로 별도 테이블 기록.
CDC log-basedCDC query-basedlog-based는 모든 변경 캡처(DELETE 포함). query-based(updated_at 폴링)는 DELETE 놓침, 변경 횟수 한계
DebeziumPolling outboxDebezium은 sub-second 지연, Kafka Connect 운영 부담. polling은 단순하지만 latency 수십 초, DB 부하
2PC (XA)Outbox2PC는 strong consistency 시도, 가용성 희생. Outbox는 eventual consistency, 가용성 우선
pgoutputwal2jsonpgoutput은 PG 10+ built-in (binary). wal2json은 외부 플러그인(JSON). 신규 배포는 pgoutput.
At-least-onceExactly-onceat-least-once 보장 가능. exactly-once는 이론상 불가, “effectively once”(at-least-once + idempotent)
OutboxEvent SourcingOutbox는 발행 신뢰성 패턴. ES는 이벤트가 state 그 자체 가 되는 데이터 모델링.
OutboxInboxOutbox: producer 쪽 원자적 발행. Inbox: consumer 쪽 dedup으로 멱등성. 둘은 짝.
REPLICA IDENTITY DREPLICA IDENTITY FDEFAULT(PK 사용, WAL 작음). FULL(전체 row, WAL 비대). outbox INSERT-only는 DEFAULT면 충분.
Schema RegistryInline schemaRegistry는 schema evolution 추적·backward compatibility 강제. inline은 단순하지만 변경 관리 어려움.
MSK Connectself-managed ConnectMSK Connect는 managed (간편). self-managed는 유연(plugin, custom transform), 운영 부담.
OutboxSAGA orchestratorOutbox는 발행 신뢰성 도구. SAGA는 다단계 워크플로 + 보상 로직. SAGA가 Outbox를 도구로 사용.

6.5 결정 트리 — 정량 trade-off · 깨지는 조건 · 전환 트리거

섹션 제목: “6.5 결정 트리 — 정량 trade-off · 깨지는 조건 · 전환 트리거”

위 비교표는 정성적이다. 실무에서는 수치 임계깨지는 조건 으로 결정해야 한다.

같은 outbox 테이블이라도 relayer 가 polling이냐 CDC냐에 따라 운영 성격이 크게 달라진다.

차원Polling outboxDebezium (CDC)임계 / 전환 트리거
메시지 latency p501~10초 (poll interval)< 1초freshness 요구 1분+ 허용이면 polling 우선
메시지 latency p99poll interval + relayer1~5초동기 응답 흐름에 잠재 영향이면 CDC
DB 추가 부하 (정상)폴링 SELECT 1~5% CPUlogical decoding stream 1~3% CPUDB CPU 평소 70%+이면 CDC가 한계 → read replica로 CDC 분리
DB 추가 부하 (peak)poll burst 시 일시적initial snapshot 시 10~50% spikesnapshot 부담 크면 polling이 안전
운영 복잡도DB + relayer 1대DB + Kafka + Connect + Schema Registry 4스택팀에 Kafka Connect 경험 없으면 polling으로 시작
throughput 한계초당 N/interval 메시지수만~수십만 msg/s (Kafka 한계)초당 1000+ 발행이면 CDC
WAL retention 위험없음replication slot WAL 누적(§8.1)RDS 디스크 < 500GB OR 매니지드 안 쓰면 polling 안전
outbox cleanup발행 후 DELETE 자유DELETE도 CDC event → tombstonepartition + DROP PARTITION 우회(§8.3)

구체적 전환 트리거:

  • polling → CDC: 메시지 latency p99 > 30초 OR 발행 QPS가 (polling LIMIT × frequency)를 초과해 backlog 누적.
  • CDC → polling: replication slot WAL 사고 2회 이상 + 팀 내 운영 인력 부재. 이 조합이면 더 단순한 도구 가 정답.
  • CDC → 대안 stream 엔진 (Flink CDC / Materialize / RisingWave): outbox 이벤트를 stream join · aggregate 해야 할 때. 단순 발행이 아니라 stream 처리가 본질일 때.

Debezium snapshot.mode 결정 — 측정 기반 reference

섹션 제목: “Debezium snapshot.mode 결정 — 측정 기반 reference”

initial 모드의 초기 snapshot 부하는 row 수 × 평균 row 크기 에 비례한다. 운영 환경 reference 수치 (PG 16, db.r6g.large, 평균 outbox row 1 KB 가정):

outbox row 수snapshot 소요 시간DB primary 추가 부하권고 모드
~10만 행수십 초무시 가능initial
100만~1000만 행5~30분CPU spike 20~40%initial (트래픽 적은 시간대에 시작)
1억 행 이상수 시간CPU spike 50%+ → OLTP 영향never + 별도 backfill 또는 PG 16+ incremental snapshot
신규 도입, 과거 데이터 불필요never
connector 재기동 잦은 CI/CDwhen_needed

깨지는 조건 — 위 권고가 더 이상 유효하지 않을 때:

  • 평균 row 크기 > 200 KB (큰 JSON payload) → 위 표를 row 수만 보면 안 됨. row 수 × 크기 로 재산정.
  • replication slot이 1주 이상 consume되지 않은 상태에서 connector 재기동 → wal_status='lost' 면 그 사이 데이터 손실 가능 → snapshot.mode always 로 강제 재snapshot 또는 수동 backfill.
  • outbox row가 수억 단위면 initial snapshot이 OLTP를 마비시킨다 → incremental snapshot(PG 16+) 또는 별도 backfill 후 never 로 전환.

REPLICA IDENTITY 결정 규칙 (재인용)

섹션 제목: “REPLICA IDENTITY 결정 규칙 (재인용)”

§8.4 상세. 요약:

  • outbox 테이블처럼 INSERT-only + PK 명확 → DEFAULT (WAL 최소)
  • UPDATE/DELETE 빈번 + PK 없음 → FULL 불가피, 단 WAL 부하 알고 들어갈 것 (매 row 사이즈만큼 WAL 추가)

  • Outbox 패턴이 해결하는 dual write problem을 코드로 설명할 수 있는가?
  • PG wal_level=logical이 무엇을 활성화하는지, wal_level=replica와의 차이를 말할 수 있는가?
  • pg_replication_slotsrestart_lsn, confirmed_flush_lsn, wal_status 컬럼이 무엇을 의미하는지 설명할 수 있는가?
  • Debezium snapshot.mode 4가지(initial / never / always / when_needed)의 트레이드오프를 말할 수 있는가?
  • outbox 테이블 스키마 5요소(id, aggregate_type, aggregate_id, event_type, payload)가 왜 이런 모양인지 설명할 수 있는가?
  • Debezium Outbox Event Router SMT가 무엇을 하는지, 안 쓰면 어떤 메시지가 발행되는지 알고 있는가?
  • At-least-once 보장 하에서 consumer 측 Inbox 패턴 으로 idempotency를 어떻게 구현할지 코드로 보일 수 있는가?
  • Replication slot이 consume되지 않아 WAL이 무한 누적되는 시나리오를 재현·해소하는 런북을 그릴 수 있는가?
  • max_slot_wal_keep_size의 의미와 설정 시 트레이드오프(slot invalidate vs 디스크 폭발)를 설명할 수 있는가?
  • outbox 테이블의 누적 row 증가 를 어떻게 처리할지(daily partition + DROP PARTITION) 설계할 수 있는가?

8.1 Replication slot WAL 누적 — 가장 흔한 사고

섹션 제목: “8.1 Replication slot WAL 누적 — 가장 흔한 사고”

시나리오: Debezium connector가 죽거나(OOM, network), Kafka Connect cluster를 일주일 멈춰뒀다고 가정. 그 사이 OLTP 트래픽은 정상 흐른다.

-- 평소
SELECT slot_name, restart_lsn,
pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)) AS retained_wal
FROM pg_replication_slots;
--
-- slot_name | restart_lsn | retained_wal
-- ----------+-------------+--------------
-- debezium | 0/1A2B3C4D | 8 MB
-- Debezium 다운된 후 6시간
-- slot_name | restart_lsn | retained_wal
-- ----------+-------------+--------------
-- debezium | 0/1A2B3C4D | 240 GB ⚠️

OLTP가 활발한 DB(2050 GB/시간)는 **23시간 안에 디스크가 95%로 차오를 수 있다**.

감지 / alarm:

-- CloudWatch alarm 쿼리 예시
SELECT
slot_name,
pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn) AS bytes_retained,
active,
wal_status -- 'reserved' / 'extended' / 'unreserved' / 'lost'
FROM pg_replication_slots;

bytes_retained > 5GB 임계로 알람, wal_status = 'lost' 시 즉시 페이지.

응급 처리 런북:

  1. 진짜 죽은 slot인지 확인 (active = false 24h 이상)
  2. Debezium 복구 가능하면 복구 우선. slot drop 하면 처음부터 snapshot 필요해 운영 비용 큼.
  3. 복구 불가 시:
    SELECT pg_drop_replication_slot('debezium');
    drop 후 즉시 WAL 회수. Debezium 새 slot으로 재시작 (필요 시 snapshot.mode=when_needed).
  4. safety net 설정: max_slot_wal_keep_size = '100GB' (postgresql.conf 또는 RDS parameter group). 임계 넘으면 slot이 자동 invalidate 되고 WAL 회수. 단, slot invalidate된 connector는 데이터 손실 가능 — alarm + 운영 의식이 더 중요.

8.2 Long-running transaction이 logical decoding을 stall 시킴

섹션 제목: “8.2 Long-running transaction이 logical decoding을 stall 시킴”

PG logical decoding은 열려있는 트랜잭션을 건너뛸 수 없다. 한 세션이 트랜잭션을 열어둔 채 30분 idle 상태가 되면 그 동안의 WAL이 모두 retained 된다 (Debezium은 “정상 동작 중”인데 slot이 advance 안 됨).

-- 의심 시 조회
SELECT pid, usename, application_name, state,
(now() - xact_start) AS xact_age, query
FROM pg_stat_activity
WHERE xact_start IS NOT NULL
AND state IN ('idle in transaction', 'active')
ORDER BY xact_start;

대응:

  • idle_in_transaction_session_timeout = '15min' 설정 (PG 9.6+).
  • statement_timeout, lock_timeout 으로 응답 없는 세션을 강제 종료.
  • 애플리케이션 코드의 긴 트랜잭션 (사용자 입력 대기 등) 검토 → 트랜잭션 범위 좁히기.

발행 후 outbox row를 안 지우면 1개월 만에 수억 row가 쌓일 수 있다. 두 가지 정리 전략:

A. 발행 직후 삭제 (CDC 패턴이면 자연스러움):

Debezium이 INSERT 이벤트를 캡처하므로, DELETE 시점은 free. 하지만 다른 consumer가 outbox를 폴링하지 않는다는 전제 필요.

B. Daily partition + DROP PARTITION:

CREATE TABLE outbox (
id UUID, aggregate_type TEXT, aggregate_id TEXT,
event_type TEXT, payload JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
) PARTITION BY RANGE (created_at);
CREATE TABLE outbox_2026_05_11 PARTITION OF outbox
FOR VALUES FROM ('2026-05-11') TO ('2026-05-12');
-- ... pg_partman 자동화 ...

7일 지난 파티션은 DROP TABLE outbox_2026_05_04metadata-only 작업이라 WAL이 거의 발생하지 않는다. 일반 DELETE로 수억 row를 지우면 WAL이 폭발해 replication slot 운영을 망친다.

기본은 DEFAULT(PK 사용). 일부 가이드가 FULL(전체 row 기록)을 권하지만, outbox는 INSERT-only라 DEFAULT면 충분 하다. FULL은 WAL 크기를 row 사이즈만큼 부풀린다 — 매 INSERT마다 payload 본문이 두 번 기록되는 셈.

-- 확인
SELECT relname, relreplident
FROM pg_class
WHERE relname = 'outbox';
-- 'd' = default (PK), 'f' = full, 'n' = nothing, 'i' = index

업데이트가 빈번한 일반 테이블에서 PK가 없다면 FULL이 불가피하지만, outbox는 INSERT-only + PK 명확이라 DEFAULT가 정답.

8.5 Schema evolution — payload JSON 형식 바꿔도 안전한가?

섹션 제목: “8.5 Schema evolution — payload JSON 형식 바꿔도 안전한가?”

outbox 패턴이 처음 도입될 때 가장 자주 충돌하는 지점.

  • payload는 도메인 이벤트 스키마 다. consumer는 그 형식을 가정해서 처리.
  • 필드 추가 는 보통 backward-compatible (consumer가 unknown field 무시).
  • 필드 제거 또는 타입 변경 은 breaking change. Schema Registry로 backward / forward compatibility 를 강제하지 않으면 consumer가 NPE / parse error.
  • 운영 권고: payload에 eventVersion: 1 필드를 처음부터 두고, breaking change 시 versioned event_type(OrderCreatedV2)을 새로 발행.

8.6 Consumer 측 멱등성 부재 — at-least-once에 데이는 사고

섹션 제목: “8.6 Consumer 측 멱등성 부재 — at-least-once에 데이는 사고”

producer가 멀쩡해도 Kafka rebalance나 consumer crash 시 같은 이벤트를 두 번 받는다. consumer가 멱등이 아니면 “이메일 두 번 발송”, “주문 두 번 처리” 사고로 직결.

최소 가드: consumer 측 inbox 테이블 + UPSERT.

CREATE TABLE inbox (
event_id UUID PRIMARY KEY,
processed_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- consumer 핸들러
INSERT INTO inbox (event_id) VALUES ($1)
ON CONFLICT (event_id) DO NOTHING;
-- 1행 삽입됐을 때만 실제 처리

event_id는 outbox row의 PK(UUID)를 그대로 사용. Debezium의 id 헤더로 자동 전파됨.


깊이 파고들 때 검색할 키워드 모음:

  • Debezium 운영: Debezium Operator (Strimzi on Kubernetes), Debezium Server (Kafka-free 모드), Debezium UI
  • Schema 관리: Confluent Schema Registry, Apicurio, Avro 호환성(BACKWARD / FORWARD / FULL)
  • PG 내부: pg_logical_emit_message, replication slot heartbeat (heartbeat.interval.ms), wal2json vs pgoutput 비교
  • 대안 CDC: Netflix DBLog (커스텀 CDC), Apache Flink CDC, Materialize (streaming SQL), RisingWave, Estuary Flow
  • Outbox 대안: Listen / Notify (pg_notify + LISTEN), Hibernate Envers + JPA, Spring Modulith Event Publication
  • streaming SQL: ksqlDB, Flink SQL, Materialize — outbox 이벤트를 stream join / aggregate
  • Lakehouse 통합: Apache Iceberg + Debezium, Delta Lake CDF
  • Inbox 강화: Saga choreography vs orchestration, ProcessManager 패턴

  • docker-compose로 PG + Debezium + Kafka + UI 띄우기: debezium/example-postgres, debezium/connect, confluentinc/cp-kafka, provectuslabs/kafka-ui
  • outbox 테이블 만들고 비즈니스 트랜잭션에서 INSERT 실험: BEGIN; INSERT INTO orders; INSERT INTO outbox; COMMIT; → Debezium UI에서 메시지 확인
  • Outbox Event Router SMT 적용 전/후 비교: 같은 outbox INSERT가 어떻게 다른 Kafka 토픽/payload로 발행되는지
  • Debezium 멈춘 상태에서 OLTP 부하 → WAL retained_wal 관찰: 1시간 후 pg_size_pretty(pg_wal_lsn_diff(...)) 변화
  • max_slot_wal_keep_size를 작게 설정(예 100MB) → 의도적 slot invalidate 재현: wal_status='lost' 직접 확인
  • Long-running transaction 시뮬레이션: 한 세션에서 BEGIN; SELECT 1; 후 30분 방치 → slot이 advance 안 되는 것 확인
  • Consumer crash 후 재시작 → 같은 이벤트가 두 번 도착: Kafka offset commit timing 이슈 재현. inbox 패턴 적용 전/후 결과 비교
  • Outbox table을 daily partition으로 전환 + 7일 지난 파티션 DROP: WAL 발생량을 일반 DELETE와 비교
  • 사내 1개 도메인에 Outbox 적용: 가장 단순한 도메인(예: 사용자 회원가입)에 outbox 도입 → “토이 프로젝트”의 phase 0
  • Outbox → Embedding worker 파이프라인 구축 (윈디 페어): PG documents 변경 → Debezium → Kafka → embedding 워커 → pgvector

11. 실습 부록 — 로컬 환경에서 직접 굴려보기

섹션 제목: “11. 실습 부록 — 로컬 환경에서 직접 굴려보기”

§10 체크리스트 항목을 자가 검증 가능한 단계로 풀어 둔다. 풀 실행 환경 + 명령 + 예상 stdout 까지 묶어 한 번씩 손에 익히는 용도.

docker-compose.cdc.yml 한 파일로 PG + Zookeeper + Kafka + Kafka Connect + UI를 띄운다.

docker-compose.cdc.yml
version: "3.8"
services:
postgres:
image: debezium/example-postgres:3.0
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: shop
command:
- postgres
- -c
- wal_level=logical
- -c
- max_wal_senders=10
- -c
- max_replication_slots=10
ports: ["5432:5432"]
zookeeper:
image: confluentinc/cp-zookeeper:7.6.0
environment:
ZOOKEEPER_CLIENT_PORT: 2181
kafka:
image: confluentinc/cp-kafka:7.6.0
depends_on: [zookeeper]
ports: ["9092:9092", "29092:29092"]
environment:
KAFKA_BROKER_ID: 1
KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181
KAFKA_LISTENERS: PLAINTEXT://0.0.0.0:9092,EXTERNAL://0.0.0.0:29092
KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka:9092,EXTERNAL://localhost:29092
KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: PLAINTEXT:PLAINTEXT,EXTERNAL:PLAINTEXT
KAFKA_INTER_BROKER_LISTENER_NAME: PLAINTEXT
KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
connect:
image: debezium/connect:3.0
depends_on: [kafka, postgres]
ports: ["8083:8083"]
environment:
BOOTSTRAP_SERVERS: kafka:9092
GROUP_ID: 1
CONFIG_STORAGE_TOPIC: connect_configs
OFFSET_STORAGE_TOPIC: connect_offsets
STATUS_STORAGE_TOPIC: connect_statuses
kafka-ui:
image: provectuslabs/kafka-ui:v0.7.2
depends_on: [kafka, connect]
ports: ["8080:8080"]
environment:
KAFKA_CLUSTERS_0_NAME: local
KAFKA_CLUSTERS_0_BOOTSTRAPSERVERS: kafka:9092
KAFKA_CLUSTERS_0_KAFKACONNECT_0_NAME: connect
KAFKA_CLUSTERS_0_KAFKACONNECT_0_ADDRESS: http://connect:8083

기동:

Terminal window
docker compose -f docker-compose.cdc.yml up -d
# 모든 서비스 healthy까지 약 30~60초 소요
docker compose -f docker-compose.cdc.yml ps

예상 stdout:

NAME IMAGE STATUS
postgres debezium/example-postgres:3.0 Up (healthy)
zookeeper confluentinc/cp-zookeeper:7.6.0 Up
kafka confluentinc/cp-kafka:7.6.0 Up
connect debezium/connect:3.0 Up
kafka-ui provectuslabs/kafka-ui:v0.7.2 Up

검증 질문: 위 5개 컨테이너가 모두 Up 인가? 브라우저로 http://localhost:8080 접속 시 로컬 Kafka 클러스터가 보이는가? curl http://localhost:8083/connectors[] (빈 배열) 응답인가?

11.2 outbox 테이블 + Debezium connector 등록

섹션 제목: “11.2 outbox 테이블 + Debezium connector 등록”
Terminal window
# 11.2.1 outbox 테이블 생성
docker compose -f docker-compose.cdc.yml exec postgres \
psql -U postgres -d shop -c "
CREATE TABLE IF NOT EXISTS outbox (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
aggregate_type TEXT NOT NULL,
aggregate_id TEXT NOT NULL,
event_type TEXT NOT NULL,
payload JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
ALTER TABLE outbox REPLICA IDENTITY DEFAULT;
"
# 예상 stdout:
# CREATE TABLE
# ALTER TABLE
Terminal window
# 11.2.2 Debezium connector 등록 (Outbox Event Router 포함)
curl -sX POST -H "Content-Type: application/json" \
http://localhost:8083/connectors -d '{
"name": "shop-outbox",
"config": {
"connector.class": "io.debezium.connector.postgresql.PostgresConnector",
"database.hostname": "postgres",
"database.port": "5432",
"database.user": "postgres",
"database.password": "postgres",
"database.dbname": "shop",
"topic.prefix": "shop",
"table.include.list": "public.outbox",
"plugin.name": "pgoutput",
"snapshot.mode": "initial",
"transforms": "outbox",
"transforms.outbox.type": "io.debezium.transforms.outbox.EventRouter",
"transforms.outbox.route.by.field": "aggregate_type",
"transforms.outbox.route.topic.replacement": "domain.${routedByValue}.events"
}
}'
# 예상 stdout (한 줄, 잘라서 표시):
# {"name":"shop-outbox","config":{...},"tasks":[],"type":"source"}

상태 확인:

Terminal window
curl -s http://localhost:8083/connectors/shop-outbox/status | jq .
# 예상 stdout:
# {
# "name": "shop-outbox",
# "connector": { "state": "RUNNING", "worker_id": "..." },
# "tasks": [{ "id": 0, "state": "RUNNING", "worker_id": "..." }]
# }

state가 FAILED 면 트레이스를 본다:

Terminal window
docker compose -f docker-compose.cdc.yml logs connect | grep -E "ERROR|FAILED" | tail -20

검증 질문: connector state 가 RUNNING 인가? pg_replication_slotsshop_outbox (혹은 그 비슷한 이름) slot이 생겼는가?

Terminal window
docker compose -f docker-compose.cdc.yml exec postgres \
psql -U postgres -d shop -c "SELECT slot_name, active, wal_status FROM pg_replication_slots;"
# 예상 stdout:
# slot_name | active | wal_status
# -------------+--------+------------
# debezium | t | reserved

11.3 NestJS — import 포함 자립 코드

섹션 제목: “11.3 NestJS — import 포함 자립 코드”

src/order/order.service.ts — entity 정의 + 트랜잭션 안에서 outbox INSERT 까지 한 파일에 닫아둔 자립 실행 가능한 형태.

src/order/order.service.ts
import { Injectable } from "@nestjs/common";
import { InjectDataSource } from "@nestjs/typeorm";
import {
Column,
CreateDateColumn,
DataSource,
Entity,
PrimaryColumn,
} from "typeorm";
import { randomUUID } from "node:crypto";
@Entity("orders")
export class Order {
@PrimaryColumn("uuid") id!: string;
@Column("text") userId!: string;
@Column("numeric") totalAmount!: number;
@Column("jsonb") items!: Array<{ sku: string; qty: number }>;
}
@Entity("outbox")
export class OutboxRow {
@PrimaryColumn("uuid") id!: string;
@Column("text") aggregateType!: string;
@Column("text") aggregateId!: string;
@Column("text") eventType!: string;
@Column("jsonb") payload!: Record<string, unknown>;
@CreateDateColumn({ name: "created_at" }) createdAt!: Date;
}
export interface PlaceOrderDto {
userId: string;
items: Array<{ sku: string; qty: number }>;
totalAmount: number;
}
@Injectable()
export class OrderService {
constructor(@InjectDataSource() private readonly ds: DataSource) {}
async placeOrder(input: PlaceOrderDto): Promise<Order> {
return this.ds.transaction(async (tx) => {
const order = await tx.getRepository(Order).save({
id: randomUUID(),
userId: input.userId,
totalAmount: input.totalAmount,
items: input.items,
});
await tx.getRepository(OutboxRow).save({
id: randomUUID(),
aggregateType: "Order",
aggregateId: order.id,
eventType: "OrderCreated",
payload: {
orderId: order.id,
userId: order.userId,
amount: order.totalAmount,
items: order.items,
eventVersion: 1,
},
});
return order;
});
}
}

호출 시점 + 예상 결과 (e2e 테스트로 검증):

test/place-order.e2e-spec.ts
import { Test } from "@nestjs/testing";
import { TypeOrmModule } from "@nestjs/typeorm";
import { Order, OrderService, OutboxRow } from "../src/order/order.service";
test("placeOrder produces order + outbox row in one tx", async () => {
const moduleRef = await Test.createTestingModule({
imports: [
TypeOrmModule.forRoot({
type: "postgres",
host: "localhost",
port: 5432,
username: "postgres",
password: "postgres",
database: "shop",
entities: [Order, OutboxRow],
synchronize: false,
}),
TypeOrmModule.forFeature([Order, OutboxRow]),
],
providers: [OrderService],
}).compile();
const svc = moduleRef.get(OrderService);
const order = await svc.placeOrder({
userId: "u-1",
items: [{ sku: "SKU-1", qty: 2 }],
totalAmount: 19900,
});
expect(order.id).toBeDefined();
// outbox row 가 같은 tx 안에서 INSERT 되었는지 확인
const ds = moduleRef.get<DataSource>(DataSource);
const rows = await ds
.getRepository(OutboxRow)
.findBy({ aggregateId: order.id });
expect(rows).toHaveLength(1);
expect(rows[0].eventType).toBe("OrderCreated");
});

11.4 검증 — Kafka 메시지 도착 확인

섹션 제목: “11.4 검증 — Kafka 메시지 도착 확인”
Terminal window
docker compose -f docker-compose.cdc.yml exec kafka \
kafka-console-consumer --bootstrap-server kafka:9092 \
--topic domain.Order.events --from-beginning --max-messages 1
# 예상 stdout (json 한 줄):
# {"orderId":"<uuid>","userId":"u-1","amount":19900,"items":[{"sku":"SKU-1","qty":2}],"eventVersion":1}

검증 질문: 토픽 이름이 domain.Order.events 인가 (Outbox Event Router 의 route.topic.replacement 가 작동했는가)? 메시지 value 가 outbox row 전체가 아니라 payload 만 풀려 있는가? 브라우저 http://localhost:8080 → Topics → domain.Order.events 에 같은 메시지가 보이는가?

11.5 의도적 사고 재현 — slot WAL 누적 (§8.1 검증)

섹션 제목: “11.5 의도적 사고 재현 — slot WAL 누적 (§8.1 검증)”
Terminal window
# 11.5.1 connector 일시 중지 (Debezium 다운 시뮬레이션)
curl -sX PUT http://localhost:8083/connectors/shop-outbox/pause
# 11.5.2 OLTP 부하 — 1000건 INSERT
docker compose -f docker-compose.cdc.yml exec postgres \
psql -U postgres -d shop -c "
INSERT INTO orders (id, user_id, total_amount, items)
SELECT gen_random_uuid(), 'u-' || g, g * 100,
('[{\"sku\":\"SKU-' || g || '\",\"qty\":1}]')::jsonb
FROM generate_series(1, 1000) g;
"
# 예상 stdout:
# INSERT 0 1000
# 11.5.3 retained WAL 조회 (반복하면 증가가 보임)
docker compose -f docker-compose.cdc.yml exec postgres \
psql -U postgres -d shop -c "
SELECT slot_name,
pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)) AS retained,
wal_status
FROM pg_replication_slots;
"
# 예상 stdout (1000 행 INSERT 직후 — 수치는 환경에 따라 변동):
# slot_name | retained | wal_status
# -------------+----------+------------
# debezium | 16 MB | reserved
# 11.5.4 복구
curl -sX PUT http://localhost:8083/connectors/shop-outbox/resume
# 잠시 후 재조회 → retained 가 수 MB 이하로 회복되는지 확인

이 흐름이 §8.1 응급 처리 런북 4단계의 축소판 이다. 한 번 실제로 굴려보면 “이게 정말 무한 누적되겠구나” 가 손에 잡힌다.

변형 실험: max_slot_wal_keep_size = '100MB' 로 설정 후 위 11.5.2 를 더 큰 부하 (generate_series(1, 100000)) 로 반복 → wal_statuslost 로 전이되는 순간을 잡아본다.

Terminal window
docker compose -f docker-compose.cdc.yml down -v
# -v 는 volume 까지 제거 — 다음 실험을 깨끗한 상태에서 시작

  1. CDC는 기술, Outbox는 패턴 — DB 변경을 외부로 흘리는 기술(CDC, 예: Debezium)과 발행 신뢰성을 보장하는 설계(Outbox)가 결합해 분산 시스템 이벤트 발행의 표준이 된다.
  2. dual write problem이 본질 — “DB 쓰기 + 메시지 발행”을 별도로 하면 partial failure 필연. Outbox는 DB 트랜잭션 안에 outbox row를 함께 INSERT 해 원자성 을 확보하고, relayer가 발행을 eventual 로 책임진다.
  3. PG replication slot의 WAL 누적이 가장 큰 운영 함정 — Debezium이 죽으면 WAL이 무한 retained. max_slot_wal_keep_size safety net + pg_replication_slots 모니터링 + idle_in_transaction_session_timeout 이 3종 세트.
  4. At-least-once + Inbox 패턴 = effectively once — exactly-once는 분산 시스템 이론상 불가. producer 쪽 Outbox + consumer 쪽 inbox(event_id dedup)가 현실적 정답.
  5. 시니어 백엔드 + AI 도메인 교차점 — Saga / CQRS-ES / EDA / 검색·임베딩 인덱스 동기화의 인프라 기반. 임베딩 파이프라인(LLM/IR)에서 가장 자연스러운 신선도 메커니즘이라 윈디(LLM/IR) 영역과의 페어 후보 1순위.

참고 출처: Debezium 공식 문서(Outbox Event Router SMT, PG connector), Gunnar Morling 블로그 (Replication Slot 운영), microservices.io 표준 패턴, AWS Prescriptive Guidance (Transactional Outbox), Decodable “Revisiting the Outbox Pattern” (2026), event-driven.io (Outbox/Inbox delivery guarantees).