Domain Event & Outbox 패턴 완전 가이드
TL;DR
- Domain Event & Outbox 패턴 완전 가이드의 핵심 개념과 실무 적용 포인트를 함께 정리한 글이다.
- Domain Event & Outbox 패턴 완전 가이드를 알아두면 설계 판단과 구현 선택을 더 분명하게 할 수 있다.
- 원문 전체는 아래 상세 내용에 그대로 포함했다.
1. 개념
Domain Event & Outbox 패턴 완전 가이드의 핵심 개념과 실무 적용 포인트를 함께 정리한 글이다.
2. 배경
Domain Event & Outbox 패턴 완전 가이드가 등장한 배경과 문제 상황을 이해하는 데 도움이 된다.
3. 이유
Domain Event & Outbox 패턴 완전 가이드를 알아두면 설계 판단과 구현 선택을 더 분명하게 할 수 있다.
4. 특징
Domain Event & Outbox 패턴 완전 가이드의 특징, 장단점, 적용 포인트를 원문에서 자세히 확인할 수 있다.
5. 상세 내용
Domain Event & Outbox 패턴 완전 가이드
1. Domain Event & Outbox 패턴이란?
1.1 마이크로서비스의 근본적 딜레마
┌─────────────────────────────────────────────────────────────────┐
│ 마이크로서비스의 근본적 딜레마 │
│ │
│ [문제 상황] │
│ │
│ 주문 서비스의 일상: │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ │ │
│ │ 1. DB에 주문 저장 (PostgreSQL) │ │
│ │ 2. 이벤트 발행 (Kafka) │ │
│ │ │ │
│ │ 두 작업 모두 성공해야 "주문 처리 완료" │ │
│ │ 그런데... 이 두 시스템은 서로 다른 시스템! │ │
│ │ 하나의 트랜잭션으로 묶을 수 없다! │ │
│ │ │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ 실패 시나리오: │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ │ │
│ │ 시나리오 A: DB 성공 → Kafka 실패 │ │
│ │ → 이벤트 유실! 다른 서비스는 주문 생성을 모름 │ │
│ │ │ │
│ │ 시나리오 B: Kafka 성공 → DB 실패 │ │
│ │ → 없는 주문에 대한 이벤트가 전파됨! │ │
│ │ │ │
│ │ 순서를 바꿔도 해결 불가 │ │
│ │ → 두 시스템이 서로의 트랜잭션을 인식하지 못하기 때문 │ │
│ │ │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ 이것이 Dual Write(이중 쓰기) 문제 │
│ → 아키텍처 구조 자체에서 발생하는 근본적 문제 │
│ → 코딩 실수가 아님! │
│ │
│ 참고: docs/backend/dual-write-패턴.md │
│ │
└─────────────────────────────────────────────────────────────────┘
1.2 해결책: Outbox 패턴
┌─────────────────────────────────────────────────────────────────┐
│ Outbox 패턴 = "발신함" 메타포 │
│ │
│ 이메일 Outbox(발신함)을 떠올려보자: │
│ ├── 이메일을 작성하면 먼저 Outbox에 저장 │
│ ├── 인터넷이 끊겨도 Outbox에 안전하게 보관 │
│ └── 연결이 복구되면 자동으로 전송 │
│ │
│ 소프트웨어 Outbox 패턴도 동일한 원리: │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ │ │
│ │ [기존 Dual Write] │ │
│ │ Service ──┬──→ DB에 저장 ← 트랜잭션 1 │ │
│ │ └──→ Kafka에 발행 ← 트랜잭션 2 (원자성X)│ │
│ │ │ │
│ │ [Outbox 패턴] │ │
│ │ Service ──→ DB에 저장 + Outbox에 이벤트 저장 │ │
│ │ └──→ 하나의 DB 트랜잭션! (원자성O) │ │
│ │ │ │
│ │ 별도 프로세스가 Outbox → Kafka로 전달 │ │
│ │ │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ 핵심 아이디어: │
│ "메시지 브로커에 직접 쓰지 말고, │
│ DB 트랜잭션 안에서 Outbox 테이블에 먼저 쓴다" │
│ │
│ 왜 "Transactional" Outbox인가? │
│ ├── 비즈니스 데이터 변경과 동일한 DB 트랜잭션 │
│ ├── 트랜잭션 롤백 → Outbox 레코드도 함께 롤백 │
│ └── 트랜잭션 커밋 → Outbox 레코드도 함께 커밋 │
│ │
└─────────────────────────────────────────────────────────────────┘
1.3 Domain Event란?
┌─────────────────────────────────────────────────────────────────┐
│ Domain Event = 도메인에서 일어난 사실 │
│ │
│ 정의: │
│ "비즈니스 도메인에서 중요하게 일어난 사실(fact)" │
│ ─ Eric Evans (DDD 창시자) │
│ │
│ 핵심 특성: │
│ ├── 과거 시제: 이미 일어난 일 (OrderPlaced, PaymentCompleted) │
│ ├── 불변(immutable): 발생한 사실은 변경 불가 │
│ ├── 비즈니스 언어: 기술 용어가 아닌 도메인 전문가의 언어 │
│ └── 발행 후 잊기: Publisher는 누가 구독하는지 모름 │
│ │
│ 네이밍 규칙: │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ ✅ 올바른 예시 (과거 시제, 완료된 사실): │ │
│ │ ├── OrderPlaced (주문 생성됨) │ │
│ │ ├── PaymentCompleted (결제 완료됨) │ │
│ │ ├── StockReserved (재고 예약됨) │ │
│ │ └── OrderCancelled (주문 취소됨) │ │
│ │ │ │
│ │ ❌ 잘못된 예시: │ │
│ │ ├── CreateOrder (명령형 → Command이지 Event 아님)│ │
│ │ ├── OrderUpdate (너무 모호) │ │
│ │ └── ProcessPayment (Command 네이밍) │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ 참고: docs/backend/cqrs-event-sourcing-패턴.md │
│ │
└─────────────────────────────────────────────────────────────────┘
2. 등장 배경과 이유
2.1 왜 2PC(Two-Phase Commit)로는 안 되는가?
┌─────────────────────────────────────────────────────────────────┐
│ 2PC가 마이크로서비스에서 실용적이지 않은 이유 │
│ │
│ 2PC 동작 방식: │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ [Coordinator] │ │
│ │ │ │ │
│ │ Phase 1 (Prepare): │ │
│ │ ├──→ DB: "커밋 가능?" ──→ "READY" │ │
│ │ └──→ Broker: "커밋 가능?" ──→ "READY" │ │
│ │ │ │ │
│ │ Phase 2 (Commit): │ │
│ │ ├──→ DB: "COMMIT!" │ │
│ │ └──→ Broker: "COMMIT!" │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ 왜 마이크로서비스에서 기피하는가? │
│ │
│ 1. Blocking 특성 │
│ ├── Coordinator가 모든 참여자 응답을 기다리는 동안 │
│ └── 리소스(lock)가 잠겨있음 → 처리량 급감 │
│ │
│ 2. 단일 장애점 (Single Point of Failure) │
│ ├── Coordinator 장애 → 모든 참여자가 불확실한 상태 │
│ └── in-doubt transaction 발생 │
│ │
│ 3. 확장성 한계 │
│ ├── 참여 서비스 증가 → 전체 처리량 감소 │
│ └── 가장 느린 참여자가 전체 병목 │
│ │
│ 4. 인프라 비호환 │
│ ├── Kafka는 XA 트랜잭션을 지원하지 않음 │
│ └── 대부분의 현대적 브로커가 XA 미지원/비권장 │
│ │
│ 5. CAP 정리 관점 │
│ ├── 2PC는 Consistency를 위해 Availability를 희생 │
│ └── 분산 시스템의 현실(Partition은 불가피)과 맞지 않음 │
│ │
│ 결론: │
│ "대규모 분산 시스템에서 분산 트랜잭션은 불가능하다" │
│ ─ Pat Helland, "Life beyond Distributed Transactions" (2007) │
│ │
│ 참고: docs/backend/보상트랜잭션-saga.md │
│ │
└─────────────────────────────────────────────────────────────────┘
2.2 세 가지 지적 계보의 수렴
┌─────────────────────────────────────────────────────────────────┐
│ Domain Event + Outbox = 세 계보의 수렴 │
│ │
│ 계보 1: DDD (Domain-Driven Design) │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ 2003 Eric Evans, DDD Blue Book 출판 │ │
│ │ (Domain Event는 미포함!) │ │
│ │ 2008 Udi Dahan, "Domain Events – Take 2" │ │
│ │ (구체적 구현 패턴 제시) │ │
│ │ 2009 Eric Evans, Domain Event를 공식 추가 │ │
│ │ "DDD에서 누락된 빌딩 블록(missing building block)"│ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ 계보 2: 분산 시스템 이론 │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ 2000 Eric Brewer, CAP Theorem 발표 │ │
│ │ 2007 Pat Helland, "Life beyond Distributed │ │
│ │ Transactions" ─ 분산 트랜잭션 없이 │ │
│ │ 로컬 원자성 + at-least-once 메시지로 해결 │ │
│ │ 2008 Dan Pritchett (eBay), BASE 개념 공식화 │ │
│ │ Basically Available, Soft state, Eventually │ │
│ │ consistent │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ 계보 3: 도구와 패턴 문서화 │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ 2016 Randall Hauch (Red Hat), Debezium 프로젝트 창시 │ │
│ │ Transaction Log Tailing의 프로덕션 구현 │ │
│ │ 2017 Chris Richardson, microservices.io에 │ │
│ │ Transactional Outbox 패턴 공식 등재 │ │
│ │ 2018 "Microservices Patterns" 책 출판 │ │
│ │ Eventuate Tram 프레임워크 공개 │ │
│ │ 2019 Gunnar Morling, Debezium Outbox Event Router │ │
│ │ CDC + Outbox 조합의 레퍼런스 아키텍처 확립 │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ 이 세 계보가 만나는 지점이 오늘날의 │
│ Domain Event + Transactional Outbox + CDC(Debezium) 스택 │
│ │
└─────────────────────────────────────────────────────────────────┘
3. 용어 사전
┌─────────────────────────────────────────────────────────────────┐
│ 용어 사전 │
│ │
│ Domain Event (도메인 이벤트) │
│ ├── DDD 맥락에서 "비즈니스 영역에서 중요하게 일어난 사실" │
│ ├── "Domain" = 비즈니스 문제 공간 (기술 공간이 아님) │
│ ├── "Event" = 이미 일어난 사실, 과거형으로 명명 │
│ └── 누가: Eric Evans(2003 내포) → Udi Dahan(2008 구체화) │
│ → Evans(2009 공식 추가) │
│ │
│ Outbox Pattern (아웃박스 패턴) │
│ ├── 이메일 "발신함(Outbox)" 메타포에서 유래 │
│ ├── 보내고 싶지만 아직 전송되지 않은 메시지를 보관하는 곳 │
│ └── "Transactional" = 비즈니스 변경과 동일 DB 트랜잭션 │
│ │
│ CDC (Change Data Capture, 변경 데이터 캡처) │
│ ├── DB의 INSERT/UPDATE/DELETE 변경을 포착하여 전달 │
│ ├── 원래 데이터 웨어하우스 ETL에서 기원 │
│ └── Oracle 9i에서 최초 도입 → 10g에서 비동기 방식 발전 │
│ │
│ Debezium (디비지움, "dee-BEE-zee-uhm") │
│ ├── "DBs" + "-ium" = 데이터베이스들의 원소 │
│ │ (주기율표 원소 이름 접미사: sodium, helium처럼) │
│ ├── Red Hat의 Randall Hauch가 2016년 창시 │
│ └── Martin Kleppmann의 "Turning the DB Inside Out"에서 영감 │
│ │
│ Polling Publisher (폴링 퍼블리셔) │
│ ├── 주기적으로 Outbox 테이블을 SELECT하여 │
│ └── 미발행 메시지를 브로커에 발행하는 컴포넌트 │
│ │
│ Transaction Log Tailing (트랜잭션 로그 테일링) │
│ ├── Unix의 `tail -f`처럼 DB 트랜잭션 로그를 │
│ │ 실시간으로 따라가며 읽는 방식 │
│ ├── PostgreSQL: WAL (Write-Ahead Log) │
│ ├── MySQL: binlog (Binary Log) │
│ └── MongoDB: oplog (Operations Log) │
│ │
│ Inbox Pattern (인박스 패턴) │
│ ├── Outbox의 반대 방향: 수신 측에서 적용 │
│ ├── 수신 메시지를 Inbox 테이블에 저장하여 중복 방지 │
│ └── Idempotent Consumer의 구현 메커니즘 │
│ │
│ Idempotent Consumer (멱등성 소비자) │
│ ├── "Idempotent" = 라틴어 idem(같은) + potens(힘) │
│ │ 1870년 수학자 Benjamin Peirce가 최초 사용 │
│ ├── f(f(x)) = f(x) — 여러 번 적용해도 결과가 동일 │
│ └── 메시지를 여러 번 처리해도 결과가 한 번과 동일한 Consumer │
│ │
│ CloudEvents │
│ ├── CNCF가 관리하는 이벤트 메타데이터 표준 스펙 │
│ └── specversion, id, source, type 등 필수 attribute 정의 │
│ │
└─────────────────────────────────────────────────────────────────┘
4. 진화 타임라인
┌─────────────────────────────────────────────────────────────────┐
│ 진화 연대표 │
│ │
│ 2000 ──── CAP Theorem 발표 (Eric Brewer) │
│ 분산 시스템에서 C,A,P 동시 보장 불가 증명 │
│ → Eventually Consistent 패턴의 이론적 토대 │
│ │
│ 2003 ──── DDD Blue Book 출판 (Eric Evans) │
│ Aggregate, Bounded Context 정립 │
│ ※ Domain Event는 미포함 │
│ │
│ 2005 ──── Event Sourcing 패턴 명명 (Martin Fowler) │
│ 상태 변경을 이벤트 시퀀스로 저장 │
│ │
│ 2007 ──── "Life beyond Distributed Transactions" (Pat Helland)│
│ 분산 트랜잭션 없이 Entity + Message로 해결 │
│ → Outbox 패턴의 철학적 토대 │
│ │
│ 2008 ──── BASE 개념 공식화 (Dan Pritchett, eBay) │
│ Udi Dahan, "Domain Events – Take 2" │
│ │
│ 2009 ──── Domain Events 명시적 패턴화 (Udi Dahan) │
│ Eric Evans, Domain Event를 DDD 공식 패턴으로 추가 │
│ │
│ 2010 ──── CQRS 공식 정의 (Greg Young) │
│ 이벤트의 과거 시제 네이밍 관례 확립 │
│ │
│ 2011 ──── Apache Kafka 오픈소스 공개 (LinkedIn) │
│ 고처리량 Distributed Commit Log │
│ │
│ 2014 ──── 마이크로서비스 아키텍처 대중화 │
│ (Fowler & Lewis) → Dual Write 문제 부상 │
│ │
│ 2016 ──── Debezium 프로젝트 시작 (Randall Hauch, Red Hat) │
│ Kafka Connect 기반 CDC 플랫폼 │
│ │
│ 2017 ──── Transactional Outbox 패턴 공식 등재 │
│ (Chris Richardson, microservices.io) │
│ │
│ 2018 ──── "Microservices Patterns" 출판 (Chris Richardson) │
│ Eventuate Tram 프레임워크 공개 │
│ │
│ 2019 ──── CDC + Outbox 레퍼런스 아키텍처 확립 │
│ (Gunnar Morling, Debezium Blog) │
│ Debezium Outbox Event Router SMT 내장 │
│ │
│ 2020 ──── Debezium Outbox Quarkus Extension │
│ Cloud-Native CDC 서비스 성숙 │
│ │
│ 2021+ ──── AWS/GCP/Azure에서 공식 클라우드 패턴으로 채택 │
│ Serverless-Native Outbox 등장 │
│ │
│ 현재 ──── Debezium 3.x, Managed CDC 서비스 보편화 │
│ CloudEvents 스펙 표준화 진행 │
│ │
└─────────────────────────────────────────────────────────────────┘
5. 구현 변형들
5.1 Transactional Outbox + Polling Publisher
┌─────────────────────────────────────────────────────────────────┐
│ 변형 A: Outbox + Polling Publisher │
│ │
│ [데이터 흐름] │
│ │
│ Application Service │
│ │ │
│ │ (single DB transaction) │
│ ▼ │
│ ┌──────────────────────────┐ │
│ │ Database │ │
│ │ ├── business_table │ ← 비즈니스 데이터 │
│ │ └── outbox_events │ ← 동일 트랜잭션으로 INSERT │
│ └───────────┬──────────────┘ │
│ │ │
│ │ SELECT FOR UPDATE SKIP LOCKED (주기적 폴링) │
│ ▼ │
│ ┌──────────────────────────┐ │
│ │ Polling Publisher │ ← 별도 프로세스/스레드 │
│ │ (Message Relay) │ │
│ └───────────┬──────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────────────┐ │
│ │ Message Broker │ │
│ │ (Kafka / RabbitMQ) │ │
│ └──────────────────────────┘ │
│ │
│ Outbox 테이블 스키마: │
│ ┌────────────────┬──────────────┬─────────────────────────┐ │
│ │ 컬럼 │ 타입 │ 설명 │ │
│ ├────────────────┼──────────────┼─────────────────────────┤ │
│ │ id │ UUID (PK) │ 중복 감지용 고유 ID │ │
│ │ aggregate_type │ VARCHAR(255) │ 라우팅 키 (e.g. "Order")│ │
│ │ aggregate_id │ VARCHAR(255) │ Kafka partition key │ │
│ │ type │ VARCHAR(255) │ 이벤트 타입 │ │
│ │ payload │ JSONB │ 이벤트 데이터 │ │
│ │ created_at │ TIMESTAMPTZ │ 생성 시각 │ │
│ │ published_at │ TIMESTAMPTZ │ 발행 시각 (NULL=미발행) │ │
│ └────────────────┴──────────────┴─────────────────────────┘ │
│ │
│ Polling 권장 설정: │
│ ├── Poll Interval: 1~2초 │
│ ├── Batch Size: 100~500건 │
│ ├── 순서: ORDER BY created_at ASC │
│ └── Lock: FOR UPDATE SKIP LOCKED (다중 인스턴스 안전) │
│ │
│ 장점: │
│ ├── 구현 단순: 추가 인프라(CDC 등) 불필요 │
│ ├── DB 무관: 모든 ACID 지원 DB에서 작동 │
│ └── 디버깅 용이: Outbox 테이블 직접 조회 가능 │
│ │
│ 단점: │
│ ├── DB 폴링 부하: 주기적 SELECT가 DB에 지속 부하 │
│ ├── 레이턴시: Poll Interval만큼의 지연 (최소 1~2초) │
│ └── 테이블 증가: 발행 완료 레코드의 주기적 정리 필요 │
│ │
└─────────────────────────────────────────────────────────────────┘
5.2 Transactional Outbox + CDC (Debezium)
┌─────────────────────────────────────────────────────────────────┐
│ 변형 B: Outbox + CDC (Transaction Log Tailing) │
│ │
│ [데이터 흐름] │
│ │
│ Application Service │
│ │ │
│ │ (single DB transaction) │
│ ▼ │
│ ┌──────────────────────────┐ │
│ │ Database │ │
│ │ ├── business_table │ │
│ │ └── outbox_events │ │
│ └───────────┬──────────────┘ │
│ │ │
│ │ WAL / binlog / oplog 읽기 │
│ ▼ │
│ ┌──────────────────────────────────────────┐ │
│ │ Debezium Source Connector │ │
│ │ (Kafka Connect Worker에서 실행) │ │
│ │ │ │
│ │ ┌──────────────────────────────────┐ │ │
│ │ │ Outbox Event Router SMT │ │ │
│ │ │ ├── aggregate_type → 토픽 결정 │ │ │
│ │ │ ├── aggregate_id → 메시지 key │ │ │
│ │ │ └── payload → 메시지 value │ │ │
│ │ └──────────────────────────────────┘ │ │
│ └───────────┬──────────────────────────────┘ │
│ ▼ │
│ ┌──────────────────────────┐ │
│ │ Apache Kafka │ │
│ │ ├── outbox.event.Order │ │
│ │ ├── outbox.event.User │ │
│ │ └── outbox.event.Payment│ │
│ └──────────────────────────┘ │
│ │
│ DB별 CDC 방식: │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ PostgreSQL: WAL (Write-Ahead Log) │ │
│ │ ├── Logical Decoding으로 row-level 변경 포착 │ │
│ │ ├── 필수: wal_level = logical │ │
│ │ └── 주의: Replication Slot이 WAL 보관 강제 │ │
│ │ │ │
│ │ MySQL: binlog (Binary Log) │ │
│ │ ├── 필수: binlog_format = ROW, binlog_row_image = FULL │ │
│ │ └── GTID 활성화 시 장애 복구가 더 안정적 │ │
│ │ │ │
│ │ MongoDB: oplog (Operations Log) │ │
│ │ ├── Replica Set에서만 사용 가능 │ │
│ │ └── Change Streams API (MongoDB 3.6+) 기반 │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ Clever Delete 기법 (Debezium 공식 권장): │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ BEGIN; │ │
│ │ INSERT INTO outbox_events (...) VALUES (...); │ │
│ │ DELETE FROM outbox_events WHERE id = ?; │ │
│ │ COMMIT; │ │
│ │ │ │
│ │ → WAL에 INSERT가 기록됨 → Debezium이 캡처 │ │
│ │ → 테이블은 항상 비어있음 → Cleanup 불필요! │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ 장점: │
│ ├── 낮은 레이턴시: commit 직후 수십 ms 내 전달 │
│ ├── DB 폴링 부하 없음: 추가 쿼리 부하 없음 │
│ ├── 강한 순서 보장: 커밋 순서 그대로 전달 │
│ └── Application 코드 분리: 발행 로직이 코드에서 완전 분리 │
│ │
│ 단점: │
│ ├── 인프라 복잡도: Kafka + Kafka Connect + Debezium 운영 │
│ ├── DB 설정 변경: WAL level 변경 등 DBA 협력 필요 │
│ └── Replication Slot 위험: 미처리 시 디스크 고갈 │
│ │
└─────────────────────────────────────────────────────────────────┘
5.3 Event Sourcing
┌─────────────────────────────────────────────────────────────────┐
│ 변형 C: Event Sourcing │
│ │
│ [핵심 아이디어] │
│ 이벤트 자체가 원본(Source of Truth) │
│ → Event Store가 곧 Outbox 역할 │
│ → Dual Write 문제가 근본적으로 해소 │
│ │
│ [일반 Outbox vs Event Sourcing 비교] │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ 일반 Outbox: │ │
│ │ DB(현재 상태) + Outbox 테이블 = 2개 저장소 동기화 필요 │ │
│ │ │ │
│ │ Event Sourcing: │ │
│ │ Event Store(이벤트 스트림) = 유일한 원본 │ │
│ │ 현재 상태 = Event 재생으로 도출 (파생값) │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ [데이터 흐름] │
│ │
│ Command ──→ Aggregate ──→ Domain Events │
│ │ │
│ ▼ │
│ ┌──────────────┐ │
│ │ Event Store │ ← append-only │
│ └──────┬───────┘ │
│ │ │
│ ┌────────────┼────────────┐ │
│ ▼ ▼ ▼ │
│ Projection Event Bus Subscription │
│ (Read Model) (다른 서비스)(실시간 구독) │
│ │
│ 장점: │
│ ├── 완전한 감사 추적 (Audit Trail) │
│ ├── 시간 여행 (Temporal Query) 가능 │
│ └── Outbox 문제 근본 해결 │
│ │
│ 단점: │
│ ├── 높은 학습 곡선 │
│ ├── 단순 CRUD에는 과도한 복잡성 │
│ └── 이벤트 스키마 진화 어려움 (upcasting 전략 필요) │
│ │
│ 참고: docs/backend/cqrs-event-sourcing-패턴.md │
│ │
└─────────────────────────────────────────────────────────────────┘
5.4 Listen to Yourself 패턴
┌─────────────────────────────────────────────────────────────────┐
│ 변형 D: Listen to Yourself │
│ │
│ [핵심 아이디어] │
│ DB에 직접 쓰지 않고, Message Broker에만 쓴다. │
│ 자기 자신도 그 메시지를 구독해서 DB에 반영한다. │
│ │
│ [데이터 흐름] │
│ │
│ Client Request │
│ │ │
│ ▼ │
│ Service A │
│ │ │
│ │ Kafka에만 이벤트 발행 (DB write 안 함!) │
│ ▼ │
│ ┌──────────────────────────┐ │
│ │ Message Broker (Kafka) │ │
│ └──┬──────────────────┬────┘ │
│ │ │ │
│ ▼ ▼ │
│ Service B Service A (자기 자신!) │
│ (다른 서비스) consume → 자신의 DB에 Write │
│ │
│ 왜 일관성이 유지되는가? │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ Broker 발행 성공 → 자신의 consume → DB write 발생 │ │
│ │ Broker 발행 실패 → DB write도 발생 안 함 (일관!) │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ 장점: │
│ ├── 추가 인프라 불필요 (Outbox 테이블, CDC 없음) │
│ ├── 매우 낮은 응답 지연 (DB commit 대기 없음) │
│ └── 자연스러운 eventual consistency │
│ │
│ 단점: │
│ ├── Read-Your-Own-Writes 불가 (쓰기 직후 읽으면 미반영) │
│ ├── Broker 가용성에 전적으로 의존 │
│ └── 개발팀 전체의 Event-Driven 사고방식 전환 필요 │
│ │
└─────────────────────────────────────────────────────────────────┘
5.5 Inbox Pattern (수신 측 보완)
┌─────────────────────────────────────────────────────────────────┐
│ 변형 E: Inbox Pattern │
│ │
│ [문제] │
│ Outbox 패턴은 at-least-once delivery를 보장한다. │
│ → 동일 이벤트가 중복 전송될 수 있다! │
│ → Consumer 측에서 중복 처리를 방지해야 한다. │
│ │
│ [Inbox 패턴의 해결] │
│ │
│ Message Broker ──→ Consumer Service │
│ │ │
│ ▼ │
│ ┌───────────────┐ │
│ │ Inbox 테이블 │ message_id로 중복 체크 │
│ │ (동일 트랜잭션)│ │
│ └───────┬───────┘ │
│ │ 중복 아닌 경우만 │
│ ▼ │
│ 비즈니스 로직 실행 │
│ │
│ Outbox + Inbox 조합 = End-to-End 신뢰성: │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ [Producer] [Broker] [Consumer] │ │
│ │ DB + Outbox ──→ At-Least-Once ──→ Inbox + DB │ │
│ │ (발행 누락 없음) (중복 처리 없음) │ │
│ │ │ │
│ │ 결과: Exactly-Once Semantics에 근사한 처리 │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
5.6 Saga + Outbox 결합
┌─────────────────────────────────────────────────────────────────┐
│ 변형 F: Saga + Outbox │
│ │
│ Saga는 여러 서비스에 걸친 장기 트랜잭션을 │
│ 로컬 트랜잭션 시퀀스로 구현하는 패턴이다. │
│ Outbox는 각 로컬 트랜잭션의 이벤트를 신뢰성 있게 전달한다. │
│ │
│ [Choreography Saga + Outbox 흐름] │
│ │
│ Order Service │
│ BEGIN; │
│ INSERT INTO orders (status='PENDING'); │
│ INSERT INTO outbox (type='OrderCreated'); │
│ COMMIT; │
│ │ CDC / Polling │
│ ▼ │
│ Kafka ──→ Payment Service │
│ BEGIN; │
│ INSERT INTO payments (...); │
│ INSERT INTO outbox (type='PaymentProcessed'); │
│ COMMIT; │
│ │ │
│ ▼ (실패 시) │
│ Kafka ──→ Order Service │
│ BEGIN; │
│ UPDATE orders SET status='CANCELLED'; │
│ INSERT INTO outbox (type='OrderCancelled'); │
│ COMMIT; ← 보상 트랜잭션 │
│ │
│ 참고: docs/backend/보상트랜잭션-saga.md │
│ │
└─────────────────────────────────────────────────────────────────┘
6. 대안 비교표
6.1 이벤트 발행 보장 방식 비교
┌─────────────────────────────────────────────────────────────────────────────────┐
│ 이벤트 발행 보장 방식 비교표 │
│ │
│ ┌──────────────┬──────┬──────┬──────────┬──────────┬──────┬─────────┐ │
│ │ 방식 │원자성│순서 │지연시간 │운영복잡도│DB부하│적합 규모│ │
│ ├──────────────┼──────┼──────┼──────────┼──────────┼──────┼─────────┤ │
│ │Outbox+Polling│ 높음 │ 중간 │1~수초 │ 중간 │ 높음 │소~중규모│ │
│ │Outbox+CDC │ 높음 │ 높음 │수십ms │ 높음 │ 낮음 │중~대규모│ │
│ │Event Sourcing│ 높음 │매우↑│수십ms │매우 높음 │ 없음 │대규모 │ │
│ │Listen-to-Self│ 중간 │ 높음 │매우 낮음 │ 중간 │ 낮음 │중~대규모│ │
│ │2PC/XA │매우↑│ 높음 │매우 높음 │매우 높음 │매우↑│비권장 │ │
│ │Dual Write │ 없음 │ 없음 │ 낮음 │ 낮음 │ 없음 │비권장 │ │
│ └──────────────┴──────┴──────┴──────────┴──────────┴──────┴─────────┘ │
│ │
│ 가장 흔한 프로덕션 조합: │
│ Transactional Outbox + Debezium + Apache Kafka │
│ = 낮은 DB 부하 + 수십 ms 지연 + 강한 순서 보장 + replay 가능 │
│ │
└─────────────────────────────────────────────────────────────────────────────────┘
6.2 CDC 도구 비교
┌─────────────────────────────────────────────────────────────────────────────────┐
│ CDC 도구 비교표 │
│ │
│ ┌──────────────┬──────────────────┬──────────┬──────────┬─────────────────┐ │
│ │ 도구 │ 지원 DB │ 처리량 │운영편의성│ 비고 │ │
│ ├──────────────┼──────────────────┼──────────┼──────────┼─────────────────┤ │
│ │ Debezium │ MySQL, PG, Mongo │ 높음 │ 중간 │업계 사실상 표준 │ │
│ │ │ SQL Server, Oracle│ │ │Red Hat 지원 │ │
│ │ │ DB2, Cassandra │ │ │ │ │
│ ├──────────────┼──────────────────┼──────────┼──────────┼─────────────────┤ │
│ │ Maxwell │ MySQL 전용 │ 중간 │ 높음 │경량, 단순 │ │
│ ├──────────────┼──────────────────┼──────────┼──────────┼─────────────────┤ │
│ │ Canal │ MySQL 전용 │ 매우 높음│ 중간 │Alibaba 개발 │ │
│ │ │ │(60만+TPS)│ │중국 커뮤니티 │ │
│ ├──────────────┼──────────────────┼──────────┼──────────┼─────────────────┤ │
│ │ AWS DMS │ 다수 │ 중간 │ 매우 높음│AWS 전용 │ │
│ ├──────────────┼──────────────────┼──────────┼──────────┼─────────────────┤ │
│ │ GCP Datastream│MySQL, PG, Oracle│ 높음 │ 매우 높음│GCP 전용 │ │
│ ├──────────────┼──────────────────┼──────────┼──────────┼─────────────────┤ │
│ │ Azure CDC │ SQL Server │ 중간 │ 높음 │Azure 전용 │ │
│ └──────────────┴──────────────────┴──────────┴──────────┴─────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────────┘
6.3 Message Broker 비교 (Outbox와 함께 쓸 때)
┌─────────────────────────────────────────────────────────────────────────────────┐
│ Message Broker 비교 (Outbox 패턴 궁합) │
│ │
│ ┌──────────────┬──────────┬──────────┬──────────┬─────────────────────────┐ │
│ │ Broker │ 궁합 │ 순서보장 │ 처리량 │ 비고 │ │
│ ├──────────────┼──────────┼──────────┼──────────┼─────────────────────────┤ │
│ │ Apache Kafka │ 매우 좋음│ Partition│ 수백만/s │ Debezium과 최적 조합 │ │
│ │ │(표준조합)│ 내 보장 │ │ replay 가능 │ │
│ ├──────────────┼──────────┼──────────┼──────────┼─────────────────────────┤ │
│ │ RabbitMQ │ 좋음 │ 단일큐내 │ 수만/s │ 경량, replay 불가 │ │
│ ├──────────────┼──────────┼──────────┼──────────┼─────────────────────────┤ │
│ │ AWS SQS/SNS │ 좋음 │ FIFO만 │ 수천~수만│ 완전 관리형 │ │
│ ├──────────────┼──────────┼──────────┼──────────┼─────────────────────────┤ │
│ │ NATS JetStream│좋음 │ 제한적 │ 수백만/s │ 경량, 낮은 운영 비용 │ │
│ ├──────────────┼──────────┼──────────┼──────────┼─────────────────────────┤ │
│ │ Apache Pulsar│ 중간 │ Partition│ 높음 │ 멀티 테넌시 강점 │ │
│ │ │ │ 내 보장 │ │ Outbox 성숙도 낮음 │ │
│ └──────────────┴──────────┴──────────┴──────────┴─────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────────┘
7. 상황별 최적 선택
┌─────────────────────────────────────────────────────────────────┐
│ 상황별 최적 선택 가이드 │
│ │
│ [의사결정 트리] │
│ │
│ Q1. 지연시간이 100ms 이하여야 하는가? │
│ ├── Yes → CDC + Kafka 또는 Listen to Yourself │
│ └── No → Q2로 │
│ │
│ Q2. 강한 순서 보장이 필요한가? │
│ ├── Yes → CDC + Kafka (partition key = aggregate ID) │
│ └── No → Q3로 │
│ │
│ Q3. 이미 Kafka가 있는가? │
│ ├── Yes → Outbox + Debezium (바로 적용 가능) │
│ └── No → Q4로 │
│ │
│ Q4. 소규모 팀, 브로커 없음? │
│ ├── Yes → Outbox + Polling Publisher (단순 시작) │
│ └── No → Q5로 │
│ │
│ Q5. AWS/GCP/Azure 올인? │
│ ├── Yes → 해당 클라우드의 관리형 CDC │
│ └── No → Q6로 │
│ │
│ Q6. 이벤트 이력이 핵심 비즈니스 자산인가? │
│ ├── Yes → Event Sourcing (복잡도 감수) │
│ └── No → Polling 시작 → 추후 CDC 마이그레이션 │
│ │
│ │
│ [상황별 정리표] │
│ │
│ ┌─────────────────────────┬────────────────────────────────┐ │
│ │ 상황 │ 권장 방식 │ │
│ ├─────────────────────────┼────────────────────────────────┤ │
│ │ 소규모 팀/단일 서비스 │ Outbox + Polling Publisher │ │
│ │ 대규모(10+서비스) │ Outbox + CDC (Debezium+Kafka) │ │
│ │ 이미 Kafka 사용 중 │ Outbox + Debezium │ │
│ │ 레거시 DB, 브로커 없음 │ Polling 시작 → CDC 전환 │ │
│ │ 강한 순서 보장 │ CDC + Kafka (partition key) │ │
│ │ 낮은 지연시간 │ Listen to Yourself 또는 CDC │ │
│ │ 감사 이력 필수 │ Event Sourcing + CQRS │ │
│ │ 멀티서비스 트랜잭션 │ Saga + Outbox │ │
│ └─────────────────────────┴────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
8. 성능 벤치마크
┌─────────────────────────────────────────────────────────────────┐
│ 성능 벤치마크 │
│ │
│ [Polling Publisher 지연시간] │
│ ┌──────────────┬──────────────┬───────────────────────────┐ │
│ │ Poll Interval│ 최대 지연 │ 비고 │ │
│ ├──────────────┼──────────────┼───────────────────────────┤ │
│ │ 100ms │ ~100ms+α │ 적극적, DB 부하 증가 │ │
│ │ 500ms │ ~500ms+α │ 일반적 설정 │ │
│ │ 1초 │ ~1초+α │ 보수적 설정 │ │
│ │ 5초 │ ~5초+α │ 지연 허용 시나리오 │ │
│ └──────────────┴──────────────┴───────────────────────────┘ │
│ → Poll Interval이 지연시간의 절대적 하한선(latency floor) │
│ │
│ [CDC (Debezium) 지연시간] │
│ ┌─────────────────────────┬────────────────────────────────┐ │
│ │ 환경 │ 지연시간 │ │
│ ├─────────────────────────┼────────────────────────────────┤ │
│ │ PostgreSQL → Kafka │ 수십 ms (20~99ms) │ │
│ │ MySQL → Kafka │ ms 단위 │ │
│ │ AWS MSK Connect(6k ops) │ 평균 258ms, P99 498.7ms │ │
│ └─────────────────────────┴────────────────────────────────┘ │
│ → Polling 대비 10~100배 낮은 지연 가능 │
│ │
│ [Debezium 처리량] │
│ ┌──────────────────────────┬───────────────────────────────┐ │
│ │ 환경 │ 처리량 │ │
│ ├──────────────────────────┼───────────────────────────────┤ │
│ │ AWS MSK Connect │ ~6,000 ops/sec │ │
│ │ PostgreSQL (protobuf) │ ~174,000 events/sec │ │
│ │ Canal (MySQL binlog) │ 600K ~ 2M TPS │ │
│ └──────────────────────────┴───────────────────────────────┘ │
│ │
│ [Outbox 테이블의 DB 성능 영향] │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ 추가 INSERT: 중간 (트랜잭션당 1회 추가) │ │
│ │ Polling 쿼리: 높음 (서비스 수 × 빈도만큼 누적) │ │
│ │ 인덱스 비용: 중간 (published/unpublished 인덱스) │ │
│ │ 테이블 증가: 높음 (cleanup 없으면 지속 증가) │ │
│ │ CDC 방식 시: 매우 낮음 (DB compute에 거의 영향 없음) │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
9. 실전 베스트 프랙티스
9.1 Outbox 테이블 설계
┌─────────────────────────────────────────────────────────────────┐
│ Outbox 테이블 설계 베스트 프랙티스 │
│ │
│ [최소 권장 스키마 (Debezium Outbox Event Router 호환)] │
│ │
│ CREATE TABLE outbox_events ( │
│ id UUID PK DEFAULT gen_random_uuid(),│
│ aggregate_type VARCHAR(255) NOT NULL, │
│ aggregate_id VARCHAR(255) NOT NULL, │
│ type VARCHAR(255) NOT NULL, │
│ payload JSONB NOT NULL, │
│ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), │
│ trace_id VARCHAR(255) NULL -- OpenTelemetry 연동 │
│ ); │
│ │
│ [컬럼별 역할] │
│ ┌────────────────┬─────────────────────────────────────────┐ │
│ │ aggregate_type │ Debezium SMT 토픽 라우팅 기준 │ │
│ │ │ "Order" → outbox.event.Order 토픽 │ │
│ ├────────────────┼─────────────────────────────────────────┤ │
│ │ aggregate_id │ Kafka message key → 동일 aggregate │ │
│ │ │ 이벤트의 파티션 순서 보장 │ │
│ ├────────────────┼─────────────────────────────────────────┤ │
│ │ payload (JSONB)│ 유연한 스키마 변경 지원 │ │
│ │ │ 1KB~10KB 이하 권장, 대용량은 reference │ │
│ └────────────────┴─────────────────────────────────────────┘ │
│ │
│ [인덱스 전략] │
│ ├── CDC 방식: id (PK)만으로 충분 │
│ └── Polling 방식: │
│ CREATE INDEX idx_outbox_unpublished │
│ ON outbox_events (created_at ASC) │
│ WHERE published_at IS NULL; -- partial index │
│ │
│ [파티셔닝 (대규모 환경)] │
│ ├── PARTITION BY RANGE (created_at) -- 날짜 기반 │
│ ├── 오래된 파티션은 DROP/DETACH (DELETE보다 10~100x 빠름) │
│ └── PostgreSQL CDC 시: publish_via_partition_root = true 필수 │
│ │
│ [Cleanup 전략] │
│ ├── 파티셔닝 + 파티션 DROP (대규모 권장) │
│ ├── 배치 DELETE (소규모) │
│ ├── Clever Delete (CDC: INSERT 즉시 DELETE, 테이블 항상 비움) │
│ └── 보존 기간: 최소 7~10일 권장 │
│ │
└─────────────────────────────────────────────────────────────────┘
9.2 Debezium 운영
┌─────────────────────────────────────────────────────────────────┐
│ Debezium 운영 베스트 프랙티스 │
│ │
│ [핵심 Connector 설정] │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ plugin.name: pgoutput │ │
│ │ slot.name: debezium_outbox_slot │ │
│ │ table.include.list: public.outbox_events │ │
│ │ snapshot.mode: never │ │
│ │ heartbeat.interval.ms: 10000 │ │
│ │ transforms: outbox (EventRouter SMT) │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ [Outbox Event Router SMT 동작] │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ Raw CDC Event Kafka Message │ │
│ │ { "after": { → Topic: outbox.event. │ │
│ │ "aggregatetype": "Order", Order │ │
│ │ "aggregateid": "123", Key: "123" │ │
│ │ "payload": "{...}" Value: {...} │ │
│ │ } │ │
│ │ } │ │
│ │ │ │
│ │ INSERT → 이벤트 추출/라우팅 │ │
│ │ DELETE → 폐기 (무시) │ │
│ │ UPDATE → 경고/에러 │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ [PostgreSQL Replication Slot 관리 — 가장 중요!] │
│ │
│ ⚠️ Slot은 처리하지 못한 WAL을 보관하도록 강제한다. │
│ ⚠️ Debezium 장애 시 WAL이 무한 축적 → 디스크 고갈! │
│ │
│ 필수 조치: │
│ ├── max_slot_wal_keep_size = 10GB (PostgreSQL 13+) │
│ ├── heartbeat 테이블: LSN을 강제 전진시켜 WAL 축적 방지 │
│ ├── 모니터링 쿼리: │
│ │ SELECT slot_name, │
│ │ pg_size_pretty( │
│ │ pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn) │
│ │ ) AS wal_lag │
│ │ FROM pg_replication_slots; │
│ └── wal_lag > 1GB 시 알람 설정 │
│ │
└─────────────────────────────────────────────────────────────────┘
9.3 이벤트 설계
┌─────────────────────────────────────────────────────────────────┐
│ 이벤트 설계 베스트 프랙티스 │
│ │
│ [CloudEvents 스펙 준수] │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ { │ │
│ │ "specversion": "1.0", │ │
│ │ "id": "550e8400-e29b-41d4-a716-446655440000", │ │
│ │ "source": "https://myservice.example.com/orders", │ │
│ │ "type": "com.example.orders.v1.OrderPlaced", │ │
│ │ "time": "2026-03-17T10:30:00Z", │ │
│ │ "datacontenttype": "application/json", │ │
│ │ "data": { │ │
│ │ "orderId": "ord-12345", │ │
│ │ "customerId": "cust-67890", │ │
│ │ "totalAmount": 150000 │ │
│ │ } │ │
│ │ } │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ [스키마 진화 (Schema Evolution) 규칙] │
│ ├── ❌ 기존 필드 타입 변경 금지 │
│ ├── ❌ 기존 필드 이름 변경 금지 │
│ ├── ❌ required 필드 삭제 금지 │
│ ├── ✅ 새 필드 추가 시 default value 필수 설정 │
│ └── ✅ 호환성 파괴 변경 시 type 버전을 올려 별도 이벤트로 처리 │
│ │
│ [Payload 크기 관리] │
│ ┌──────────────┬────────────────────────────────────────┐ │
│ │ Fat Event │ 전체 상태를 payload에 포함 │ │
│ │ │ → Consumer가 추가 조회 없이 처리 가능 │ │
│ ├──────────────┼────────────────────────────────────────┤ │
│ │ Thin Event │ ID만 포함, Consumer가 조회 │ │
│ │ (Reference) │ → 민감 데이터, 대용량 객체에 적합 │ │
│ ├──────────────┼────────────────────────────────────────┤ │
│ │ Hybrid │ 핵심 필드 + aggregate ID │ │
│ │ (권장) │ → 일반적인 권장 방식 │ │
│ └──────────────┴────────────────────────────────────────┘ │
│ Outbox payload 크기: 1KB~10KB 이하 권장 │
│ 대용량 바이너리 → S3 URL을 reference로 포함 (Claim Check 패턴) │
│ │
└─────────────────────────────────────────────────────────────────┘
9.4 코드 구현 예시 (Spring Boot + JPA)
┌─────────────────────────────────────────────────────────────────┐
│ Spring Boot + JPA Outbox 구현 패턴 │
│ │
│ [1. 도메인 서비스 — 같은 트랜잭션에서 비즈니스 + Outbox] │
│ │
│ @Service │
│ public class OrderService { │
│ │
│ @Transactional │
│ public Order placeOrder(PlaceOrderCommand cmd) { │
│ // 비즈니스 로직 │
│ Order order = new Order(cmd.getCustomerId(), ...); │
│ orderRepository.save(order); │
│ │
│ // Outbox에 이벤트 기록 (동일 트랜잭션!) │
│ OutboxEvent event = OutboxEvent.builder() │
│ .aggregateType("Order") │
│ .aggregateId(order.getId().toString()) │
│ .type("OrderPlaced") │
│ .payload(objectMapper.writeValueAsString( │
│ new OrderPlacedEvent(order))) │
│ .build(); │
│ outboxEventRepository.save(event); │
│ │
│ return order; │
│ } │
│ } │
│ │
│ [2. Polling Publisher (CDC 미사용 시)] │
│ │
│ @Scheduled(fixedDelay = 1000) // 1초 간격 │
│ @Transactional │
│ public void pollAndPublish() { │
│ List<OutboxEvent> events = outboxEventRepository │
│ .findUnprocessedWithLock(); // SKIP LOCKED │
│ │
│ for (OutboxEvent event : events) { │
│ String topic = "outbox.event." + │
│ event.getAggregateType(); │
│ kafkaTemplate.send(topic, │
│ event.getAggregateId(), │
│ event.getPayload()).get(5, SECONDS); │
│ event.markAsProcessed(); │
│ } │
│ } │
│ │
│ [3. Idempotent Consumer (Inbox 패턴)] │
│ │
│ @KafkaListener(topics = "outbox.event.Order") │
│ @Transactional │
│ public void handle(ConsumerRecord<String, String> record) { │
│ String eventId = getHeader(record, "id"); │
│ │
│ // 중복 처리 방지 │
│ if (processedEventRepository.existsById(eventId)) { │
│ return; // 이미 처리됨, 스킵 │
│ } │
│ │
│ processEvent(record.value()); │
│ processedEventRepository.save( │
│ new ProcessedEvent(eventId, Instant.now())); │
│ } │
│ │
└─────────────────────────────────────────────────────────────────┘
10. 주요 함정과 안티패턴
┌─────────────────────────────────────────────────────────────────┐
│ 10대 안티패턴 │
│ │
│ 1. Outbox 테이블 무한 성장 │
│ ├── Cleanup 없이 운영 → 수억 건 축적 → 쿼리 성능 급락 │
│ └── 해결: 파티셔닝 + DROP, 또는 Clever Delete (CDC 시) │
│ │
│ 2. 순서 보장 실패 (Partition Key 미설정) │
│ ├── aggregate_id를 Kafka key로 미설정 시 │
│ │ 동일 Order의 이벤트가 서로 다른 partition에 분산 │
│ └── 해결: aggregate_id → Kafka message key 매핑 필수 │
│ │
│ 3. 이벤트 Payload에 민감 정보 포함 │
│ ├── PII, 카드번호 등이 Kafka에 영구 저장 → GDPR 위반 │
│ └── 해결: reference(ID)만 포함, Consumer가 API로 조회 │
│ │
│ 4. 스키마 변경 시 하위 호환성 파괴 │
│ ├── 필드 삭제/타입 변경 → 구버전 Consumer 중단 │
│ └── 해결: 새 필드 추가만 허용, 호환 불가 시 type 버전업 │
│ │
│ 5. Polling Publisher의 "Thundering Herd" │
│ ├── 10개 서비스 × 3 인스턴스 = 초당 60+회 폴링 쿼리 │
│ └── 해결: CDC 전환, 또는 adaptive polling + jitter │
│ │
│ 6. CDC Connector 장애 시 WAL/Binlog 폭증 │
│ ├── Debezium 정지 → PostgreSQL WAL 무한 축적 → 디스크 고갈 │
│ └── 해결: max_slot_wal_keep_size, wal_lag 알람 설정 │
│ │
│ 7. Outbox에 너무 큰 Payload 저장 │
│ ├── 수백 KB payload → DB 부하 + Kafka 크기 제한 초과 │
│ └── 해결: 1~10KB 이하 권장, 대용량은 Claim Check 패턴 │
│ │
│ 8. Consumer 멱등성 미구현 │
│ ├── 중복 이벤트 → 중복 처리 (잔액 2회 차감 등) │
│ └── 해결: Inbox 패턴 (message_id 기반 deduplication) │
│ │
│ 9. 이벤트 발행 순서에 대한 잘못된 가정 │
│ ├── INSERT 순서 ≠ Kafka 도달 순서 (항상은 아님) │
│ └── 해결: 이벤트 내 version/timestamp 사용, partition 보장 │
│ │
│ 10. Outbox를 API로 직접 조회 │
│ ├── Consumer가 Producer의 Outbox 테이블을 직접 쿼리 │
│ │ → 서비스 간 강한 결합, Outbox 패턴의 목적 훼손 │
│ └── 해결: 모든 구독은 반드시 Message Broker를 통해야 함 │
│ │
│ 프로덕션 장애의 70%: │
│ Consumer 멱등성 미구현 + Outbox cleanup 없음 │
│ │
└─────────────────────────────────────────────────────────────────┘
11. 마이그레이션 가이드
11.1 모놀리스 → Outbox 패턴 도입
┌─────────────────────────────────────────────────────────────────┐
│ 모놀리스에서 Outbox 패턴 도입 단계 │
│ │
│ Phase 1: 준비 (1~2주) │
│ ├── 도메인 이벤트 발생 지점 식별 │
│ ├── Outbox 테이블 스키마 설계 및 생성 │
│ ├── 비즈니스 로직에 Outbox 쓰기 코드 추가 │
│ └── Feature flag로 on/off 제어 준비 │
│ │
│ Phase 2: 검증 (2~4주) │
│ ├── Staging에서 Outbox 쓰기 활성화 │
│ ├── 이벤트 기록 검증 │
│ ├── 모니터링 대시보드 구축 │
│ └── Cleanup 스케줄러 검증 │
│ │
│ Phase 3: 발행 (2~4주) │
│ ├── Polling Publisher 구현 │
│ ├── Consumer 이벤트 수신 테스트 │
│ ├── 멱등성 검증 (중복 발행 시뮬레이션) │
│ └── Production 배포 (Feature flag로 점진적 활성화) │
│ │
│ Phase 4: 안정화 │
│ ├── 동기 호출 → 이벤트 기반으로 점진적 대체 │
│ └── CDC 전환 계획 수립 │
│ │
└─────────────────────────────────────────────────────────────────┘
11.2 Polling Publisher → CDC (Debezium) 전환
┌─────────────────────────────────────────────────────────────────┐
│ Polling → CDC 전환 단계 │
│ │
│ Phase 1: 인프라 준비 (1주) │
│ ├── Kafka Connect 클러스터 구성 │
│ ├── Debezium PostgreSQL Connector 설치 │
│ ├── PostgreSQL: wal_level=logical 확인 │
│ └── max_replication_slots, max_wal_senders 확인 │
│ │
│ Phase 2: 스키마 보정 (2~3일) │
│ ├── aggregate_type, aggregate_id 컬럼 확인/추가 │
│ ├── CREATE PUBLICATION outbox_pub FOR TABLE outbox_events; │
│ └── Replication slot 생성 테스트 │
│ │
│ Phase 3: 병렬 운영 (1~2주) ← 핵심 단계! │
│ ├── Debezium Connector 배포 (이벤트 발행 비활성화) │
│ ├── Polling Publisher 계속 운영 (이중 안전망) │
│ ├── Debezium 캡처 이벤트 vs Polling 이벤트 비교 검증 │
│ └── 레이턴시 비교: Polling(1~2초) vs CDC(수십 ms) │
│ │
│ Phase 4: 전환 완료 (1일) │
│ ├── Polling Publisher 비활성화 │
│ ├── Debezium 실제 발행 활성화 │
│ └── 모니터링 확인 후 Polling 코드 제거 │
│ │
│ Phase 5: 정리 │
│ ├── processed_at 컬럼 제거 (CDC에서는 불필요) │
│ ├── Polling용 인덱스 제거 │
│ └── 파티셔닝 최적화 적용 │
│ │
└─────────────────────────────────────────────────────────────────┘
11.3 기존 Dual Write → Outbox 패턴 전환
┌─────────────────────────────────────────────────────────────────┐
│ Dual Write → Outbox 전환 전략 │
│ │
│ [현재] Service → DB write + Kafka send (원자성 없음) │
│ [목표] Service → DB + Outbox → Debezium → Kafka │
│ │
│ Step 1: Outbox 테이블 추가 (무중단) │
│ └── 서비스 코드 미변경, 테이블만 생성 │
│ │
│ Step 2: Dual-Track 쓰기 (무중단) │
│ ├── 기존 Kafka 직접 발행 유지 │
│ ├── 동시에 Outbox에도 기록 │
│ ├── Consumer가 중복 처리 (idempotency 선행 구현 필요!) │
│ └── Feature flag: outbox_write_enabled = true │
│ │
│ Step 3: Debezium 활성화 (무중단) │
│ ├── Outbox → Kafka 경로 검증 │
│ └── 직접 발행 vs Debezium 이벤트 비교 │
│ │
│ Step 4: 직접 발행 코드 제거 (배포 필요) │
│ ├── Feature flag: direct_kafka_publish = false │
│ └── Outbox 경로만으로 운영 (72시간 관찰) │
│ │
│ Step 5: 정리 │
│ ├── 직접 발행 코드 완전 제거 │
│ └── Feature flag 코드 제거 │
│ │
│ 핵심 원칙: │
│ ├── 각 단계는 독립적으로 롤백 가능 │
│ ├── Consumer idempotency는 Step 2 이전에 반드시 완료 │
│ └── Shadow comparison으로 정확성 검증 │
│ │
└─────────────────────────────────────────────────────────────────┘
12. 학술적/이론적 배경
┌─────────────────────────────────────────────────────────────────┐
│ 핵심 학술 문헌 │
│ │
│ [Pat Helland — "Life beyond Distributed Transactions" (2007)] │
│ ├── CIDR 2007 발표, 2016년 ACM Queue 재게재 │
│ ├── 핵심 명제: "대규모 시스템에서 분산 트랜잭션은 불가능" │
│ ├── 대안: Entity + Activity + Message 세 가지 추상화 │
│ │ ├── Entity: 단일 키로 식별, 내부만 원자적 업데이트 가능 │
│ │ ├── Activity: Entity 간 합의를 위한 워크플로우 │
│ │ └── Message: Entity 간 조율의 유일한 메커니즘 │
│ └── Outbox 패턴 = Helland 모델의 실용적 구현 │
│ │
│ [Eric Brewer — CAP Theorem (2000)] │
│ ├── Consistency, Availability, Partition Tolerance │
│ │ → 셋 중 둘만 선택 가능 │
│ ├── 2002년 Gilbert & Lynch가 수학적 증명 │
│ └── Outbox 패턴이 필요한 이론적 배경: │
│ CP/AP 선택 불가피 → Eventual Consistency 패턴의 정당성 │
│ │
│ [Eric Evans — DDD Blue Book (2003)] │
│ ├── Aggregate 경계 = 트랜잭션 일관성 경계 │
│ ├── Domain Event는 원서에 미포함 → 2009년 공식 추가 │
│ └── Aggregate 간 데이터 일관성 문제 → Outbox 패턴의 구조적 원인│
│ │
│ [Martin Fowler — Event Sourcing (2005)] │
│ ├── 모든 상태 변경을 이벤트 시퀀스로 저장 │
│ └── Append-only log 개념 → Outbox 테이블 설계의 원리 │
│ │
│ [Greg Young — CQRS (2010)] │
│ ├── Command와 Query의 책임 분리 │
│ ├── Domain Event를 과거 시제로 명명하는 관례 확립 │
│ └── Event Store = Source of Truth 개념 │
│ │
│ [Chris Richardson — Microservices Patterns (2018)] │
│ ├── Transactional Outbox를 공식 패턴으로 카탈로그화 │
│ ├── microservices.io에 44개+ 패턴 체계 정립 │
│ └── Eventuate Tram 프레임워크로 레퍼런스 구현 제공 │
│ │
│ [Gunnar Morling — CDC + Outbox 청사진 (2019)] │
│ ├── Debezium Blog에서 레퍼런스 아키텍처 확립 │
│ ├── Clever Delete 기법 (INSERT 즉시 DELETE) 최초 문서화 │
│ └── Outbox Event Router SMT 설계/구현 │
│ │
└─────────────────────────────────────────────────────────────────┘
13. 관련 문서
┌─────────────────────────────────────────────────────────────────┐
│ 관련 문서 링크 │
│ │
│ 프로젝트 내 관련 문서: │
│ ├── docs/backend/dual-write-패턴.md │
│ │ └── Dual Write 문제의 상세 분석과 해결 패턴 │
│ ├── docs/backend/cqrs-event-sourcing-패턴.md │
│ │ └── CQRS + Event Sourcing 완전 가이드 │
│ └── docs/backend/보상트랜잭션-saga.md │
│ └── Saga 패턴과 보상 트랜잭션 상세 │
│ │
│ 외부 참고 자료: │
│ ├── microservices.io/patterns/data/transactional-outbox.html │
│ ├── debezium.io/blog/2019/02/19/reliable-microservices-data- │
│ │ exchange-with-the-outbox-pattern/ │
│ ├── debezium.io/documentation/reference/stable/transformations │
│ │ /outbox-event-router.html │
│ ├── queue.acm.org/detail.cfm?id=3025012 │
│ │ (Pat Helland, "Life beyond Distributed Transactions") │
│ ├── github.com/cloudevents/spec │
│ │ (CloudEvents Specification) │
│ ├── docs.confluent.io/platform/current/schema-registry/ │
│ │ fundamentals/schema-evolution.html │
│ └── udidahan.com/2009/06/14/domain-events-salvation/ │
│ (Udi Dahan, Domain Events – Salvation) │
│ │
└─────────────────────────────────────────────────────────────────┘