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

9.9 KiB

title, source_type, status, confidence, tags, related_projects, last_reviewed
title source_type status confidence tags related_projects last_reviewed
Transactional Outbox Pattern (SKIP LOCKED polling vs CDC) llm-generated draft medium
outbox
event-driven
distributed-systems
ca-skeleton
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

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