DB 성공 후 발행 실패
주문은 DB에 있지만 `order.created`가 없다.
알림, 정산, 검색 색인 같은 downstream이 주문을 모른다.분류: Layer 8 - 데이터베이스 심화
이 문서는 L8 후반부 문서다. transaction-basics.mdx에서 커밋 경계와 WAL의 감각을 잡고, db-replication-sharding.mdx에서 원본 DB와 복사본 사이의 지연을 읽은 뒤 들어오는 편이 좋다. L6의 eda-basics.mdx를 읽었다면 broker, consumer, at-least-once 같은 단어도 훨씬 덜 낯설다. 2PC, XA, replication slot, eventual consistency는 사전 암기 대상이 아니라 본문에서 필요한 만큼 풀어 설명한다.
CDC(Change Data Capture, 변경 데이터 캡처) 는 데이터베이스에서 발생한 INSERT, UPDATE, DELETE 같은 변경을 읽어 외부 시스템으로 흘려보내는 기술이다. PostgreSQL에서는 보통 WAL(Write-Ahead Log, 선행 기록 로그) 을 읽는다. WAL은 데이터 파일을 바꾸기 전에 변경 내용을 먼저 남기는 DB의 복구 로그이며, 커밋된 변경을 시간순으로 따라갈 수 있는 원천이다.
Outbox 패턴은 비즈니스 테이블 변경과 이벤트 발행 의도를 같은 DB 트랜잭션 안에 기록하는 설계다. 애플리케이션은 Kafka 같은 외부 브로커에 직접 발행하지 않고, outbox 테이블에 “이 이벤트를 발행해야 한다”는 사실을 함께 INSERT한다. 이후 relayer가 outbox 기록을 읽어 브로커로 발행한다. relayer는 발행 책임을 애플리케이션 요청 경로 밖으로 분리한 별도 프로세스나 CDC 커넥터를 뜻한다.
이 둘을 결합하면 흐름은 보통 이렇게 된다.
비즈니스 트랜잭션 ├─ orders INSERT └─ outbox INSERT ← 발행 의도를 DB 커밋 안에 기록 │ ▼CDC relayer(Debezium 등) │ ▼Kafka / EventBridge / SQS │ ▼Consumer이 구조의 목표는 “메시지를 정확히 한 번 보낸다”가 아니다. 더 현실적인 목표는 발행 의도는 DB 트랜잭션으로 잃지 않고, 발행은 적어도 한 번 수행하며, 소비자는 idempotency(멱등성)로 중복 처리를 막는다는 것이다. 멱등성은 같은 이벤트를 여러 번 받아도 최종 효과가 한 번 처리한 것과 같도록 만드는 성질이다.
문제의 출발점은 dual write problem이다. 하나의 업무 사건이 두 저장소에 동시에 기록되어야 하는데, 두 저장소가 하나의 트랜잭션으로 묶이지 않는 상황을 말한다. 주문 생성 예시는 단순하다.
async function placeOrder(input: PlaceOrderDto) { const order = await db.order.insert(input); // DB write await kafka.publish("order.created", order); // broker write return order;}이 코드는 읽기 좋지만 신뢰성 관점에서는 깨지기 쉽다. DB와 Kafka는 서로 다른 시스템이고, 둘 사이에는 하나의 원자적 커밋 경계가 없다.
주문은 DB에 있지만 `order.created`가 없다.
알림, 정산, 검색 색인 같은 downstream이 주문을 모른다.커밋은 끝났지만 발행 코드까지 도달하지 못한다.
재시도할 단서가 애플리케이션 메모리나 로그에만 남으면 유실된다.이벤트는 나갔지만 실제 주문은 존재하지 않는다.
consumer가 존재하지 않는 주문을 처리하려고 한다.네트워크 타임아웃 때문에 broker가 받았는지 모른다.
재시도하면 중복 가능성이 생기고, 포기하면 유실 가능성이 생긴다.분산 트랜잭션인 2PC(Two-Phase Commit)나 XA로 DB와 브로커를 강하게 묶고 싶어질 수 있다. 하지만 Kafka 같은 브로커는 전통적인 XA 트랜잭션에 맞지 않고, 설령 가능한 조합이어도 가용성, 처리량, 운영 복잡도를 크게 희생한다. Outbox는 “모든 시스템을 하나의 강한 트랜잭션으로 묶자”가 아니라, DB 트랜잭션을 발행 의도의 source of truth로 삼고 외부 발행은 eventual하게 따라오게 하자는 선택이다.
Outbox가 보장하려는 것은 메시지 브로커까지의 즉시 성공이 아니다. 더 좁고 강한 보장을 잡는다.
핵심은 “발행을 트랜잭션 안에서 끝낸다”가 아니라, 발행해야 한다는 의사결정을 트랜잭션 안에 남긴다는 점이다. 그래서 Outbox 테이블은 단순한 임시 큐가 아니라, 커밋된 비즈니스 사건의 발행 원장에 가깝다.
가장 작은 outbox 스키마는 다음 모양이다.
CREATE TABLE 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());
CREATE INDEX outbox_aggregate_created_idx ON outbox (aggregate_type, aggregate_id, created_at);각 컬럼은 발행 의미를 분리한다.
| 컬럼 | 의미 |
|---|---|
id | 이벤트의 고유 식별자. 중복 제거 키가 된다. |
aggregate_type | 이벤트가 속한 도메인 객체 종류. 예: Order. |
aggregate_id | 같은 도메인 객체 안의 순서 보존 기준. |
event_type | 발생한 사건의 이름. 예: OrderCreated. |
payload | consumer가 실제로 처리할 도메인 이벤트 본문. |
created_at | 디버깅, 감사, 정리 정책에 필요한 생성 시각. |
비즈니스 작업은 다음처럼 하나의 DB 트랜잭션으로 닫힌다.
async function placeOrder(input: PlaceOrderDto): Promise<Order> { return dataSource.transaction(async (tx) => { const order = await tx.getRepository(Order).save({ userId: input.userId, totalAmount: input.totalAmount, items: input.items, }); const eventId = crypto.randomUUID(); const eventType = "OrderCreated";
await tx.getRepository(OutboxRow).save({ id: eventId, aggregateType: "Order", aggregateId: order.id, eventType, payload: { eventId, eventType, orderId: order.id, userId: order.userId, amount: order.totalAmount, eventVersion: 1, }, });
return order; });}여기서 중요한 것은 Kafka 코드가 없다는 점이다. 요청 처리 경로는 DB 커밋까지만 책임진다. orders와 outbox는 함께 커밋되거나 함께 롤백된다. 외부 발행은 relayer가 나중에 처리한다.
이 문서에서는 outbox.id와 payload.eventId를 같은 UUID로 두고, 이를 consumer의 canonical idempotency key로 삼는다. row id, Kafka message id, consumer inbox key가 서로 다르면 중복 처리 기준이 흔들리기 쉽다. 실제 구현에서는 하나의 event id를 생성한 뒤 outbox primary key와 payload에 함께 넣거나, Event Router 설정으로 outbox id를 consumer가 읽는 위치에 전달한다.
작은 추적 예시로 보면 관계가 더 선명하다.
| 단계 | 값 | 왜 중요한가 |
|---|---|---|
| 주문 트랜잭션 | orders.id = ord-1 | 비즈니스 사실의 기준점이다. |
| outbox row | outbox.id = evt-7, aggregate_id=ord-1 | 발행 의도와 중복 제거 기준을 함께 남긴다. |
| Kafka message | key = ord-1, value.eventId = evt-7 | 같은 주문의 순서와 event id 전달을 유지한다. |
| consumer inbox | inbox.event_id = evt-7 | 재전달되어도 같은 효과를 두 번 내지 않게 한다. |
여기서 ord-1은 “무엇에 대한 사건인가”를 묶는 키이고, evt-7은 “이 사건 자체가 한 번 처리됐는가”를 판정하는 키다. 둘을 바꿔 쓰면 같은 주문의 여러 이벤트를 하나로 뭉개거나, 같은 이벤트를 중복 처리하는 반대 문제가 생긴다.
Relayer는 outbox row를 브로커 메시지로 바꾸는 발행자다. 구현은 크게 세 가지다.
| 방식 | 동작 방식 | 장점 | 부담 |
|---|---|---|---|
| Polling relayer | processed_at IS NULL 같은 조건으로 outbox를 주기적으로 조회한다. | 구현과 디버깅이 단순하다. | 폴링 지연과 DB 조회 부하가 생긴다. |
| CDC relayer | Debezium 같은 CDC가 outbox INSERT를 WAL에서 감지해 발행한다. | 지연이 낮고 처리량이 높다. | Kafka Connect, replication slot 운영이 필요하다. |
| Managed stream bridge | DynamoDB Streams, EventBridge Pipes 같은 관리형 도구가 relayer 역할을 한다. | 운영 일부를 서비스에 맡길 수 있다. | 서비스별 제약과 비용 모델을 이해해야 한다. |
작게 시작하는 팀은 polling outbox로도 충분할 수 있다. 발행량이 많고 지연 요구가 낮아지면 CDC relayer가 자연스러운 전환점이 된다. 중요한 것은 방식이 아니라 경계다. 비즈니스 트랜잭션은 발행 의도를 남기고, relayer는 그 의도를 외부 메시지로 전달한다.
정량 감각은 단순하다. Polling relayer가 5초마다 500건씩 읽는다면, 새 이벤트는 평균적으로 약 2.5초를 기다리고 backlog가 10,000건이면 최소 20번의 poll cycle이 필요하다. 한 worker라면 backlog를 비우는 데 poll interval만으로도 100초가 걸린다. poll interval을 200ms로 줄이면 지연은 좋아지지만, 비어 있는 outbox를 확인하는 SELECT와 처리 표시 UPDATE가 훨씬 자주 발생해 DB CPU와 vacuum 부담이 커진다. CDC relayer는 반복 조회를 줄이고 WAL 흐름을 따라가므로 낮은 지연과 높은 처리량에 유리하지만, snapshot, logical decoding, Kafka Connect, replication slot을 운영해야 한다.
Relayer는 발행 책임을 분리하지만, 중복 발행 가능성까지 없애지는 않는다. Polling relayer가 브로커 publish에는 성공했지만 processed_at 업데이트 전에 죽으면 같은 outbox row를 다시 보낼 수 있다. CDC relayer도 Kafka write 이후 offset flush 전에 죽으면 마지막 메시지를 다시 내보낼 수 있다. 따라서 relayer의 성공 기준은 “한 번만 보낸다”가 아니라, “발행 의도를 잃지 않고 재시도하며, 같은 eventId가 다시 나가도 consumer가 견딘다”에 가깝다.
Outbox는 발행 유실 가능성을 크게 줄이지만 모든 문제를 없애지는 않는다.
이 경계를 받아들이는 것이 Outbox를 제대로 쓰는 출발점이다. Outbox는 강한 일관성을 전체 시스템으로 확장하는 도구가 아니라, 커밋된 사실과 발행 의도를 같은 원자적 경계 안에 두는 도구다.
CDC relayer를 이해하려면 PostgreSQL의 세 단어를 알아야 한다.
WAL(Write-Ahead Log) 은 PostgreSQL이 변경을 복구 가능하게 만들기 위해 먼저 기록하는 로그다. 트랜잭션이 커밋될 때 DB는 데이터 파일 전체를 즉시 안전하게 써야 하는 것이 아니라, 커밋에 필요한 WAL 레코드를 안전하게 flush하면 된다. 장애가 나면 PostgreSQL은 WAL을 다시 읽어 커밋된 변경을 재생한다.
LSN(Log Sequence Number) 은 WAL 안의 위치를 나타내는 주소다. CDC consumer가 “어디까지 읽었는가”를 말하려면 row 번호가 아니라 WAL 위치가 필요하다. 예를 들어 0/1A2B3C4D 같은 값은 WAL 스트림 안의 특정 지점을 가리킨다.
Replication slot은 PostgreSQL이 특정 consumer를 위해 WAL 보존 위치를 기억하는 장치다. Debezium이 slot을 만들면 PostgreSQL은 그 consumer가 확인한 LSN 이후의 WAL을 함부로 지우지 않는다. 이것이 CDC의 안전장치이자 가장 큰 운영 함정이다. consumer가 멈추면 slot의 위치가 앞으로 가지 않고, PostgreSQL은 오래된 WAL을 계속 보관한다.
PostgreSQL ├─ orders INSERT ├─ outbox INSERT ├─ COMMIT └─ WAL 기록 │ ▼logical decoding │ ▼replication slot - restart_lsn: 다시 읽을 수 있어야 하는 가장 오래된 위치 - confirmed_flush_lsn: consumer가 처리했다고 확인한 위치 │ ▼Debezium connector │ ▼Kafka topicCDC는 일반적으로 데이터 파일을 스캔하지 않는다. WAL을 logical decoding으로 읽어 “어떤 테이블에 어떤 row 변경이 커밋되었는가”라는 이벤트로 바꾼다. Outbox와 결합할 때는 모든 테이블을 다 내보내기보다 outbox 테이블의 INSERT만 발행 대상으로 삼는 경우가 많다.
PostgreSQL에서 이 흐름을 실제로 가능하게 하는 설정과 구성요소도 이름 정도는 알고 있어야 한다.
wal_level=logical: WAL을 logical decoding이 읽을 수 있는 수준으로 남기게 하는 설정이다.pgoutput: PostgreSQL 10 이후 내장된 logical decoding output plugin이다. Debezium PostgreSQL connector에서 흔히 쓴다.max_replication_slots: 동시에 유지할 replication slot 수의 상한이다. CDC connector가 늘면 slot도 늘어난다.max_wal_senders: WAL을 외부 consumer로 보내는 sender 프로세스 수의 상한이다.restart_lsn, confirmed_flush_lsn, 현재 WAL 위치는 서로 다른 질문에 답한다. 현재 WAL 위치는 DB가 어디까지 변경을 만들었는가를 말한다. confirmed_flush_lsn은 consumer가 어디까지 처리했다고 확인했는가를 말한다. restart_lsn은 PostgreSQL이 slot을 위해 최소 어디부터 WAL을 보존해야 하는가를 말한다. 그래서 freshness lag는 “이벤트가 consumer에 얼마나 늦게 도착하는가”이고, retained WAL은 “slot 때문에 디스크에 얼마나 오래된 WAL을 붙잡고 있는가”다. 둘은 함께 나빠질 수 있지만 같은 지표는 아니다.
두 지표를 섞으면 장애 판단이 흔들린다.
| 지표 | 묻는 질문 | 나빠질 때 보이는 현상 | 반례/주의점 |
|---|---|---|---|
| freshness lag | 이벤트가 downstream에 얼마나 늦나 | consumer가 최신 주문, 검색 인덱스, 분석 테이블을 늦게 본다. | WAL 보존량이 작아도 느린 consumer 때문에 커질 수 있다. |
| retained WAL | DB가 slot 때문에 얼마를 붙잡나 | pg_wal 또는 RDS storage가 계속 증가한다. | consumer가 잠깐 느려도 slot이 금방 따라잡으면 작을 수 있다. |
예를 들어 Kafka consumer가 느려져 검색 인덱스 반영이 30초 늦는 것은 freshness 문제다. 반대로 Debezium connector 자체가 멈춰 restart_lsn이 움직이지 않고 WAL이 수십 GB 쌓이는 것은 DB 디스크 위험이다. 둘 다 “CDC lag”라고 부르면 어느 팀이 무엇을 복구해야 하는지 흐려진다.
Debezium은 대표적인 log-based CDC 도구다. PostgreSQL connector는 replication slot을 통해 WAL을 읽고, Kafka Connect를 통해 Kafka topic으로 메시지를 보낸다.
snapshot.mode는 “처음 시작할 때 기존 데이터를 어떻게 다룰 것인가”를 정한다.
| 모드 | 의미 | 주로 쓰는 상황 |
|---|---|---|
initial | 기존 row를 한 번 읽고 이후 WAL을 따라간다. | 새 connector를 안전하게 붙일 때 |
no_data | row snapshot 없이 저장된 LSN 또는 새 slot 위치부터 streaming한다. | 별도 backfill을 쓰고 WAL 시작점을 검증했을 때 |
when_needed | offset이 없거나 유효하지 않을 때 snapshot한다. | 운영 복구 자동화를 선호할 때 |
initial_only | snapshot만 하고 streaming은 하지 않는다. | 일회성 backfill |
Outbox 테이블은 보통 앞으로 발생할 이벤트를 담기 때문에 initial이나 no_data 중 하나를 선택한다. 이미 오래 쌓인 outbox row가 있다면 snapshot 부하와 중복 발행 위험을 함께 판단해야 한다. 현재 Debezium stable의 snapshot 생략 모드는 no_data다. 오래된 예제에서 보이는 never를 새 설정에 그대로 복사하지 말고, 실제 배포 버전의 connector 문서와 유효 값 목록을 확인한다.
no_data는 “설정한 지금부터”라는 시간 필터가 아니다. Kafka Connect offset storage에 이전 LSN이 있으면 그 위치부터 재개하고, 저장된 LSN이 없는 새 connector라면 새 replication slot이 만들어진 위치부터 streaming한다. 따라서 connector를 재생성했어도 offset이나 slot을 재사용하면 그 시작점 이후 WAL에 남은 과거 outbox INSERT가 다시 발행될 수 있다. 배포 전에 connector offset, slot의 confirmed_flush_lsn과 restart_lsn, downstream 처리 위치를 함께 대조하고, 재전달된 eventId를 consumer Inbox나 고유 제약으로 무해하게 만드는 멱등성을 기본 전제로 둔다.
snapshot 판단은 “기존 row가 있는가”보다 “그 row를 이벤트로 다시 발행해야 하는가”가 먼저다.
| 상황 | 판단 | 이유 |
|---|---|---|
| 신규 outbox이고 row가 거의 없음 | initial도 부담이 작다. | snapshot이 읽을 row가 적고, 이후 WAL streaming으로 자연스럽게 전환된다. |
| 운영 outbox에 수백만 row가 있음 | 먼저 row 수, row 크기, 보존 기간을 계산한다. | 큰 JSON payload를 snapshot하면 OLTP DB I/O와 Kafka 적재량을 동시에 압박한다. |
| 과거 이벤트가 downstream에 필요함 | initial 또는 별도 backfill을 설계한다. | 검색/분석/감사 시스템이 과거 상태를 재구성해야 한다. |
| 과거 이벤트가 이미 처리됐거나 불필요함 | no_data의 실제 시작 LSN을 먼저 검증한다. | snapshot은 생략해도 offset/slot 이후 WAL 이벤트는 재생될 수 있다. |
| schema가 과거와 현재에서 다름 | snapshot 전에 변환 규칙을 정한다. | 예전 payload가 현재 consumer 계약과 맞지 않을 수 있다. |
숫자로 보면 판단이 더 현실적이다. outbox에 2,000,000 row가 있고 평균 payload가 4KB라면 payload만 약 8GB다. Debezium이 initial snapshot으로 이를 한 번에 읽으면 OLTP DB I/O, Kafka 적재량, consumer 재처리 비용이 동시에 생긴다. 과거 이벤트가 꼭 필요하면 별도 backfill 창을 잡는 편이 낫다. 이미 처리된 이벤트라면 no_data로 row snapshot을 피할 수 있지만, 저장 offset이나 slot 위치 이후의 WAL 이벤트까지 사라지는 것은 아니므로 재생 범위를 측정하고 consumer 멱등성을 확인해야 한다.
CDC가 outbox INSERT를 그대로 내보내면 consumer는 다음 형태를 받는다.
{ "id": "evt-7", "aggregate_type": "Order", "aggregate_id": "ord-1", "event_type": "OrderCreated", "payload": { "eventId": "evt-7", "eventType": "OrderCreated", "eventVersion": 1, "orderId": "ord-1", "amount": 19900 }}이 메시지는 DB row에 가깝다. Consumer가 진짜 원하는 것은 payload 안의 도메인 이벤트다. Debezium의 Outbox Event Router SMT(Single Message Transform) 는 outbox row를 도메인 이벤트로 바꾸는 변환기다.
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.table.fields.additional.placement=event_type:header:typetransforms.outbox.route.by.field=aggregate_typetransforms.outbox.route.topic.replacement=domain.${routedByValue}.events이 설정은 문서의 snake_case 스키마를 SMT 필드에 명시적으로 대응시킨다. id는 Kafka header의 id, aggregate_id는 message key, payload는 message value, event_type은 type header가 된다. aggregate_type=Order인 row는 domain.Order.events 토픽으로 간다. 같은 주문에 대한 이벤트를 같은 partition에 보내 순서를 유지하려면 key를 aggregate id로 두는 것이 중요하다.
SMT는 payload column을 message value로 수정 없이 전달한다. 따라서 id header를 만들더라도 payload에 eventId를 삽입하지 않고, event_type을 type header로 옮기더라도 payload에 eventType을 삽입하지 않는다. 이 문서의 정책은 header를 편의 메타데이터로, payload의 eventId와 eventType을 consumer 계약으로 함께 제공하는 것이다. 위 트랜잭션 예제처럼 애플리케이션이 하나의 eventId와 eventType 값을 각각 outbox column과 payload에 동시에 기록해야 두 표현이 일치한다.
정상 payload는 작고 안정적이어야 한다. Consumer가 주문 전체 row를 다시 구성할 수 있도록 모든 컬럼을 밀어 넣는 것이 아니라, “무슨 일이 일어났고, 누가 중복 판정을 할 수 있는가”를 담는다.
{ "eventId": "evt-7", "eventType": "OrderCreated", "eventVersion": 1, "orderId": "ord-1", "userId": "user-42", "amount": 19900, "occurredAt": "2026-06-30T12:00:00Z"}반대로 payload에 order.status의 모든 과거/현재 필드, 내부 정산 플래그, consumer가 모르는 임시 컬럼을 계속 추가하면 이벤트가 DB row 복제처럼 변한다. 그 순간 schema evolution 비용이 커지고, 어느 consumer가 어떤 필드에 의존하는지 추적하기 어려워진다.
eventVersion은 payload가 시간이 지나며 바뀔 수 있다는 사실을 명시하는 장치다. 보통 필드 추가는 consumer가 모르는 필드를 무시할 수 있으면 안전하지만, 필드 제거, 이름 변경, 타입 변경은 기존 consumer를 깨뜨린다. amount를 number에서 string으로 바꾸거나 userId를 없애야 한다면 같은 OrderCreated를 조용히 바꾸기보다 eventVersion: 2 또는 새 이벤트 타입을 두고, consumer가 v1과 v2를 함께 읽는 전환 기간을 잡는 편이 안전하다. 이벤트 스키마는 코드 내부 DTO가 아니라 여러 팀이 공유하는 배포 계약으로 다뤄야 한다.
CDC + Outbox는 보통 at-least-once로 이해해야 한다. 이벤트가 적어도 한 번은 발행되도록 재시도하지만, 장애 타이밍에 따라 같은 이벤트가 두 번 이상 발행될 수 있다.
중복은 이상한 예외가 아니라 정상적인 가능성이다.
따라서 consumer는 idempotency를 가져야 한다. 가장 흔한 방식은 Inbox 패턴이다. Consumer 쪽 DB에 처리한 event id를 먼저 기록하고, 이미 처리한 이벤트면 본문 처리를 건너뛴다.
CREATE TABLE inbox ( event_id UUID PRIMARY KEY, processed_at TIMESTAMPTZ NOT NULL DEFAULT NOW());async function handleOrderCreated(event: OrderCreatedEvent) { await db.transaction(async (tx) => { const inserted = await tx.inbox .insert({ eventId: event.eventId }) .onConflictDoNothing();
if (inserted.rowCount === 0) { return; // 이미 처리한 이벤트 }
await tx.searchProjection.upsert({ orderId: event.orderId, userId: event.userId, amount: event.amount, }); });}이 예시는 검색 projection처럼 consumer 자신의 DB에 쓰는 작업을 기준으로 한다. inbox 기록과 projection 갱신을 같은 트랜잭션으로 묶으면, projection 갱신이 실패했을 때 inbox만 먼저 남아 재시도가 막히는 문제를 줄일 수 있다. 이메일 발송, 외부 결제 취소, 서드파티 API 호출처럼 DB 트랜잭션으로 묶을 수 없는 부수효과는 provider idempotency key, 별도 outbox, 재시도 상태 테이블을 추가로 설계해야 한다.
외부 부수효과는 더 엄격하게 나눠서 봐야 한다. 검색 projection의 UPSERT처럼 같은 입력을 여러 번 적용해도 결과가 같은 작업은 Inbox 트랜잭션으로 충분한 경우가 많다. 반면 이메일 발송은 같은 API를 두 번 호출하면 실제 메일이 두 번 갈 수 있고, 결제 취소나 포인트 지급은 돈의 이동이 두 번 생길 수 있다. 이때는 eventId를 외부 provider의 idempotency key로 넘기거나, consumer 내부에 다시 outbox를 두어 “외부 호출 의도”와 “호출 결과”를 별도로 추적한다. 즉 Inbox는 중복 이벤트를 감지하는 최소 장치이고, 모든 부수효과를 자동으로 exactly-once로 바꾸는 장치가 아니다.
Provider idempotency key는 외부 API가 같은 키로 들어온 재시도를 같은 요청으로 취급하게 만드는 식별자다. 예를 들어 결제 취소 API에 evt-7을 idempotency key로 넘기면 네트워크 타임아웃 뒤 같은 이벤트를 재시도해도 provider가 취소를 두 번 만들지 않도록 기대할 수 있다. Consumer-side outbox는 consumer가 자기 DB 트랜잭션 안에 “이 외부 API를 호출해야 한다”는 row를 남기고, 별도 worker가 그 row를 재시도하면서 성공/실패를 기록하는 패턴이다.
Producer 쪽 Outbox와 consumer 쪽 Inbox는 짝이다. Outbox는 “커밋된 사건의 발행 의도”를 잃지 않게 하고, Inbox는 “적어도 한 번 온 이벤트”가 여러 번 효과를 내지 않게 한다. 이 조합을 실무에서는 effectively once에 가깝게 다룬다. 인프라가 완전한 exactly-once를 만들어준다고 가정하지 않고, 중복 가능성을 설계에 포함하는 방식이다.
예를 들어 주문 생성은 하나의 API 응답으로 끝나지 않는다.
orders + outbox commit │ ▼domain.Order.events │ ├─ notification-service ├─ inventory-service ├─ analytics-pipeline ├─ search-indexer └─ recommendation-workerOutbox가 없으면 일부 downstream에만 이벤트가 가는 상태가 생긴다. Outbox가 있으면 적어도 “주문 커밋과 발행 의도 커밋”은 함께 움직인다.
다른 실무 사례도 같은 구조로 읽으면 된다.
Outbox + CDC는 강력하지만 공짜가 아니다. 다음 조건에서는 더 단순한 선택이 맞을 수 있다.
새로운 메시징 도구나 관리형 서비스를 볼 때도 질문은 같다.
| 질문 | 확인할 것 |
|---|---|
| 원자성은 어디서 보장되는가? | DB 트랜잭션, 단일 저장소 트랜잭션, 2PC, 또는 없음 |
| 발행 책임은 어디로 분리되는가? | producer 내부, 별도 relayer, CDC, managed bridge |
| 보장 수준은 무엇인가? | at-most-once, at-least-once, effectively once |
| 중복은 어디서 막는가? | event id, inbox table, UPSERT, 자연 멱등 작업 |
| 실패 흔적은 어디에 남는가? | outbox row, retry queue, DLQ, audit log, connector 상태 |
이 다섯 칸 중 비어 있는 자리가 그 구조의 사고 지점이다. 특히 “원자성”과 “실패 흔적”이 빈 구조는 운영 중에 원인을 설명하기 어렵다.
이 문서의 중심은 개념이지만, CDC + Outbox는 운영 실패 모드를 모르면 위험하다. 명령어보다 먼저 실패 원리를 기억해야 한다.
Replication slot은 consumer가 읽지 않은 WAL을 보존한다. Debezium이 멈춘 상태에서 OLTP 쓰기는 계속되면, PostgreSQL은 slot을 위해 오래된 WAL을 지우지 못한다. 디스크가 차면 DB 전체 장애로 번질 수 있다.
예를 들어 평소 slot의 retained WAL이 수 MB 수준이어도, 쓰기가 많은 DB에서 Debezium이 6시간 멈추면 수백 GB까지 커질 수 있다. 시간당 2050GB의 WAL을 만드는 OLTP라면 23시간 장애만으로도 RDS 디스크 여유분이 위험해진다. 이 숫자는 외워야 할 임계값이 아니라, “CDC consumer 중단은 메시지 지연만이 아니라 DB 디스크 장애로 번질 수 있다”는 감각을 주기 위한 예시다.
실패 신호:
pg_replication_slots.active = false가 오래 지속된다.pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn) 값이 계속 증가한다.wal_status = 'lost'가 보이면 slot이 더 이상 필요한 WAL을 보존하지 못한 상태일 수 있다.active = true만으로는 안심할 수 없다. Connector 프로세스가 연결되어 있어도 downstream Kafka write, offset 저장, consumer 처리 중 하나가 느리면 confirmed_flush_lsn이 천천히 움직일 수 있다. 관측할 때는 “프로세스가 살아 있는가”보다 “확인된 LSN이 현재 WAL 위치를 따라잡고 있는가”, “retained WAL이 시간에 비례해 계속 쌓이는가”를 함께 본다.
짧은 관찰 예시는 다음과 같다.
| 시점 | active | confirmed_flush_lsn | retained_wal | 해석 |
|---|---|---|---|---|
| 정상 | true | 계속 증가 | 32 MB | connector가 WAL을 따라가고 있다. |
| connector 중단 | false | 멈춤 | 18 GB | DB는 slot을 위해 WAL을 붙잡고 있다. |
| 재시작 후 catch-up | true | 빠르게 증가 | 96 MB | consumer가 밀린 WAL을 읽고 있다. |
이 표에서 핵심은 숫자의 절대값이 아니라 방향이다. confirmed_flush_lsn이 멈춘 채 retained WAL만 증가하면 freshness 지연을 넘어 DB 저장공간 문제가 된다. 반대로 재시작 뒤 confirmed_flush_lsn이 움직이고 retained WAL이 줄면, downstream은 늦게 따라오더라도 slot 자체는 회복 중이라고 볼 수 있다.
대응 철학은 slot drop부터가 아니다. 먼저 connector 복구가 가능한지 보고, 복구할 수 없고 디스크 위험이 크면 slot 제거와 backfill 전략을 함께 판단한다. max_slot_wal_keep_size는 디스크 폭발을 막는 safety net이지만, slot invalidation이 데이터 손실로 이어질 수 있다는 점을 이해해야 한다.
Logical decoding은 커밋 순서와 트랜잭션 경계를 지켜야 한다. 오래 열린 트랜잭션이 있으면 Debezium이 뒤의 변경을 마음대로 확정해 흘려보내기 어렵다. 이때 connector는 살아 있어도 slot LSN이 앞으로 가지 않는 것처럼 보일 수 있다.
실패 신호:
idle in transaction 세션이 오래 남아 있다.예방은 트랜잭션 범위를 짧게 유지하고, idle_in_transaction_session_timeout 같은 DB 설정으로 오래 열린 세션을 제한하는 것이다.
Outbox row를 영구 보관하면 감사 로그로 쓸 수 있지만, 무한히 쌓인다. CDC가 INSERT를 이미 읽었다면 운영 테이블에서는 일정 기간 뒤 정리해야 한다. 대량 DELETE는 다시 WAL을 많이 만들 수 있으므로, 큰 규모에서는 날짜 파티션을 만들고 오래된 partition을 drop하는 방식이 더 안전하다.
payload는 단순 JSON 덩어리가 아니라 consumer와의 계약이다. 필드 추가는 보통 안전하지만, 필드 제거와 타입 변경은 consumer를 깨뜨린다. 처음부터 eventVersion을 두고, breaking change는 새 이벤트 타입이나 새 버전으로 발행하는 편이 안전하다. Schema Registry를 쓰면 호환성 규칙을 자동으로 강제할 수 있다.
REPLICA IDENTITY는 WAL 크기에 영향을 준다PostgreSQL logical decoding에서 REPLICA IDENTITY는 UPDATE나 DELETE 이벤트를 만들 때 이전 row를 얼마나 기록할지 정한다. Outbox가 INSERT-only이고 primary key가 명확하다면 보통 DEFAULT면 충분하다. 불필요하게 FULL을 쓰면 큰 JSON payload가 WAL에 더 많이 남아 CDC와 디스크 부담을 키울 수 있다.
| 기준 | Polling outbox | CDC relayer(Debezium) |
|---|---|---|
| 지연 | poll interval만큼 지연된다. | 보통 더 낮다. |
| 처리량 | 쿼리 주기와 batch 크기에 묶인다. | Kafka와 connector 처리량까지 확장 가능하다. |
| DB 부하 | 반복 SELECT와 UPDATE가 생긴다. | WAL decoding과 snapshot 부하가 생긴다. |
| 운영 복잡도 | 애플리케이션/worker 수준에서 다룰 수 있다. | replication slot, Kafka Connect, offset 관리가 필요하다. |
| 장애 흔적 | outbox row와 processed 상태가 직접 보인다. | connector status, offset, slot LSN을 함께 봐야 한다. |
| 좋은 시작점 | 작은 팀, 낮은 발행량, 초 단위 지연 허용 | 높은 발행량, 낮은 지연, 이미 Kafka 운영 중 |
처음부터 Debezium을 도입해야만 “진짜 Outbox”가 되는 것은 아니다. Outbox의 본질은 DB 트랜잭션 안에 발행 의도를 남기는 것이다. CDC는 그 의도를 읽는 강력한 relayer 구현이다.
조금 더 구체적인 판단 기준은 다음처럼 잡을 수 있다.
| 관찰값/요구 | Polling 유지 쪽 신호 | CDC 검토 쪽 신호 |
|---|---|---|
| 허용 지연 | 1~10초 지연이 업무적으로 괜찮다. | 수백 ms~1초대 freshness가 필요하다. |
| 발행량 | 초당 수십 건 이하이고 batch로 충분히 비운다. | 초당 수백~수천 건 이상이거나 peak가 크다. |
| backlog | worker 증설이나 batch size 조정으로 줄어든다. | 계속 증가하고 p99 발행 지연이 SLA를 넘는다. |
| DB CPU | outbox poll query가 눈에 띄지 않는다. | 빈 poll, lock, processed update가 DB 부하에 보인다. |
| 운영 역량 | 앱 worker 로그와 DB row 상태로 추적하고 싶다. | Kafka Connect, slot, offset, schema evolution을 운영할 수 있다. |
| 장애 허용 방식 | 느리게 따라와도 직접 row를 보고 재처리한다. | slot 정체와 backfill을 운영 절차로 감당할 수 있다. |
전환 신호는 다음처럼 보면 된다.
Snapshot도 같은 식으로 판단한다. 기존 outbox row가 적고 과거 이벤트를 발행해야 한다면 initial이 자연스럽다. 이미 별도 backfill을 했거나 row snapshot이 필요 없다면 no_data를 검토하되, 저장된 connector LSN 또는 새 slot 생성 위치가 실제 streaming 시작점이라는 사실을 확인한다. 오래 쌓인 outbox를 initial로 한 번에 읽으면 OLTP DB에 snapshot 부하와 중복 발행 위험을 동시에 만들 수 있고, no_data도 시작 LSN 이후 변경의 재전달 가능성을 없애지는 않는다.
| 개념 | 핵심 차이 |
|---|---|
| 직접 publish | DB 커밋과 발행이 하나의 원자적 경계에 없다. |
| Trigger 기반 CDC | DB trigger로 변경을 별도 테이블에 남긴다. 애플리케이션 의도보다 DB 변경 캡처에 가깝다. |
| Query 기반 CDC | updated_at 같은 컬럼을 폴링한다. DELETE와 빠른 연속 변경을 놓치기 쉽다. |
| Event Sourcing | 이벤트가 상태의 원본이다. Outbox는 상태 변경 후 발행 신뢰성을 위한 보조 원장이다. |
| Inbox | Consumer 쪽 dedup 패턴이다. Producer 쪽 Outbox와 짝을 이룬다. |
LISTEN/NOTIFY | PostgreSQL 알림 기능이다. subscriber가 없거나 장애 중이면 내구적 큐처럼 쓰기 어렵다. |
특히 Query 기반 CDC는 처음에는 쉬워 보이지만 반례가 많다. updated_at > last_seen_at로 1초마다 polling한다고 가정해보자. 같은 row가 1초 안에 PENDING -> PAID -> CANCELED로 빠르게 바뀌면 consumer는 마지막 상태만 보고 중간 사건을 놓칠 수 있다. DELETE는 row 자체가 사라져 별도 tombstone 없이는 감지하기 어렵다. 서버 시간이 어긋나거나 updated_at 갱신을 누락한 코드가 있으면 변경이 조용히 빠진다. 그래서 “최종 상태 동기화”에는 query polling이 충분할 수 있지만, “사건의 순서와 유실 방지”가 필요하면 WAL 기반 CDC나 Outbox가 더 맞다.
본문 이해에는 아래 절차를 외울 필요가 없다. 운영이나 실습을 실제로 할 때 참고할 수 있도록 핵심만 접어 둔다.
Replication slot WAL 보존량을 볼 때는 현재 WAL 위치와 slot의 restart_lsn 차이를 확인한다.
SELECT slot_name, active, wal_status, restart_lsn, confirmed_flush_lsn, pg_size_pretty( pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn) ) AS retained_walFROM pg_replication_slots;오래 열린 트랜잭션은 다음처럼 찾는다.
SELECT pid, usename, application_name, state, now() - xact_start AS xact_age, queryFROM pg_stat_activityWHERE xact_start IS NOT NULLORDER BY xact_start;Outbox 테이블의 replica identity는 다음처럼 확인한다.
SELECT relname, relreplidentFROM pg_classWHERE relname = 'outbox';relreplident = 'd'는 DEFAULT, 'f'는 FULL이다.
전체 docker-compose 파일을 문서 본문에 외울 필요는 없다. 실습 목표는 네 가지다.
wal_level=logical로 띄운다.outbox 테이블을 만들고 Debezium connector가 public.outbox만 읽게 한다.orders와 outbox를 한 트랜잭션으로 INSERT한 뒤 Kafka topic에 payload가 도착하는지 본다.Connector 설정에서 확인할 핵심 필드는 다음 정도다.
{ "connector.class": "io.debezium.connector.postgresql.PostgresConnector", "plugin.name": "pgoutput", "table.include.list": "public.outbox", "snapshot.mode": "initial", "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.table.fields.additional.placement": "event_type:header:type", "transforms.outbox.route.by.field": "aggregate_type", "transforms.outbox.route.topic.replacement": "domain.${routedByValue}.events"}실습에서 반드시 관찰할 것은 세 가지다.
slot 관찰은 다음 두 시점만 비교해도 충분하다.
SELECT slot_name, active, restart_lsn, confirmed_flush_lsn, pg_size_pretty( pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn) ) AS retained_walFROM pg_replication_slots;retained_wal이 작게 유지되는지 본다.confirmed_flush_lsn이 앞으로 가고 retained_wal이 줄어드는지 본다.이 실습의 목적은 connector 설정을 외우는 것이 아니라, “slot이 consumer의 읽기 위치를 기억하기 때문에 안전하지만, 멈추면 WAL을 붙잡는다”는 원리를 눈으로 확인하는 것이다.
CDC가 outbox INSERT를 이미 읽었다면 운영 테이블은 일정 기간 뒤 정리한다. 대량 테이블에서는 일반 DELETE보다 날짜 파티션이 안전하다.
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_06_30 PARTITION OF outbox FOR VALUES FROM ('2026-06-30') TO ('2026-07-01');오래된 파티션은 DROP TABLE outbox_YYYY_MM_DD로 제거한다. 일반 DELETE보다 WAL 발생량이 작고, 정리 작업이 CDC 자체를 압박할 가능성이 낮다.