CDC & Outbox 패턴
분류: Layer 8 - 데이터베이스 심화
1. 한 줄 정의
섹션 제목: “1. 한 줄 정의”CDC(Change Data Capture) 는 데이터베이스의 변경 이력(INSERT/UPDATE/DELETE)을 WAL/binlog 같은 트랜잭션 로그에서 읽어 외부 시스템으로 전파하는 기술이고, Outbox 패턴 은 비즈니스 데이터 저장과 이벤트 발행을 한 DB 트랜잭션 안에서 원자적으로 묶기 위해 outbox 테이블에 이벤트를 함께 기록하고 별도 relayer가 발행을 책임지도록 분리하는 설계 패턴이다. 둘은 자주 결합되어 “Outbox table → Debezium(CDC) → Kafka” 가 분산 시스템의 신뢰성 있는 이벤트 발행 표준이 된다.
2. 왜 중요한가
섹션 제목: “2. 왜 중요한가”2.1 dual write problem — Outbox가 푸는 핵심 문제
섹션 제목: “2.1 dual write problem — Outbox가 푸는 핵심 문제”분산 시스템에서 “DB 변경 + 메시지 발행”을 별도 작업으로 처리하면 partial failure가 필연이다.
// 안티패턴 — 두 시스템에 따로 쓰는 dual writeasync 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 consistency 와 at-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하고 보정으로 채운다 는 발상은 같다.
3. 핵심 개념
섹션 제목: “3. 핵심 개념”3.1 PostgreSQL Logical Decoding
섹션 제목: “3.1 PostgreSQL Logical Decoding”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씩 점유.
3.2 Debezium 동작 구조
섹션 제목: “3.2 Debezium 동작 구조”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) │ └─────────────┘라이프사이클:
- Initial Snapshot: 첫 기동 시 기존 테이블 row를 모두 읽어 Kafka로 발행. 부하 큰 단계.
- Streaming: 이후엔 WAL을 따라가며 변경 발생 즉시 발행.
- Tombstone: DELETE 이벤트 발행 후 같은 키로 null 메시지를 한 번 더 보내 Kafka log compaction이 작동하게 한다.
snapshot.mode 4가지:
| 모드 | 동작 | 사용 시점 |
|---|---|---|
initial | 최초 1회 snapshot → streaming (기본) | 대부분의 신규 connector |
never | snapshot 건너뛰고 현재 WAL부터 streaming | 별도로 backfill 했거나 과거 데이터 불필요 |
always | 매 재시작마다 snapshot | 거의 사용 안 함. 대용량 테이블에 위험 |
when_needed | offset 없거나 invalid 시에만 snapshot | slot 재생성 후 자동 복구가 필요한 운영 환경 |
initial_only | snapshot 후 종료 (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가 감지해 자동 발행.
3.4 Debezium Outbox Event Router (SMT)
섹션 제목: “3.4 Debezium Outbox Event Router (SMT)”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=outboxtransforms.outbox.type=io.debezium.transforms.outbox.EventRoutertransforms.outbox.table.field.event.id=idtransforms.outbox.table.field.event.key=aggregate_idtransforms.outbox.table.field.event.payload=payloadtransforms.outbox.route.by.field=aggregate_typetransforms.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 시 메시지 유실 가능성 |
| 3 | delivery guarantee는? (at-most-once / at-least-once / effectively-once) | exactly-once 약속하면 거의 거짓말(§3.5) |
| 4 | consumer 측 멱등성은 어떻게 보장하는가? (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 tx | Debezium relayer | at-least-once | Inbox 패턴(§8.6) | DLQ + outbox audit |
| AWS EventBridge Pipes (DDB Streams → Bridge) | DDB single-table tx | Pipes (managed) | at-least-once | idempotent target | DLQ to SQS |
pg_notify + LISTEN | DB tx | consumer-driven | at-most-once (subscriber 없으면 유실) | N/A | 없음 |
①과 ⑤가 가장 자주 비는 자리다. 새 발행 인프라를 평가할 때 먼저 그 두 칸을 채울 수 있는지부터 확인하면 빠르다. 5칸 중 비는 자리가 정확히 그 인프라의 운영 함정 후보가 된다.
이 5질문 공식은 본 문서의 다음 섹션에 그대로 적용되어 있다 — §2.1 dual write 시나리오는 ①의 부재 사례, §3.5 effectively once는 ③+④의 결합 규칙, §8.5 schema evolution은 ⑤의 silent break 패턴, §8.6 Inbox 패턴은 ④의 구체 구현이다.
4. 실무에서 어디에 쓰이나
섹션 제목: “4. 실무에서 어디에 쓰이나”4.1 사례 1: 주문 fan-out
섹션 제목: “4.1 사례 1: 주문 fan-out”가장 흔한 사례. 한 번의 주문 생성이 여러 다운스트림 작업을 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로는 “발행 자체가 일어났음 → 적어도 한 번씩은 도착” 이 보장된다.
4.2 사례 2: 검색 인덱스 동기화
섹션 제목: “4.2 사례 2: 검색 인덱스 동기화”PG 변경을 Elasticsearch / OpenSearch / pgvector로 실시간 반영.
// Kafka consumerawait 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와 사실상 같은 구조).
5. 현재 내 업무와 연결점
섹션 제목: “5. 현재 내 업무와 연결점”5.1 cs-study 레이어 연계
섹션 제목: “5.1 cs-study 레이어 연계”- 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.
5.2 플랫폼 엔지니어 관점
섹션 제목: “5.2 플랫폼 엔지니어 관점”- 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개월 학습 후반부에 윈디와 페어로 이 파이프라인을 사내 도메인에 적용하면 시너지가 크다.
6. 자주 헷갈리는 개념 비교
섹션 제목: “6. 자주 헷갈리는 개념 비교”| 개념 A | 개념 B | 차이점 |
|---|---|---|
| Outbox 패턴 | 직접 publish | Outbox는 DB 커밋과 이벤트 발행이 한 트랜잭션. 직접 publish는 dual write 문제. |
| CDC | Outbox | CDC는 기술 (DB 변경을 캡처). Outbox는 패턴 (이벤트 발행 설계). 둘은 자주 결합하지만 독립적. |
| CDC log-based | CDC trigger-based | log-based(Debezium)는 WAL을 읽어 비침투적. trigger-based는 INSERT/UPDATE 트리거로 별도 테이블 기록. |
| CDC log-based | CDC query-based | log-based는 모든 변경 캡처(DELETE 포함). query-based(updated_at 폴링)는 DELETE 놓침, 변경 횟수 한계 |
| Debezium | Polling outbox | Debezium은 sub-second 지연, Kafka Connect 운영 부담. polling은 단순하지만 latency 수십 초, DB 부하 |
| 2PC (XA) | Outbox | 2PC는 strong consistency 시도, 가용성 희생. Outbox는 eventual consistency, 가용성 우선 |
| pgoutput | wal2json | pgoutput은 PG 10+ built-in (binary). wal2json은 외부 플러그인(JSON). 신규 배포는 pgoutput. |
| At-least-once | Exactly-once | at-least-once 보장 가능. exactly-once는 이론상 불가, “effectively once”(at-least-once + idempotent) |
| Outbox | Event Sourcing | Outbox는 발행 신뢰성 패턴. ES는 이벤트가 state 그 자체 가 되는 데이터 모델링. |
| Outbox | Inbox | Outbox: producer 쪽 원자적 발행. Inbox: consumer 쪽 dedup으로 멱등성. 둘은 짝. |
| REPLICA IDENTITY D | REPLICA IDENTITY F | DEFAULT(PK 사용, WAL 작음). FULL(전체 row, WAL 비대). outbox INSERT-only는 DEFAULT면 충분. |
| Schema Registry | Inline schema | Registry는 schema evolution 추적·backward compatibility 강제. inline은 단순하지만 변경 관리 어려움. |
| MSK Connect | self-managed Connect | MSK Connect는 managed (간편). self-managed는 유연(plugin, custom transform), 운영 부담. |
| Outbox | SAGA orchestrator | Outbox는 발행 신뢰성 도구. SAGA는 다단계 워크플로 + 보상 로직. SAGA가 Outbox를 도구로 사용. |
6.5 결정 트리 — 정량 trade-off · 깨지는 조건 · 전환 트리거
섹션 제목: “6.5 결정 트리 — 정량 trade-off · 깨지는 조건 · 전환 트리거”위 비교표는 정성적이다. 실무에서는 수치 임계 와 깨지는 조건 으로 결정해야 한다.
Polling outbox vs Debezium (CDC)
섹션 제목: “Polling outbox vs Debezium (CDC)”같은 outbox 테이블이라도 relayer 가 polling이냐 CDC냐에 따라 운영 성격이 크게 달라진다.
| 차원 | Polling outbox | Debezium (CDC) | 임계 / 전환 트리거 |
|---|---|---|---|
| 메시지 latency p50 | 1~10초 (poll interval) | < 1초 | freshness 요구 1분+ 허용이면 polling 우선 |
| 메시지 latency p99 | poll interval + relayer | 1~5초 | 동기 응답 흐름에 잠재 영향이면 CDC |
| DB 추가 부하 (정상) | 폴링 SELECT 1~5% CPU | logical decoding stream 1~3% CPU | DB CPU 평소 70%+이면 CDC가 한계 → read replica로 CDC 분리 |
| DB 추가 부하 (peak) | poll burst 시 일시적 | initial snapshot 시 10~50% spike | snapshot 부담 크면 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 → tombstone | partition + 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/CD | — | — | when_needed |
깨지는 조건 — 위 권고가 더 이상 유효하지 않을 때:
- 평균 row 크기 > 200 KB (큰 JSON payload) → 위 표를 row 수만 보면 안 됨. row 수 × 크기 로 재산정.
- replication slot이 1주 이상 consume되지 않은 상태에서 connector 재기동 →
wal_status='lost'면 그 사이 데이터 손실 가능 → snapshot.modealways로 강제 재snapshot 또는 수동 backfill. - outbox row가 수억 단위면
initialsnapshot이 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 추가)
7. 체크리스트
섹션 제목: “7. 체크리스트”- Outbox 패턴이 해결하는 dual write problem을 코드로 설명할 수 있는가?
- PG
wal_level=logical이 무엇을 활성화하는지,wal_level=replica와의 차이를 말할 수 있는가? -
pg_replication_slots의restart_lsn,confirmed_flush_lsn,wal_status컬럼이 무엇을 의미하는지 설명할 수 있는가? - Debezium
snapshot.mode4가지(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. 운영 함정과 런북
섹션 제목: “8. 운영 함정과 런북”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_walFROM 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' 시 즉시 페이지.
응급 처리 런북:
- 진짜 죽은 slot인지 확인 (
active = false24h 이상) - Debezium 복구 가능하면 복구 우선. slot drop 하면 처음부터 snapshot 필요해 운영 비용 큼.
- 복구 불가 시:
drop 후 즉시 WAL 회수. Debezium 새 slot으로 재시작 (필요 시 snapshot.mode=when_needed).SELECT pg_drop_replication_slot('debezium');
- 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, queryFROM pg_stat_activityWHERE 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으로 응답 없는 세션을 강제 종료.- 애플리케이션 코드의 긴 트랜잭션 (사용자 입력 대기 등) 검토 → 트랜잭션 범위 좁히기.
8.3 Outbox 테이블 비대화
섹션 제목: “8.3 Outbox 테이블 비대화”발행 후 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_04 — metadata-only 작업이라 WAL이 거의 발생하지 않는다. 일반 DELETE로 수억 row를 지우면 WAL이 폭발해 replication slot 운영을 망친다.
8.4 REPLICA IDENTITY 잘못 설정
섹션 제목: “8.4 REPLICA IDENTITY 잘못 설정”기본은 DEFAULT(PK 사용). 일부 가이드가 FULL(전체 row 기록)을 권하지만, outbox는 INSERT-only라 DEFAULT면 충분 하다. FULL은 WAL 크기를 row 사이즈만큼 부풀린다 — 매 INSERT마다 payload 본문이 두 번 기록되는 셈.
-- 확인SELECT relname, relreplidentFROM pg_classWHERE 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 헤더로 자동 전파됨.
9. 추가 학습 키워드
섹션 제목: “9. 추가 학습 키워드”깊이 파고들 때 검색할 키워드 모음:
- 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),wal2jsonvspgoutput비교 - 대안 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 패턴
10. 내가 직접 확인해볼 것
섹션 제목: “10. 내가 직접 확인해볼 것”- 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 까지 묶어 한 번씩 손에 익히는 용도.
11.1 docker-compose 풀 파일
섹션 제목: “11.1 docker-compose 풀 파일”docker-compose.cdc.yml 한 파일로 PG + Zookeeper + Kafka + Kafka Connect + UI를 띄운다.
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기동:
docker compose -f docker-compose.cdc.yml up -d# 모든 서비스 healthy까지 약 30~60초 소요docker compose -f docker-compose.cdc.yml ps예상 stdout:
NAME IMAGE STATUSpostgres debezium/example-postgres:3.0 Up (healthy)zookeeper confluentinc/cp-zookeeper:7.6.0 Upkafka confluentinc/cp-kafka:7.6.0 Upconnect debezium/connect:3.0 Upkafka-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 등록”# 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# 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"}상태 확인:
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 면 트레이스를 본다:
docker compose -f docker-compose.cdc.yml logs connect | grep -E "ERROR|FAILED" | tail -20검증 질문: connector state 가 RUNNING 인가? pg_replication_slots 에 shop_outbox (혹은 그 비슷한 이름) slot이 생겼는가?
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 | reserved11.3 NestJS — import 포함 자립 코드
섹션 제목: “11.3 NestJS — import 포함 자립 코드”src/order/order.service.ts — entity 정의 + 트랜잭션 안에서 outbox INSERT 까지 한 파일에 닫아둔 자립 실행 가능한 형태.
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 테스트로 검증):
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 메시지 도착 확인”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 검증)”# 11.5.1 connector 일시 중지 (Debezium 다운 시뮬레이션)curl -sX PUT http://localhost:8083/connectors/shop-outbox/pause
# 11.5.2 OLTP 부하 — 1000건 INSERTdocker 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_status가lost로 전이되는 순간을 잡아본다.
11.6 정리
섹션 제목: “11.6 정리”docker compose -f docker-compose.cdc.yml down -v# -v 는 volume 까지 제거 — 다음 실험을 깨끗한 상태에서 시작12. 5줄 요약
섹션 제목: “12. 5줄 요약”- CDC는 기술, Outbox는 패턴 — DB 변경을 외부로 흘리는 기술(CDC, 예: Debezium)과 발행 신뢰성을 보장하는 설계(Outbox)가 결합해 분산 시스템 이벤트 발행의 표준이 된다.
- dual write problem이 본질 — “DB 쓰기 + 메시지 발행”을 별도로 하면 partial failure 필연. Outbox는 DB 트랜잭션 안에 outbox row를 함께 INSERT 해 원자성 을 확보하고, relayer가 발행을 eventual 로 책임진다.
- PG replication slot의 WAL 누적이 가장 큰 운영 함정 — Debezium이 죽으면 WAL이 무한 retained.
max_slot_wal_keep_sizesafety net +pg_replication_slots모니터링 +idle_in_transaction_session_timeout이 3종 세트. - At-least-once + Inbox 패턴 = effectively once — exactly-once는 분산 시스템 이론상 불가. producer 쪽 Outbox + consumer 쪽 inbox(
event_iddedup)가 현실적 정답. - 시니어 백엔드 + 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).