Files
llm-wiki/wiki/concepts/transactional-outbox-pattern.md

113 lines
9.9 KiB
Markdown

---
title: Transactional Outbox Pattern (SKIP LOCKED polling vs CDC)
source_type: llm-generated
status: draft
confidence: medium
tags: [outbox, event-driven, distributed-systems]
related_projects: [ca-skeleton]
last_reviewed: 2026-05-22
---
# Transactional Outbox Pattern (SKIP LOCKED polling vs CDC)
> Layer: `wiki/concepts/` — 일반 개념. 프로젝트 적용 사실은 [[raw/project-notes/ca-skeleton-operational-contract]] 등 project 문서 참조.
## Summary
Transactional outbox는 "DB write + 외부 메시지 publish"라는 두 시스템에 걸친 원자성 요구를 **단일 RDB 트랜잭션 + 비동기 publisher**로 우회하는 패턴입니다. 도메인 변경과 같은 트랜잭션에서 `outbox` 테이블에 이벤트 row를 INSERT하고, 별도 publisher가 그 row를 polling(또는 CDC)으로 읽어 broker에 발행함으로써 dual-write 문제(두 시스템 중 하나만 성공)를 제거합니다. polling 구현체에서는 PostgreSQL/MySQL의 `FOR UPDATE SKIP LOCKED`로 다중 publisher 간 row 경합을 해소합니다.
## Standard (공식 정의)
- **microservices.io / Chris Richardson**: outbox 패턴의 원형 정의. 서비스가 DB 트랜잭션 내에 `OUTBOX` 테이블에 이벤트를 기록하고, 별도 message relay가 이 테이블을 읽어 broker로 publish. dual-write를 명시적 anti-pattern으로 두고 outbox/event sourcing을 두 정식 대안으로 제시.
- **PostgreSQL `FOR UPDATE SKIP LOCKED`**: 9.5+. `SELECT ... FOR UPDATE` 대상 row 중 다른 트랜잭션이 이미 잠근 row를 **차단 없이 skip**. queue 형태의 워크로드(outbox claim, job queue)에 사용 권장. 잠금은 row 단위, 트랜잭션 종료 시 해제.
- **MySQL 8.0+ `SKIP LOCKED`**: PostgreSQL과 동일한 의미. 8.0 이전 버전은 미지원 — advisory lock으로 fallback.
- **Debezium**: 오픈소스 CDC 플랫폼. DB write-ahead log(Postgres logical replication / MySQL binlog)을 읽어 변경 이벤트를 Kafka 등 broker로 전달. outbox 테이블도 다른 테이블과 동일하게 WAL/binlog로 캡처.
- **Kafka Connect Outbox Event Router (Debezium SMT)**: Debezium이 캡처한 outbox row를 Single Message Transform 단계에서 Kafka topic/key/headers로 라우팅. outbox row schema 규약(`aggregatetype`, `aggregateid`, `type`, `payload`)을 요구.
- **delivery semantic**: outbox + 비동기 publish는 **at-least-once**가 기본이며 exactly-once가 아님. consumer 측에서 `eventId` 또는 `idempotencyKey` 기반 dedupe가 필수.
## 한계 / 주의점
각 구현 옵션별 trade-off.
### SKIP LOCKED polling
- publish lag = polling interval + claim transaction + broker publish. 일반적으로 **수 초~수 분** 수준이며 sub-second lag 요구에는 부적합.
- outbox 테이블이 단조 증가 → archived/published row cleanup 정책 필수 (TTL 삭제 또는 partition rotation). 누락 시 인덱스 비대 및 vacuum 비용 증가.
- 단일 DB가 SSOT여야 함. 멀티 DB에 도메인 write가 분산되면 outbox 1개로 해소 불가.
- multi-instance publisher 운영 시 동일 row 중복 claim 방지는 SKIP LOCKED 자체가 보장하지만, publish 후 commit 실패 시 재시도로 인한 중복 publish 가능 → consumer dedupe가 정합성의 일부.
### Debezium CDC
- WAL/binlog 기반이므로 publish lag이 polling보다 짧음(밀리초~초 단위).
- 단, Kafka Connect 클러스터, connector 설정/스키마, replica slot 관리, snapshot 운영 인력이 추가로 필요. **인프라 비용·운영 학습 비용이 폴링 대비 크게 큼**.
- Postgres에서는 logical replication slot이 누적되면 WAL 디스크가 증가하는 운영 risk가 있음(slot lag 모니터링 필수).
- 마이그레이션 트리거는 보통 "polling lag SLO 위반" 또는 "DB load가 polling 쿼리로 포화"이며, 그 가정이 깨지지 않으면 도입 정당화 어려움.
### Kafka Connect Outbox SMT (Debezium event router)
- payload 변환·라우팅 로직이 connector 설정 + SMT 규약에 묶임. 복잡한 payload 가공이나 multi-topic fan-out은 SMT 표현력의 한계가 있음.
- outbox row schema가 Debezium event router 규약에 종속 → 자유로운 컬럼 설계가 어려움.
### Dual-write (anti-pattern, negative reference)
- 애플리케이션 코드에서 DB commit과 broker publish를 **순차로 직접 호출**하는 형태. 둘 사이에 프로세스 종료/장애가 끼면 정합성이 깨짐.
- outbox 도입의 근거 그 자체이므로, "왜 outbox인가"의 답은 항상 dual-write 실패 시나리오에서 출발.
- 외부 publish 없이 in-process consumer만 있는 경우라면 트랜잭션 commit 후 in-process dispatch도 허용 가능 — 하지만 외부 transport가 끼는 순간 outbox가 기본값.
### Event sourcing
- 흔히 "outbox 대안"으로 묶이지만 실제로는 **도메인 모델 자체를 이벤트 스트림으로 교체**하는 결정이며, 단순 publish 정합성 문제 해결이 아님.
- 도메인 재설계, 스냅샷·재구성 운영, 쿼리 모델(CQRS) 분리 비용 동반. 단지 "이벤트 발행이 필요해서" event sourcing으로 가는 것은 trade-off 오판.
### Spring `@TransactionalEventListener`
- `AFTER_COMMIT` phase에서 in-process bean으로 이벤트 dispatch. **JVM 프로세스 내부에서만 동작**.
- commit 직후 publish 실패(예: 외부 broker 호출 예외, 프로세스 강제 종료)에 대한 영속 큐가 없음 → **재시작 시 유실**. 외부 broker로 가는 integration event 발행에는 부적합.
- 도메인 이벤트의 in-process side effect 트리거 용도로만 안전.
### Netflix DBLog 류 자체 CDC
- Debezium보다 더 큰 자체 인프라 투자. 일반 백엔드 팀이 도입할 baseline 아님. 비교 시 "왜 Debezium도 부담이라 polling을 골랐는가"의 대조군으로만 사용.
## Project Application
- [[wiki/projects/ca-tmpl/transactional-outbox-pattern]] — ca-tmpl 의사결정 기록 (현재 `documented-only`, Phase C2 미진입). 실제 구현 여부는 project 문서 참조.
- [[raw/branch-notes/feature-domain-event-outbox-contract]] — outbox publisher SSOT, row status(`PENDING/IN_FLIGHT/PUBLISHED/FAILED/DEAD`), per-aggregate FIFO, claim transaction(`READ_COMMITTED` + `FOR UPDATE SKIP LOCKED`), at-least-once + consumer dedupe 결정.
- [[raw/branch-notes/feature-background-job-async-contract]] — outbox publisher가 consume하는 retry/DLQ vocabulary(exp backoff with jitter, max 3, DLQ exhausted) SSOT.
- [[raw/project-notes/ca-skeleton-operational-contract]] (§11 Adapter Failure, §14 Transaction/Concurrency, §29 Topic 3) — outbox 패턴이 어떤 운영 계약 안에서 어떤 위치를 차지하는지의 canonical map.
## Interview Questions
- 왜 dual-write는 안 되는가? outbox는 dual-write의 어떤 실패 모드를 어떻게 제거하는가?
- SKIP LOCKED polling은 publish lag과 어떤 trade-off를 가지는가? lag을 줄이려면 polling interval만 줄이면 되는가?
- Debezium CDC로 마이그레이션을 결정하는 트리거는 무엇인가? (어떤 가정이 깨졌을 때?)
- outbox 테이블 cleanup(archived row 삭제/파티셔닝)을 누락하면 어떤 문제가 생기는가?
- outbox가 exactly-once를 보장하지 않는 이유와, 그 위에서 consumer가 정합성을 유지하는 메커니즘(idempotency key)을 설명할 수 있는가?
## Do Not Overclaim
- "outbox = exactly-once delivery"라고 말하지 않기. 정확한 표현은 **at-least-once delivery + idempotent consumer**.
- "Debezium을 곧 도입할 것"이라고 말하지 않기. CDC migration은 polling lag SLO나 DB 부하 가정이 깨질 때만 정당화되며, 현 시점에는 가정이 유지된다고만 말할 것.
- "outbox만 있으면 정합성이 보장된다"고 말하지 않기. publisher 측의 retry/DLQ, consumer 측의 dedupe, outbox row cleanup 정책이 함께 있어야 운영 가능.
- "SKIP LOCKED가 race condition을 다 막아준다"고 말하지 않기. SKIP LOCKED는 **claim 단계의 row 경합**만 해소하며, publish 후 commit 실패로 인한 재발행은 별개의 문제.
- "event sourcing이 outbox의 상위 호환이다"라고 말하지 않기. 둘은 해결하려는 문제의 층위가 다름(전달 정합성 vs 도메인 모델링).
- 본인이 polling publisher를 운영해 본 측정값이 없다면 lag 수치를 단정적으로 말하지 않기.
## Sources
- [Pattern: Transactional outbox (microservices.io)](https://microservices.io/patterns/data/transactional-outbox.html) — outbox 원형 정의 / Chris Richardson
- [PostgreSQL: SELECT — The Locking Clause](https://www.postgresql.org/docs/current/sql-select.html#SQL-FOR-UPDATE-SHARE) — `FOR UPDATE SKIP LOCKED` 의미론
- [Debezium documentation — Outbox Event Router](https://debezium.io/documentation/reference/stable/transformations/outbox-event-router.html) — Kafka Connect SMT
- [Spring Framework — `@TransactionalEventListener`](https://docs.spring.io/spring-framework/reference/data-access/transaction/event.html) — in-process only 한계
- [[raw/official-docs/outbox-skip-locked-microservices-io]] — outbox 원형 raw 발췌
- [[raw/official-docs/skip-locked-postgres-docs]] — Postgres SKIP LOCKED 원리
- [[raw/official-docs/outbox-debezium-official-docs]] — Debezium 공식 문서
- [[raw/official-docs/spring-transactional-event-listener]] — Spring 공식 문서
- [[raw/official-docs/event-sourcing-vs-outbox-microservices-io]] — outbox vs event sourcing
- [[raw/official-docs/dual-write-antipattern-microservices-io]] — dual-write negative reference
- [[raw/company-tech-blogs/outbox-woowahan-techblog-pattern]] — 우아한형제들 polling 사례
- [[raw/company-tech-blogs/outbox-wix-engineering-debezium]] — Wix Debezium migration 사례
- [[raw/company-tech-blogs/outbox-confluent-kafka-connect-smt]] — Confluent Kafka Connect outbox SMT
- [[raw/company-tech-blogs/outbox-netflix-domain-events-cdc]] — Netflix DBLog 자체 CDC
- [[raw/project-notes/ca-skeleton-operational-contract]] — §11 / §14 / §29 Topic 3 canonical map