CDC와 Outbox로 이벤트 발행을 지키는 법
CDC와 Outbox는 DB 변경과 메시지 발행 사이의 partial failure를 줄이기 위해, 트랜잭션 원자성과 비동기 발행을 결합하는 패턴이다. 이 스크립트는 PostgreSQL Logical Decoding, Debezium, Outbox Event Router, at-least-once 운영 현실과 주요 장애 런북을 연결해 설명한다.
Script Companion
오디오와 함께 스크립트 보기
- 01
CDC와 Outbox를 이해할 때 출발점은 dual write problem입니다. 분산 시스템에서 DB 변경과 메시지 발행을 별도 작업으로 처리하면, 둘 중 하나만 성공하는 partial failure가 필연적으로 생깁니다. DB에는 주문이 있는데 메시지가 없어 알림, 정산, 검색 같은 다운스트림이 주문을 모를 수 있고, 반대로 메시지는 발행됐는데 DB 트랜잭션이 rollback되면 존재하지 않는 주문을 처리하려는 오류가 생깁니다. 앱이 DB 쓰기 직후 크래시되는 경우도 같은 문제로 이어집니다.
- 02
이 문제에 대해 2PC나 XA를 떠올릴 수 있지만, 문서의 전제에서는 답이 아닙니다. PG와 Kafka 환경에서 Kafka는 XA를 지원하지 않고, 가능하더라도 가용성과 throughput을 크게 깎습니다. 그래서 분산 트랜잭션을 직접 붙잡기보다, DB 트랜잭션 안에 발행할 이벤트 기록을 함께 남기고, 실제 외부 발행은 나중에 relayer가 책임지는 방식이 필요합니다. 이것이 Outbox이며, eventual consistency와 at-least-once를 인정하고 운영 가능한 형태로 만드는 패턴입니다.
- 03
이 패턴이 중요한 이유는 적용 범위가 넓기 때문입니다. 한 서비스의 DB 변경을 다른 서비스, Kafka, EventBridge, SQS로 전파하는 마이크로서비스 데이터 동기화에 쓰이고, PG의 documents 테이블 변경을 embedding 워커로 보내 pgvector, OpenSearch, Elasticsearch에 반영하는 검색과 임베딩 인덱스 동기화에도 쓰입니다. PG 변경을 Snowflake, BigQuery, ClickHouse로 흘리는 데이터 웨어하우스 stream, 금융·의료·법률 도메인의 audit log compliance, 그리고 L9의 SAGA와 CQRS, Event Sourcing 기반에서도 같은 구조가 반복됩니다.
- 04
프론트엔드 경험과 연결하면 감각이 조금 더 선명해집니다. 낙관적 업데이트와 서버 reconciliation은 UI를 먼저 바꾸고 백그라운드에서 서버와 맞춘 뒤, 실패하면 보상 처리합니다. Outbox도 DB에 일단 쓰고, 백그라운드 relayer가 외부 시스템과 맞춘 뒤, 실패하면 retry로 보정합니다. 동작 위치는 다르지만 핵심 발상은 같습니다. 즉시 완벽한 동기화를 약속하기보다 eventual consistency를 받아들이고, 유실 없이 따라잡을 수 있는 흔적을 남기는 것입니다.
- 05
CDC 쪽 기술 기반은 PostgreSQL Logical Decoding입니다. WAL, 즉 Write-Ahead Log는 PostgreSQL 트랜잭션의 1차 기록이며, 디스크 데이터 파일을 직접 수정하기 전에 변경 사항을 WAL에 먼저 쓰고 fsync합니다. 이 기록이 크래시 복구의 근거이자 CDC의 원천입니다. Logical Replication은 WAL을 row 단위 SQL-level 변경으로 디코딩하며, raw bytes를 흘리는 physical replication과 다릅니다. 여기서 Replication Slot은 consumer가 어디까지 읽었는지 restart_lsn과 confirmed_flush_lsn으로 추적합니다.
- 06
Replication Slot은 안전성과 운영 함정을 동시에 만듭니다. PostgreSQL은 slot이 추적하는 위치 이후의 WAL을 지우지 않기 때문에, consumer가 늦어도 재개할 수 있습니다. 하지만 consumer가 죽은 채 오래 방치되면 WAL이 계속 쌓입니다. pgoutput 플러그인은 PG 10 이상에서 제공되는 표준 logical decoding output plugin이고, 이전의 wal2json이나 decoderbufs 같은 외부 플러그인 의존을 줄여줍니다. 운영에서는 wal_level = logical 설정이 필요하며, 변경 후 재시작해야 합니다. max_wal_senders와 max_replication_slots는 walsender 프로세스 수와 동시 slot 수 한도를 정하고, RDS 기본값은 10과 10입니다.
- 07
Debezium은 Kafka Connect 위에서 동작하는 source connector이며 PostgreSQL 외에도 MySQL, MongoDB, Cassandra, Oracle, SQL Server 등을 지원합니다. 첫 기동 때는 Initial Snapshot으로 기존 테이블 row를 모두 읽어 Kafka로 발행하고, 이후에는 Streaming 단계에서 WAL을 따라가며 변경을 거의 즉시 발행합니다. DELETE 이벤트 뒤에는 같은 키로 null 메시지를 한 번 더 보내는 Tombstone도 있으며, 이는 Kafka log compaction이 작동하게 하는 신호입니다. snapshot.mode는 initial, never, always, when_needed, initial_only처럼 운영 상황에 따라 다르게 선택합니다.
- 08
snapshot.mode 선택은 생각보다 큰 운영 판단입니다. initial은 최초 1회 snapshot 뒤 streaming으로 가는 기본값이고, never는 과거 데이터를 별도로 backfill했거나 필요 없을 때 현재 WAL부터 읽습니다. always는 재시작마다 snapshot을 수행하므로 대용량 테이블에는 위험하고, when_needed는 offset이 없거나 invalid일 때 snapshot으로 복구할 수 있어 slot 재생성 이후 운영 환경에 맞습니다. initial_only는 snapshot 후 종료하는 일회성 backfill 용도입니다. 메시지 포맷에서는 Avro, JSON, Protobuf를 고를 수 있고, Schema Registry를 쓰지 않으면 메시지 크기와 backward compatibility 추적이 부담이 됩니다.
- 09
Outbox 패턴의 핵심 흐름은 비즈니스 트랜잭션 안에서 outbox 테이블에 함께 INSERT하는 것입니다. 주문을 만들었다면 주문 row와 함께 발행해야 할 도메인 이벤트 row도 같은 DB 트랜잭션에 들어갑니다. 이후 별도 프로세스가 outbox를 읽어 Kafka로 발행하는데, 이 프로세스가 relayer입니다. 구현은 크게 polling relayer와 CDC relayer로 나뉩니다. polling 방식은 처리되지 않은 row를 잠금과 함께 가져가며, CDC 방식은 Debezium이 outbox 테이블의 INSERT 자체를 감지해 발행합니다.
- 10
Debezium Outbox Event Router는 CDC 방식에서 다운스트림이 받는 메시지 모양을 정리해 줍니다. outbox 테이블의 INSERT 이벤트를 그대로 보내면 다운스트림은 id, aggregate_type, aggregate_id, event_type, payload 같은 outbox row 자체를 보게 됩니다. 하지만 다운스트림이 진짜 원하는 것은 payload 안의 도메인 이벤트입니다. Outbox Event Router라는 SMT, 즉 Single Message Transform을 쓰면 aggregate_type이 Order일 때 domain.Order.events 같은 토픽으로 라우팅하고, aggregate_id를 메시지 key로 삼아 Kafka partition 분배와 같은 aggregate 이벤트 순서 보존에 활용합니다. value는 payload 그 자체가 되고, outbox row의 메타데이터는 헤더로 이동합니다.
- 11
Delivery Semantics에서 현실적인 기준은 at-least-once입니다. Debezium이 재시작하거나 Kafka 쪽 commit이 타이밍 문제로 누락되면 같은 이벤트가 다시 발행될 수 있습니다. 문서는 exactly-once를 이론상 불가능한 약속으로 다룹니다. 메시지가 발행됐는지에 대한 ack가 사라지면 보낸 쪽은 재시도할 수밖에 없기 때문입니다. 실무 답은 effectively once, 다시 말해 at-least-once와 idempotent consumer의 결합입니다. consumer 측에서 event_id 같은 dedup 키로 멱등성을 강제해야 하고, 이를 Inbox Pattern이라고 부릅니다. Outbox가 producer 쪽 책임이라면 Inbox는 consumer 쪽 책임입니다.
- 12
새로운 발행 인프라를 볼 때는 다섯 가지 질문으로 평가할 수 있습니다. 원자성은 어디서 보장되는지, 발행 책임은 producer 내장인지 별도 relayer인지 CDC인지, delivery guarantee는 at-most-once인지 at-least-once인지 effectively-once인지, consumer 측 멱등성은 event id dedup이나 UPSERT로 보장되는지, 실패 시 retry queue, DLQ, compensating event, audit log 같은 흔적이 남는지 확인합니다. 이 공식은 Kafka Streams Outbox, NATS JetStream, Pulsar Functions, Iceberg CDF, ksqlDB Materialized View, EventBridge Pipes, Listen/Notify 같은 인프라를 볼 때도 재사용할 수 있습니다. 특히 원자성과 실패 흔적이 비는 자리가 운영 함정 후보입니다.
- 13
실무 사례를 보면 패턴의 역할이 더 구체적입니다. 주문 fan-out에서는 한 번의 주문 생성이 여러 다운스트림 작업을 trigger하므로, Outbox가 없으면 일부 시스템에만 발행된 상태가 가능합니다. 검색 인덱스 동기화에서는 PG 변경을 Elasticsearch, OpenSearch, pgvector에 반영해 배치 reindex보다 짧은 지연으로 최신 검색 결과를 만듭니다. Data Lake와 Warehouse stream에서는 PG의 OLTP 변경을 ClickHouse, Snowflake, BigQuery, S3 Parquet으로 흘려 배치 ETL을 대체합니다. 다만 schema evolution과 type coercion 때문에 stream 안에서 데이터 모델 정합성을 유지하기 어렵다는 단점도 함께 봐야 합니다.
- 14
운영에서 가장 흔한 사고는 Replication slot WAL 누적입니다. Debezium connector가 OOM이나 network 문제로 죽거나 Kafka Connect cluster를 일주일 멈춰둔 동안에도 OLTP 트래픽은 계속 흐릅니다. OLTP가 활발한 DB에서 20에서 50 GB/시간 수준으로 WAL이 늘면 2에서 3시간 안에 디스크가 95%까지 찰 수 있습니다. 감지는 bytes_retained > 5GB 임계 알람과 wal_status = lost 즉시 페이지로 잡습니다. 응급 처리에서는 먼저 active = false가 24h 이상인 진짜 죽은 slot인지 확인하고, 가능하면 Debezium 복구를 우선합니다. slot drop은 처음부터 snapshot이 필요할 수 있어 운영 비용이 큽니다.
- 15
두 번째 함정은 long-running transaction입니다. PostgreSQL logical decoding은 열려있는 트랜잭션을 건너뛸 수 없습니다. 어떤 세션이 트랜잭션을 열어둔 채 30분 idle 상태가 되면, Debezium이 정상 동작 중이어도 slot이 advance하지 않고 그동안의 WAL이 retained 됩니다. 대응은 idle_in_transaction_session_timeout = 15min 설정, statement_timeout과 lock_timeout으로 응답 없는 세션을 강제 종료하는 방식입니다. 애플리케이션 코드에서는 사용자 입력 대기처럼 긴 트랜잭션을 만드는 흐름을 검토하고, 트랜잭션 범위를 좁혀야 합니다.
- 16
Outbox 테이블 자체도 관리 대상입니다. 발행 후 row를 정리하지 않으면 1개월 만에 수억 row가 쌓일 수 있습니다. CDC 패턴에서는 Debezium이 INSERT 이벤트를 캡처하므로 발행 직후 삭제가 자연스럽지만, 다른 consumer가 outbox를 폴링하지 않는다는 전제가 필요합니다. 다른 전략은 Daily partition과 DROP PARTITION입니다. 7일 지난 파티션을 DROP TABLE로 제거하면 metadata-only 작업이라 WAL이 거의 발생하지 않습니다. 반대로 일반 DELETE로 수억 row를 지우면 WAL이 폭발해 replication slot 운영을 망칠 수 있습니다. REPLICA IDENTITY도 outbox는 INSERT-only와 PK가 명확하므로 DEFAULT면 충분하고, FULL은 WAL 크기를 불필요하게 키웁니다.
같은 레이어
L8에서 이어 듣기
- 오디오 파일
- /podcasts/l8-cdc-outbox.mp3