Skip to content

Split View: Outbox 패턴과 CDC로 구현하는 마이크로서비스 데이터 동기화: Debezium 실전 가이드

✨ Learn with Quiz
|

Outbox 패턴과 CDC로 구현하는 마이크로서비스 데이터 동기화: Debezium 실전 가이드

Outbox Pattern CDC

들어가며: Dual Write 문제

마이크로서비스 아키텍처에서 가장 흔하게 마주치는 문제 중 하나는 데이터베이스와 메시지 브로커에 동시에 데이터를 기록해야 하는 상황이다. 주문 서비스가 주문을 생성하면서 동시에 재고 서비스에 이벤트를 발행해야 하는 경우를 생각해보자. 단순하게 구현하면 다음과 같은 코드가 된다.

// 위험한 Dual Write 패턴
@Transactional
public Order createOrder(OrderRequest request) {
    Order order = orderRepository.save(new Order(request));  // Step 1: DB 저장

    // Step 2: 메시지 브로커에 이벤트 발행
    kafkaTemplate.send("order-events", new OrderCreatedEvent(order));

    return order;
}

이 코드는 치명적인 문제를 안고 있다. DB 저장은 성공했는데 Kafka 발행이 실패하면 주문은 존재하지만 다른 서비스는 이를 알 수 없다. 반대로 Kafka 발행은 성공했는데 DB 트랜잭션이 롤백되면 존재하지 않는 주문에 대한 이벤트가 전파된다. 이것이 바로 Dual Write 문제다.

분산 트랜잭션(2PC)으로 해결할 수도 있지만, Kafka는 전통적인 XA 트랜잭션에 참여할 수 없고, 2PC 자체가 성능 병목과 가용성 저하를 초래한다. 이 문제를 근본적으로 해결하는 패턴이 바로 Outbox 패턴이며, 이를 효율적으로 구현하는 기술이 **CDC(Change Data Capture)**다.

1. Outbox 패턴 아키텍처

1.1 핵심 아이디어

Outbox 패턴의 핵심은 단순하다. 이벤트를 메시지 브로커에 직접 발행하지 않고, 비즈니스 데이터와 같은 트랜잭션으로 Outbox 테이블에 기록하는 것이다. 이렇게 하면 DB의 ACID 트랜잭션이 두 작업의 원자성을 보장한다. 이후 별도의 프로세스가 Outbox 테이블을 읽어 메시지 브로커로 전달한다.

[서비스 코드]
    ├── 비즈니스 데이터 INSERT ─────┐
    │                                │  같은 DB 트랜잭션
    └── Outbox 테이블 INSERT ───────┘
                              [Outbox Relay]
                              (CDC or Polling)
                              [메시지 브로커]
                              (Kafka, RabbitMQ)
                              [다른 마이크로서비스]

이 방식의 장점은 명확하다. 비즈니스 데이터와 이벤트가 하나의 트랜잭션으로 묶이므로 둘 다 저장되거나 둘 다 저장되지 않는다. 최소 한 번(at-least-once) 전달이 보장되며, 컨슈머 측에서 멱등성을 구현하면 정확히 한 번(effectively-once) 의미론도 달성할 수 있다.

1.2 Outbox 패턴의 두 가지 구현 방식

Outbox 테이블의 변경 사항을 메시지 브로커로 전달하는 방식은 크게 두 가지다.

  1. Polling 방식: 주기적으로 Outbox 테이블을 쿼리하여 미발행 이벤트를 가져온다
  2. CDC 방식: 데이터베이스의 트랜잭션 로그(WAL/binlog)를 감시하여 변경 사항을 캡처한다

2. Outbox 테이블 설계

2.1 기본 스키마

-- PostgreSQL Outbox 테이블 DDL
CREATE TABLE outbox_events (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    aggregate_type  VARCHAR(255) NOT NULL,   -- 이벤트 소속 도메인 (예: 'Order')
    aggregate_id    VARCHAR(255) NOT NULL,   -- 도메인 엔티티 ID (예: 주문 ID)
    event_type      VARCHAR(255) NOT NULL,   -- 이벤트 타입 (예: 'OrderCreated')
    payload         JSONB NOT NULL,          -- 이벤트 본문
    metadata        JSONB DEFAULT '{}',      -- 추가 메타데이터
    created_at      TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
    published_at    TIMESTAMP WITH TIME ZONE,-- Polling 방식용: 발행 완료 시각
    retry_count     INT DEFAULT 0,           -- Polling 방식용: 재시도 횟수
    status          VARCHAR(20) DEFAULT 'PENDING'  -- PENDING, PUBLISHED, FAILED
);

-- Polling 방식에서 미발행 이벤트 조회를 위한 인덱스
CREATE INDEX idx_outbox_status_created ON outbox_events(status, created_at)
    WHERE status = 'PENDING';

-- CDC 방식에서는 status/published_at 컬럼이 불필요하며,
-- 이벤트 발행 후 행을 삭제하는 방식을 사용할 수 있다

2.2 설계 시 핵심 고려사항

aggregate_id를 Kafka 파티션 키로 사용: 같은 aggregate_id를 가진 이벤트가 같은 파티션에 들어가므로 해당 엔티티에 대한 이벤트 순서가 보장된다. 예를 들어 주문 123에 대한 'OrderCreated' -> 'OrderPaid' -> 'OrderShipped' 이벤트가 순서대로 처리된다.

payload에 모든 필요한 정보를 포함: 컨슈머가 이벤트만으로 처리를 완료할 수 있도록 payload에 충분한 정보를 담는다. 컨슈머가 프로듀서의 DB를 직접 조회하는 것은 서비스 간 결합도를 높이므로 피해야 한다.

테이블 비대화 방지: CDC 방식에서는 이벤트를 캡처한 후 주기적으로 오래된 행을 삭제한다. Polling 방식에서는 발행 완료된 이벤트를 일정 기간 후 아카이빙하거나 삭제한다.

2.3 MySQL용 트리거 기반 Outbox (대안)

-- MySQL에서 트리거를 이용한 자동 Outbox 기록
DELIMITER //

CREATE TRIGGER after_order_insert
AFTER INSERT ON orders
FOR EACH ROW
BEGIN
    INSERT INTO outbox_events (
        id, aggregate_type, aggregate_id, event_type, payload, created_at
    ) VALUES (
        UUID(),
        'Order',
        NEW.order_id,
        'OrderCreated',
        JSON_OBJECT(
            'orderId', NEW.order_id,
            'userId', NEW.user_id,
            'totalAmount', NEW.total_amount,
            'status', NEW.status,
            'createdAt', NEW.created_at
        ),
        NOW()
    );
END //

CREATE TRIGGER after_order_update
AFTER UPDATE ON orders
FOR EACH ROW
BEGIN
    IF OLD.status != NEW.status THEN
        INSERT INTO outbox_events (
            id, aggregate_type, aggregate_id, event_type, payload, created_at
        ) VALUES (
            UUID(),
            'Order',
            NEW.order_id,
            CONCAT('OrderStatus', NEW.status),
            JSON_OBJECT(
                'orderId', NEW.order_id,
                'previousStatus', OLD.status,
                'newStatus', NEW.status,
                'updatedAt', NEW.updated_at
            ),
            NOW()
        );
    END IF;
END //

DELIMITER ;

트리거 기반 접근은 애플리케이션 코드를 수정하지 않아도 되는 장점이 있지만, 트리거의 실행 비용이 메인 트랜잭션에 포함되어 성능에 영향을 줄 수 있다. 또한 복잡한 비즈니스 로직을 트리거에 담기 어렵고 디버깅이 까다로우므로, 대부분의 경우 애플리케이션 레벨에서 명시적으로 Outbox 행을 삽입하는 것을 권장한다.

3. CDC 개념과 동작 원리

3.1 CDC란 무엇인가

CDC(Change Data Capture)는 데이터베이스의 변경 사항(INSERT, UPDATE, DELETE)을 실시간으로 캡처하여 외부 시스템으로 전달하는 기술이다. CDC의 핵심은 데이터베이스의 트랜잭션 로그를 직접 읽는 것이다.

  • PostgreSQL: WAL(Write-Ahead Log)의 논리적 복제(logical replication) 슬롯을 사용
  • MySQL: binlog(binary log)를 읽어 변경 사항을 캡처
  • SQL Server: CDC 기능이 내장되어 있으며 변경 테이블을 자동 생성
  • MongoDB: Change Streams를 통해 oplog의 변경 사항을 스트리밍

3.2 CDC의 장점

로그 기반 CDC는 Polling 방식 대비 여러 장점이 있다.

  1. 극도로 낮은 지연시간: 트랜잭션 커밋 후 밀리초 단위로 변경 사항 캡처
  2. DB 부하 최소화: 인덱스 기반 쿼리가 아닌 로그 스트림을 읽으므로 DB에 추가 부하가 거의 없음
  3. DELETE 캡처 가능: Polling 방식에서는 삭제된 행을 감지하기 어렵지만, 로그에는 DELETE 이벤트도 기록됨
  4. 모든 변경 포착: 중간 상태의 변경도 놓치지 않음 (Polling은 간격 사이의 변경을 놓칠 수 있음)
  5. 스키마 변경 추적: 테이블 구조 변경도 로그에 기록되어 스키마 진화를 처리 가능

4. Debezium 설치 및 커넥터 구성

4.1 Debezium 개요

Debezium은 Red Hat이 주도하는 오픈소스 CDC 플랫폼으로, Kafka Connect 프레임워크 위에서 동작한다. PostgreSQL, MySQL, MongoDB, SQL Server, Oracle, Cassandra, Vitess 등 다양한 데이터베이스를 지원하며, Outbox Event Router SMT(Single Message Transformation)를 통해 Outbox 패턴을 네이티브로 지원한다.

4.2 Docker Compose로 전체 스택 구성

# docker-compose.yml - Debezium + Kafka 전체 스택
version: '3.8'
services:
  postgres:
    image: postgres:16
    environment:
      POSTGRES_DB: orderdb
      POSTGRES_USER: appuser
      POSTGRES_PASSWORD: secret
    command:
      - 'postgres'
      - '-c'
      - 'wal_level=logical' # CDC를 위한 필수 설정
      - '-c'
      - 'max_replication_slots=4'
      - '-c'
      - 'max_wal_senders=4'
    ports:
      - '5432:5432'
    volumes:
      - postgres_data:/var/lib/postgresql/data

  zookeeper:
    image: confluentinc/cp-zookeeper:7.6.0
    environment:
      ZOOKEEPER_CLIENT_PORT: 2181
      ZOOKEEPER_TICK_TIME: 2000

  kafka:
    image: confluentinc/cp-kafka:7.6.0
    depends_on:
      - zookeeper
    ports:
      - '9092:9092'
    environment:
      KAFKA_BROKER_ID: 1
      KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181
      KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka:29092,PLAINTEXT_HOST://localhost:9092
      KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: PLAINTEXT:PLAINTEXT,PLAINTEXT_HOST:PLAINTEXT
      KAFKA_INTER_BROKER_LISTENER_NAME: PLAINTEXT
      KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
      KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: 1
      KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: 1

  connect:
    image: debezium/connect:2.7
    depends_on:
      - kafka
      - postgres
    ports:
      - '8083:8083'
    environment:
      BOOTSTRAP_SERVERS: kafka:29092
      GROUP_ID: debezium-connect-group
      CONFIG_STORAGE_TOPIC: connect-configs
      OFFSET_STORAGE_TOPIC: connect-offsets
      STATUS_STORAGE_TOPIC: connect-status
      CONFIG_STORAGE_REPLICATION_FACTOR: 1
      OFFSET_STORAGE_REPLICATION_FACTOR: 1
      STATUS_STORAGE_REPLICATION_FACTOR: 1

  kafka-ui:
    image: provectuslabs/kafka-ui:latest
    ports:
      - '8080:8080'
    environment:
      KAFKA_CLUSTERS_0_NAME: local
      KAFKA_CLUSTERS_0_BOOTSTRAPSERVERS: kafka:29092
      KAFKA_CLUSTERS_0_KAFKACONNECT_0_NAME: debezium
      KAFKA_CLUSTERS_0_KAFKACONNECT_0_ADDRESS: http://connect:8083

volumes:
  postgres_data:

4.3 Debezium 커넥터 등록 (Outbox Event Router 포함)

{
  "name": "order-outbox-connector",
  "config": {
    "connector.class": "io.debezium.connector.postgresql.PostgresConnector",
    "database.hostname": "postgres",
    "database.port": "5432",
    "database.user": "appuser",
    "database.password": "secret",
    "database.dbname": "orderdb",
    "topic.prefix": "order-service",
    "schema.include.list": "public",
    "table.include.list": "public.outbox_events",

    "tombstones.on.delete": "false",
    "slot.name": "order_outbox_slot",
    "plugin.name": "pgoutput",

    "transforms": "outbox",
    "transforms.outbox.type": "io.debezium.transforms.outbox.EventRouter",
    "transforms.outbox.table.fields.additional.placement": "event_type:header:eventType",
    "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.field.event.timestamp": "created_at",
    "transforms.outbox.route.by.field": "aggregate_type",
    "transforms.outbox.route.topic.replacement": "events.${routedByValue}",

    "transforms.outbox.table.expand.json.payload": "true",

    "key.converter": "org.apache.kafka.connect.storage.StringConverter",
    "value.converter": "org.apache.kafka.connect.json.JsonConverter",
    "value.converter.schemas.enable": "false",

    "heartbeat.interval.ms": "10000",
    "snapshot.mode": "initial"
  }
}

이 설정의 핵심 포인트를 짚어보면 다음과 같다.

  • table.include.list로 Outbox 테이블만 감시하여 불필요한 CDC 이벤트를 방지한다
  • transforms.outbox.route.by.fieldaggregate_type으로 설정하여 도메인별로 Kafka 토픽이 자동 생성된다. 예를 들어 aggregate_type이 'Order'이면 events.Order 토픽에 발행된다
  • table.field.event.keyaggregate_id로 설정하여 같은 엔티티의 이벤트가 같은 Kafka 파티션에 들어간다
  • heartbeat.interval.ms를 설정하여 Outbox 테이블에 변경이 없어도 주기적으로 오프셋을 갱신한다. 이 설정이 없으면 WAL 보존 기간이 불필요하게 길어질 수 있다
# 커넥터 등록
curl -X POST http://localhost:8083/connectors \
  -H "Content-Type: application/json" \
  -d @order-outbox-connector.json

# 커넥터 상태 확인
curl -s http://localhost:8083/connectors/order-outbox-connector/status | jq .

# 커넥터 재시작
curl -X POST http://localhost:8083/connectors/order-outbox-connector/restart

# 토픽 목록 확인 (Kafka UI 또는 CLI)
docker exec -it kafka kafka-topics --bootstrap-server localhost:9092 --list

5. Kafka Connect 파이프라인

5.1 전체 파이프라인 흐름

Kafka Connect 기반 CDC 파이프라인의 전체 흐름은 다음과 같다.

  1. 애플리케이션이 비즈니스 데이터와 Outbox 이벤트를 같은 트랜잭션으로 DB에 기록한다
  2. Debezium Source Connector가 PostgreSQL의 WAL을 읽어 Outbox 테이블의 변경을 감지한다
  3. Outbox Event Router SMT가 원본 CDC 이벤트를 도메인별 Kafka 토픽으로 라우팅한다
  4. 컨슈머 마이크로서비스가 해당 토픽을 구독하여 이벤트를 처리한다
  5. 필요 시 Sink Connector가 이벤트를 다른 데이터 저장소(Elasticsearch, S3 등)로 전달한다

5.2 Spring Boot Outbox 패턴 구현

// OutboxEvent.java - Outbox 엔티티
@Entity
@Table(name = "outbox_events")
public class OutboxEvent {
    @Id
    @GeneratedValue(strategy = GenerationType.UUID)
    private UUID id;

    @Column(name = "aggregate_type", nullable = false)
    private String aggregateType;

    @Column(name = "aggregate_id", nullable = false)
    private String aggregateId;

    @Column(name = "event_type", nullable = false)
    private String eventType;

    @Column(name = "payload", columnDefinition = "jsonb", nullable = false)
    private String payload;

    @Column(name = "created_at", nullable = false)
    private Instant createdAt;

    // 기본 생성자, getter, setter 생략

    public static OutboxEvent create(String aggregateType, String aggregateId,
                                     String eventType, Object payload) {
        OutboxEvent event = new OutboxEvent();
        event.aggregateType = aggregateType;
        event.aggregateId = aggregateId;
        event.eventType = eventType;
        event.payload = toJson(payload);
        event.createdAt = Instant.now();
        return event;
    }

    private static String toJson(Object obj) {
        try {
            return new ObjectMapper()
                .registerModule(new JavaTimeModule())
                .writeValueAsString(obj);
        } catch (JsonProcessingException e) {
            throw new RuntimeException("JSON 직렬화 실패", e);
        }
    }
}

// OrderService.java - Outbox 패턴 적용 주문 서비스
@Service
@RequiredArgsConstructor
public class OrderService {

    private final OrderRepository orderRepository;
    private final OutboxEventRepository outboxRepository;

    @Transactional  // 하나의 트랜잭션으로 비즈니스 데이터와 이벤트를 함께 저장
    public Order createOrder(OrderRequest request) {
        // 1. 비즈니스 로직 실행
        Order order = Order.builder()
            .userId(request.getUserId())
            .items(request.getItems())
            .totalAmount(calculateTotal(request.getItems()))
            .status(OrderStatus.CREATED)
            .build();

        Order savedOrder = orderRepository.save(order);

        // 2. Outbox 테이블에 이벤트 기록 (같은 트랜잭션)
        OrderCreatedPayload eventPayload = OrderCreatedPayload.builder()
            .orderId(savedOrder.getId().toString())
            .userId(savedOrder.getUserId())
            .totalAmount(savedOrder.getTotalAmount())
            .items(savedOrder.getItems())
            .createdAt(savedOrder.getCreatedAt())
            .build();

        OutboxEvent outboxEvent = OutboxEvent.create(
            "Order",                              // aggregate_type -> Kafka 토픽 결정
            savedOrder.getId().toString(),         // aggregate_id -> Kafka 파티션 키
            "OrderCreated",                        // event_type
            eventPayload                           // payload
        );

        outboxRepository.save(outboxEvent);

        return savedOrder;
    }

    @Transactional
    public Order cancelOrder(UUID orderId) {
        Order order = orderRepository.findById(orderId)
            .orElseThrow(() -> new OrderNotFoundException(orderId));

        if (order.getStatus() != OrderStatus.CREATED) {
            throw new IllegalStateException(
                "CREATED 상태의 주문만 취소할 수 있습니다. 현재 상태: " + order.getStatus()
            );
        }

        order.setStatus(OrderStatus.CANCELLED);
        Order savedOrder = orderRepository.save(order);

        OutboxEvent outboxEvent = OutboxEvent.create(
            "Order",
            savedOrder.getId().toString(),
            "OrderCancelled",
            Map.of(
                "orderId", savedOrder.getId().toString(),
                "reason", "사용자 요청",
                "cancelledAt", Instant.now().toString()
            )
        );

        outboxRepository.save(outboxEvent);

        return savedOrder;
    }
}

6. 이벤트 순서 보장과 멱등성 처리

6.1 이벤트 순서 보장

Outbox 패턴에서 이벤트 순서 보장은 다음 원칙을 따른다.

  • 같은 Aggregate에 대한 순서 보장: aggregate_id를 Kafka 파티션 키로 사용하면, 같은 엔티티에 대한 이벤트가 같은 파티션에 순서대로 저장된다
  • 서로 다른 Aggregate 간에는 순서가 보장되지 않음: 이는 의도적인 설계이며, 마이크로서비스 간의 느슨한 결합에 부합한다
  • 파티션 수 변경 시 주의: Kafka 토픽의 파티션 수를 변경하면 기존의 키-파티션 매핑이 깨지므로, 운영 중에는 파티션 수를 변경하지 않는 것이 안전하다

6.2 멱등성 처리 (Python 컨슈머 예시)

import json
import uuid
from datetime import datetime
from kafka import KafkaConsumer
from sqlalchemy import create_engine, text
from sqlalchemy.orm import Session

# 멱등성 보장을 위한 처리 이력 테이블
# CREATE TABLE processed_events (
#     event_id UUID PRIMARY KEY,
#     processed_at TIMESTAMP NOT NULL DEFAULT NOW()
# );

engine = create_engine('postgresql://user:pass@localhost:5432/inventorydb')

consumer = KafkaConsumer(
    'events.Order',
    bootstrap_servers=['localhost:9092'],
    group_id='inventory-service',
    auto_offset_reset='earliest',
    enable_auto_commit=False,          # 수동 커밋으로 정확한 제어
    value_deserializer=lambda m: json.loads(m.decode('utf-8')),
    max_poll_records=100,
)

def process_order_event(event_data: dict, event_id: str) -> None:
    """멱등성을 보장하는 이벤트 처리"""
    with Session(engine) as session:
        with session.begin():
            # 1. 이미 처리된 이벤트인지 확인
            result = session.execute(
                text("SELECT 1 FROM processed_events WHERE event_id = :id"),
                {"id": event_id}
            )
            if result.fetchone():
                print(f"이미 처리된 이벤트 건너뜀: {event_id}")
                return

            # 2. 비즈니스 로직 실행
            event_type = event_data.get('eventType', '')

            if event_type == 'OrderCreated':
                items = event_data.get('items', [])
                for item in items:
                    session.execute(
                        text("""
                            UPDATE inventory
                            SET reserved_quantity = reserved_quantity + :qty
                            WHERE product_id = :product_id
                              AND available_quantity >= :qty
                        """),
                        {"qty": item['quantity'], "product_id": item['productId']}
                    )

            elif event_type == 'OrderCancelled':
                order_id = event_data.get('orderId')
                session.execute(
                    text("""
                        UPDATE inventory i
                        SET reserved_quantity = reserved_quantity - oi.quantity
                        FROM order_items oi
                        WHERE oi.order_id = :order_id
                          AND oi.product_id = i.product_id
                    """),
                    {"order_id": order_id}
                )

            # 3. 처리 이력 기록 (같은 트랜잭션)
            session.execute(
                text("""
                    INSERT INTO processed_events (event_id, processed_at)
                    VALUES (:id, :now)
                """),
                {"id": event_id, "now": datetime.utcnow()}
            )

    print(f"이벤트 처리 완료: {event_id} ({event_type})")


# 메인 컨슈머 루프
print("이벤트 컨슈머 시작...")
try:
    for message in consumer:
        try:
            event_data = message.value
            # Debezium Outbox Event Router가 설정한 헤더에서 event_id 추출
            headers = {k: v.decode('utf-8') for k, v in message.headers}
            event_id = headers.get('id', str(uuid.uuid4()))

            process_order_event(event_data, event_id)

            # 처리 성공 시에만 오프셋 커밋
            consumer.commit()

        except Exception as e:
            print(f"이벤트 처리 실패: {e}")
            # 실패 시 DLQ(Dead Letter Queue)로 전송하거나 재시도 로직 적용
            # 여기서는 단순히 로깅하고 다음 이벤트로 진행

except KeyboardInterrupt:
    print("컨슈머 종료")
finally:
    consumer.close()

멱등성 처리의 핵심은 이벤트 처리와 처리 이력 기록을 같은 DB 트랜잭션으로 묶는 것이다. 이렇게 하면 이벤트 처리 도중 장애가 발생해도 처리 이력이 기록되지 않으므로, 재시작 시 동일한 이벤트를 다시 처리할 수 있다. 이미 처리된 이벤트는 처리 이력 테이블에서 확인하여 건너뛴다.

7. Polling 방식 vs CDC 방식 비교

비교 항목Polling 방식CDC 방식 (Debezium)
지연시간폴링 주기에 의존 (초~분)밀리초 수준의 근실시간
DB 부하주기적 SELECT 쿼리 부하WAL/binlog 읽기로 부하 최소
구현 복잡도낮음 (CRON + SQL)높음 (Kafka Connect + Debezium)
인프라 요구사항DB만 필요Kafka + Kafka Connect + Debezium
DELETE 감지불가 (soft delete 필요)가능 (로그에 기록)
중간 상태 캡처불가 (마지막 상태만)가능 (모든 변경 캡처)
순서 보장타임스탬프 기반 (취약)로그 기반 (강력)
확장성DB 커넥션 부담 증가Kafka Connect 수평 확장 가능
운영 복잡도낮음높음 (Kafka 클러스터 관리 필요)
장애 복구상태 컬럼 기반 간단한 재시도Kafka 오프셋 기반 정교한 복구
적합 환경소규모, 낮은 지연 허용대규모, 실시간 처리 필요

처음 Outbox 패턴을 도입할 때는 Polling 방식으로 시작하고, 트래픽 증가에 따라 CDC 방식으로 전환하는 점진적 접근도 유효하다. Polling 방식이라도 Dual Write 문제는 완벽히 해결되며, 지연시간 요구사항이 초 단위면 충분할 수 있다.

8. Debezium vs Maxwell vs Canal 비교

비교 항목DebeziumMaxwellCanal
지원 DBPostgreSQL, MySQL, MongoDB, SQL Server, Oracle, Cassandra 등MySQL만MySQL만
아키텍처Kafka Connect 기반 분산단일 Java 프로세스단일 Java 프로세스
메시지 브로커Kafka (기본), Pulsar, NATS 등Kafka, RabbitMQ, Redis 등Kafka, RocketMQ 등
Outbox 지원EventRouter SMT 네이티브 지원별도 구현 필요별도 구현 필요
스키마 진화Schema Registry 통합 지원제한적제한적
확장성Kafka Connect 분산 모드로 수평 확장단일 프로세스 한계단일 프로세스 한계
설정 복잡도높음 (Kafka Connect 이해 필요)낮음 (간단한 설정)중간
커뮤니티매우 활발 (Red Hat 후원)소규모중국 커뮤니티 중심 (Alibaba)
운영 성숙도높음중간중간
적합 환경대규모, 다중 DB, 엔터프라이즈MySQL만 사용하는 소규모 서비스MySQL 기반 중국 생태계

선택 가이드: 대부분의 프로덕션 환경에서는 Debezium을 권장한다. 다양한 데이터베이스를 지원하고, Kafka Connect의 분산 모드로 고가용성을 확보할 수 있으며, Outbox Event Router를 네이티브로 지원한다. MySQL만 사용하고 빠른 PoC가 필요하다면 Maxwell의 단순함이 매력적이다. Canal은 Alibaba에서 개발되어 중국 생태계에서 주로 사용되며, 해외에서의 채택은 상대적으로 낮다.

9. 실패 시나리오와 복구 절차

9.1 시나리오 1: Debezium 커넥터 장애

증상: Kafka Connect의 커넥터 상태가 FAILED로 변경되고, 새로운 이벤트가 Kafka에 발행되지 않는다.

주요 원인: DB 연결 끊김, WAL 슬롯 삭제, 스키마 변경 호환성 문제, Kafka 브로커 연결 불가 등

복구 절차:

# 1. 커넥터 상태 확인
curl -s http://localhost:8083/connectors/order-outbox-connector/status | jq .

# 2. 커넥터 태스크 재시작 시도
curl -X POST http://localhost:8083/connectors/order-outbox-connector/tasks/0/restart

# 3. 재시작이 안 되면 커넥터 삭제 후 재등록
curl -X DELETE http://localhost:8083/connectors/order-outbox-connector
curl -X POST http://localhost:8083/connectors \
  -H "Content-Type: application/json" \
  -d @order-outbox-connector.json

# 4. PostgreSQL WAL 슬롯 상태 확인
psql -c "SELECT * FROM pg_replication_slots;"

# 5. 필요 시 슬롯 재생성
psql -c "SELECT pg_drop_replication_slot('order_outbox_slot');"
# 커넥터 재등록 시 자동으로 슬롯이 생성됨

주의사항: WAL 슬롯이 활성화된 상태에서 Debezium이 장기간 멈추면, PostgreSQL이 해당 슬롯 이후의 WAL 파일을 삭제하지 못해 디스크가 가득 찰 수 있다. 반드시 WAL 크기를 모니터링하고, max_slot_wal_keep_size 파라미터로 상한선을 설정해야 한다.

9.2 시나리오 2: Kafka 브로커 장애

증상: Debezium 커넥터가 이벤트를 발행하지 못하고, 컨슈머도 이벤트를 소비하지 못한다.

복구 절차:

  1. Kafka 브로커를 복구한다
  2. Debezium은 마지막으로 성공한 오프셋부터 자동으로 재개된다 (Kafka Connect의 offset 관리)
  3. 컨슈머도 마지막 커밋된 오프셋부터 재개된다
  4. CDC 방식에서는 DB의 WAL이 보존되어 있는 한 데이터 손실이 발생하지 않는다

9.3 시나리오 3: 컨슈머 처리 실패

증상: 특정 이벤트가 반복적으로 처리에 실패하여 컨슈머가 진행하지 못한다 (poison pill).

복구 절차:

  1. 재시도 정책을 적용한다 (최대 3~5회, 지수 백오프)
  2. 최대 재시도 초과 시 DLQ(Dead Letter Queue) 토픽으로 전송한다
  3. DLQ의 메시지를 수동으로 분석하고, 수정 후 원래 토픽으로 재발행한다

9.4 시나리오 4: Outbox 테이블 비대화

증상: Outbox 테이블이 수백만 행으로 커져 DB 성능이 저하된다.

복구 절차:

  1. CDC 방식에서는 이벤트가 캡처된 후 일정 시간이 지난 행을 삭제한다
  2. PostgreSQL의 경우 파티셔닝을 적용하여 오래된 파티션을 DROP한다
  3. 정기적인 VACUUM을 실행하여 죽은 행(dead tuple)을 정리한다
-- 일별 파티셔닝된 Outbox 테이블
CREATE TABLE outbox_events (
    id              UUID NOT NULL DEFAULT gen_random_uuid(),
    aggregate_type  VARCHAR(255) NOT NULL,
    aggregate_id    VARCHAR(255) NOT NULL,
    event_type      VARCHAR(255) NOT NULL,
    payload         JSONB NOT NULL,
    created_at      TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
) PARTITION BY RANGE (created_at);

-- 일별 파티션 자동 생성 (pg_partman 활용)
SELECT partman.create_parent(
    'public.outbox_events',
    'created_at',
    'native',
    'daily'
);

-- 7일 이상 된 파티션 자동 삭제
UPDATE partman.part_config
SET retention = '7 days', retention_keep_table = false
WHERE parent_table = 'public.outbox_events';

10. 운영 시 주의사항 체크리스트

Debezium/CDC 설정 단계:

  • PostgreSQL의 wal_levellogical로 설정하고 재시작했는지 확인한다
  • max_replication_slotsmax_wal_senders를 커넥터 수보다 넉넉하게 설정한다
  • max_slot_wal_keep_size를 설정하여 WAL 디스크 폭주를 방지한다
  • Debezium 커넥터의 heartbeat.interval.ms를 반드시 설정하여 오프셋 갱신을 보장한다
  • snapshot.mode를 신중하게 선택한다 (초기에는 initial, 이후 재시작 시 schema_only)

이벤트 설계 단계:

  • 이벤트 payload에 컨슈머가 필요한 모든 정보를 포함시킨다 (자기 완결적 이벤트)
  • 스키마 진화를 고려하여 필드 추가는 허용하되 기존 필드 삭제는 피한다
  • aggregate_id를 Kafka 파티션 키로 사용하여 같은 엔티티의 이벤트 순서를 보장한다
  • 이벤트 크기를 1MB 이하로 유지한다 (Kafka 기본 메시지 최대 크기)

컨슈머 구현 단계:

  • 모든 컨슈머에 멱등성을 구현한다 (processed_events 테이블 활용)
  • auto.commit을 비활성화하고 수동으로 오프셋을 커밋한다
  • DLQ를 구성하여 poison pill 메시지로 인한 처리 정체를 방지한다
  • 컨슈머 그룹의 세션 타임아웃과 하트비트 간격을 적절히 설정한다

모니터링 단계:

  • Debezium 커넥터 상태를 주기적으로 확인한다 (RUNNING/FAILED)
  • WAL 슬롯 크기와 디스크 사용량을 모니터링한다
  • 이벤트 발행 지연시간(CDC lag)을 모니터링한다
  • 컨슈머 랙(consumer lag)을 모니터링한다
  • Outbox 테이블 크기와 행 수를 모니터링한다
  • DLQ 토픽의 메시지 수를 알림 설정한다

장애 대비:

  • Kafka Connect를 분산 모드로 운영하여 워커 노드 장애 시 태스크가 자동 재할당되도록 한다
  • 정기적으로 커넥터 삭제-재등록 복구 절차를 테스트한다
  • WAL 슬롯 관련 장애 시나리오를 운영 매뉴얼에 문서화한다
  • 전체 파이프라인의 end-to-end 테스트를 자동화한다

참고자료

  1. Debezium 공식 문서 - Outbox Event Router - Outbox Event Router SMT의 전체 설정 옵션과 사용법
  2. Debezium 블로그 - Reliable Microservices Data Exchange With the Outbox Pattern - Outbox 패턴과 Debezium을 활용한 마이크로서비스 데이터 교환 원리
  3. Thorben Janssen - Implementing the Outbox Pattern with CDC using Debezium - JPA/Hibernate 환경에서의 Outbox 패턴 구현 실전 가이드
  4. Decodable - Revisiting the Outbox Pattern - 2024년 관점에서 Outbox 패턴의 재평가와 대안 분석
  5. Upsolver - Debezium vs Maxwell - CDC 도구 비교와 선택 기준 상세 분석
  6. RisingWave - Debezium vs Other CDC Tools - Debezium과 경쟁 CDC 도구의 종합 비교
  7. DEV Community - CDC Maxwell vs Debezium - Maxwell과 Debezium의 아키텍처 및 기능 비교
  8. Medium - Change Data Capture vs Outbox Pattern - CDC와 Outbox 패턴의 차이와 적합한 사용 시나리오 분석

Microservices Data Synchronization with the Outbox Pattern and CDC: A Practical Debezium Guide

Outbox Pattern CDC

Introduction: The Dual Write Problem

One of the most common challenges in microservices architecture is the need to write data to both a database and a message broker simultaneously. Consider the case where an order service must create an order and publish an event to the inventory service at the same time. A naive implementation looks something like this:

// Dangerous Dual Write pattern
@Transactional
public Order createOrder(OrderRequest request) {
    Order order = orderRepository.save(new Order(request));  // Step 1: Save to DB

    // Step 2: Publish event to message broker
    kafkaTemplate.send("order-events", new OrderCreatedEvent(order));

    return order;
}

This code harbors a critical flaw. If the DB save succeeds but the Kafka publish fails, the order exists but no other service is aware of it. Conversely, if the Kafka publish succeeds but the DB transaction rolls back, an event is propagated for an order that does not exist. This is the Dual Write problem.

While distributed transactions (2PC) could theoretically solve this, Kafka cannot participate in traditional XA transactions, and 2PC itself introduces performance bottlenecks and reduces availability. The pattern that fundamentally solves this problem is the Outbox Pattern, and the technology that efficiently implements it is CDC (Change Data Capture).

1. Outbox Pattern Architecture

1.1 Core Idea

The core idea of the Outbox Pattern is straightforward: instead of publishing events directly to a message broker, write them to an Outbox table within the same transaction as the business data. This way, the database's ACID transaction guarantees the atomicity of both operations. A separate process then reads the Outbox table and forwards the events to the message broker.

[Service Code]
    ├── Business data INSERT ────────┐
    │                                │  Same DB transaction
    └── Outbox table INSERT ─────────┘
                              [Outbox Relay]
                              (CDC or Polling)
                              [Message Broker]
                              (Kafka, RabbitMQ)
                              [Other Microservices]

The benefits of this approach are clear. Since the business data and the event are bound within a single transaction, either both are persisted or neither is. At-least-once delivery is guaranteed, and if consumers implement idempotency, effectively-once semantics can also be achieved.

1.2 Two Implementation Approaches for the Outbox Pattern

There are two primary approaches for relaying changes from the Outbox table to the message broker:

  1. Polling approach: Periodically query the Outbox table to retrieve unpublished events
  2. CDC approach: Monitor the database's transaction log (WAL/binlog) to capture changes

2. Outbox Table Design

2.1 Basic Schema

-- PostgreSQL Outbox table DDL
CREATE TABLE outbox_events (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    aggregate_type  VARCHAR(255) NOT NULL,   -- Domain the event belongs to (e.g., 'Order')
    aggregate_id    VARCHAR(255) NOT NULL,   -- Domain entity ID (e.g., order ID)
    event_type      VARCHAR(255) NOT NULL,   -- Event type (e.g., 'OrderCreated')
    payload         JSONB NOT NULL,          -- Event body
    metadata        JSONB DEFAULT '{}',      -- Additional metadata
    created_at      TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
    published_at    TIMESTAMP WITH TIME ZONE,-- For polling: timestamp when published
    retry_count     INT DEFAULT 0,           -- For polling: retry count
    status          VARCHAR(20) DEFAULT 'PENDING'  -- PENDING, PUBLISHED, FAILED
);

-- Index for querying unpublished events in polling approach
CREATE INDEX idx_outbox_status_created ON outbox_events(status, created_at)
    WHERE status = 'PENDING';

-- For CDC approach, the status/published_at columns are unnecessary,
-- and rows can be deleted after event publication

2.2 Key Design Considerations

Use aggregate_id as the Kafka partition key: Events with the same aggregate_id land in the same partition, ensuring event ordering for that entity. For example, the events 'OrderCreated' -> 'OrderPaid' -> 'OrderShipped' for order 123 are processed in order.

Include all necessary information in the payload: The payload should contain enough information for the consumer to complete its processing using the event alone. Consumers should not directly query the producer's database, as this increases inter-service coupling.

Prevent table bloat: With the CDC approach, periodically delete old rows after events have been captured. With the polling approach, archive or delete published events after a retention period.

2.3 Trigger-Based Outbox for MySQL (Alternative)

-- Automatic Outbox recording using triggers in MySQL
DELIMITER //

CREATE TRIGGER after_order_insert
AFTER INSERT ON orders
FOR EACH ROW
BEGIN
    INSERT INTO outbox_events (
        id, aggregate_type, aggregate_id, event_type, payload, created_at
    ) VALUES (
        UUID(),
        'Order',
        NEW.order_id,
        'OrderCreated',
        JSON_OBJECT(
            'orderId', NEW.order_id,
            'userId', NEW.user_id,
            'totalAmount', NEW.total_amount,
            'status', NEW.status,
            'createdAt', NEW.created_at
        ),
        NOW()
    );
END //

CREATE TRIGGER after_order_update
AFTER UPDATE ON orders
FOR EACH ROW
BEGIN
    IF OLD.status != NEW.status THEN
        INSERT INTO outbox_events (
            id, aggregate_type, aggregate_id, event_type, payload, created_at
        ) VALUES (
            UUID(),
            'Order',
            NEW.order_id,
            CONCAT('OrderStatus', NEW.status),
            JSON_OBJECT(
                'orderId', NEW.order_id,
                'previousStatus', OLD.status,
                'newStatus', NEW.status,
                'updatedAt', NEW.updated_at
            ),
            NOW()
        );
    END IF;
END //

DELIMITER ;

The trigger-based approach has the advantage of requiring no application code changes, but the trigger execution cost is included in the main transaction, which can impact performance. Additionally, embedding complex business logic in triggers is difficult and debugging is cumbersome. In most cases, explicitly inserting Outbox rows at the application level is recommended.

3. CDC Concepts and How It Works

3.1 What Is CDC?

CDC (Change Data Capture) is a technology that captures database changes (INSERT, UPDATE, DELETE) in real time and delivers them to external systems. The key to CDC is reading the database's transaction log directly.

  • PostgreSQL: Uses logical replication slots from the WAL (Write-Ahead Log)
  • MySQL: Reads the binlog (binary log) to capture changes
  • SQL Server: Has built-in CDC functionality that automatically creates change tables
  • MongoDB: Streams changes from the oplog via Change Streams

3.2 Advantages of CDC

Log-based CDC offers several advantages over the polling approach:

  1. Extremely low latency: Changes are captured within milliseconds after transaction commit
  2. Minimal DB load: Reads a log stream rather than running index-based queries, imposing virtually no additional load on the database
  3. Ability to capture DELETEs: Polling struggles to detect deleted rows, but DELETE events are recorded in the log
  4. Captures all changes: No intermediate state changes are missed (polling can miss changes between intervals)
  5. Schema change tracking: Table structure changes are also recorded in the log, enabling schema evolution handling

4. Debezium Installation and Connector Configuration

4.1 Debezium Overview

Debezium is an open-source CDC platform led by Red Hat that runs on top of the Kafka Connect framework. It supports a wide range of databases including PostgreSQL, MySQL, MongoDB, SQL Server, Oracle, Cassandra, and Vitess. Through the Outbox Event Router SMT (Single Message Transformation), it provides native support for the Outbox Pattern.

4.2 Full Stack Setup with Docker Compose

# docker-compose.yml - Full Debezium + Kafka stack
version: '3.8'
services:
  postgres:
    image: postgres:16
    environment:
      POSTGRES_DB: orderdb
      POSTGRES_USER: appuser
      POSTGRES_PASSWORD: secret
    command:
      - 'postgres'
      - '-c'
      - 'wal_level=logical' # Required setting for CDC
      - '-c'
      - 'max_replication_slots=4'
      - '-c'
      - 'max_wal_senders=4'
    ports:
      - '5432:5432'
    volumes:
      - postgres_data:/var/lib/postgresql/data

  zookeeper:
    image: confluentinc/cp-zookeeper:7.6.0
    environment:
      ZOOKEEPER_CLIENT_PORT: 2181
      ZOOKEEPER_TICK_TIME: 2000

  kafka:
    image: confluentinc/cp-kafka:7.6.0
    depends_on:
      - zookeeper
    ports:
      - '9092:9092'
    environment:
      KAFKA_BROKER_ID: 1
      KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181
      KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka:29092,PLAINTEXT_HOST://localhost:9092
      KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: PLAINTEXT:PLAINTEXT,PLAINTEXT_HOST:PLAINTEXT
      KAFKA_INTER_BROKER_LISTENER_NAME: PLAINTEXT
      KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
      KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: 1
      KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: 1

  connect:
    image: debezium/connect:2.7
    depends_on:
      - kafka
      - postgres
    ports:
      - '8083:8083'
    environment:
      BOOTSTRAP_SERVERS: kafka:29092
      GROUP_ID: debezium-connect-group
      CONFIG_STORAGE_TOPIC: connect-configs
      OFFSET_STORAGE_TOPIC: connect-offsets
      STATUS_STORAGE_TOPIC: connect-status
      CONFIG_STORAGE_REPLICATION_FACTOR: 1
      OFFSET_STORAGE_REPLICATION_FACTOR: 1
      STATUS_STORAGE_REPLICATION_FACTOR: 1

  kafka-ui:
    image: provectuslabs/kafka-ui:latest
    ports:
      - '8080:8080'
    environment:
      KAFKA_CLUSTERS_0_NAME: local
      KAFKA_CLUSTERS_0_BOOTSTRAPSERVERS: kafka:29092
      KAFKA_CLUSTERS_0_KAFKACONNECT_0_NAME: debezium
      KAFKA_CLUSTERS_0_KAFKACONNECT_0_ADDRESS: http://connect:8083

volumes:
  postgres_data:

4.3 Registering the Debezium Connector (with Outbox Event Router)

{
  "name": "order-outbox-connector",
  "config": {
    "connector.class": "io.debezium.connector.postgresql.PostgresConnector",
    "database.hostname": "postgres",
    "database.port": "5432",
    "database.user": "appuser",
    "database.password": "secret",
    "database.dbname": "orderdb",
    "topic.prefix": "order-service",
    "schema.include.list": "public",
    "table.include.list": "public.outbox_events",

    "tombstones.on.delete": "false",
    "slot.name": "order_outbox_slot",
    "plugin.name": "pgoutput",

    "transforms": "outbox",
    "transforms.outbox.type": "io.debezium.transforms.outbox.EventRouter",
    "transforms.outbox.table.fields.additional.placement": "event_type:header:eventType",
    "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.field.event.timestamp": "created_at",
    "transforms.outbox.route.by.field": "aggregate_type",
    "transforms.outbox.route.topic.replacement": "events.${routedByValue}",

    "transforms.outbox.table.expand.json.payload": "true",

    "key.converter": "org.apache.kafka.connect.storage.StringConverter",
    "value.converter": "org.apache.kafka.connect.json.JsonConverter",
    "value.converter.schemas.enable": "false",

    "heartbeat.interval.ms": "10000",
    "snapshot.mode": "initial"
  }
}

The key points of this configuration are as follows:

  • table.include.list monitors only the Outbox table, preventing unnecessary CDC events
  • transforms.outbox.route.by.field is set to aggregate_type, so Kafka topics are automatically created per domain. For example, if aggregate_type is 'Order', the event is published to the events.Order topic
  • table.field.event.key is set to aggregate_id, ensuring events for the same entity land in the same Kafka partition
  • heartbeat.interval.ms is configured so that offsets are periodically updated even when there are no changes to the Outbox table. Without this setting, WAL retention can grow unnecessarily large
# Register connector
curl -X POST http://localhost:8083/connectors \
  -H "Content-Type: application/json" \
  -d @order-outbox-connector.json

# Check connector status
curl -s http://localhost:8083/connectors/order-outbox-connector/status | jq .

# Restart connector
curl -X POST http://localhost:8083/connectors/order-outbox-connector/restart

# List topics (via Kafka UI or CLI)
docker exec -it kafka kafka-topics --bootstrap-server localhost:9092 --list

5. Kafka Connect Pipeline

5.1 End-to-End Pipeline Flow

The full flow of a Kafka Connect-based CDC pipeline is as follows:

  1. The application writes business data and an Outbox event to the database within the same transaction
  2. The Debezium Source Connector reads the PostgreSQL WAL and detects changes to the Outbox table
  3. The Outbox Event Router SMT routes the raw CDC event to domain-specific Kafka topics
  4. Consumer microservices subscribe to the relevant topics and process the events
  5. Optionally, a Sink Connector forwards events to other data stores (Elasticsearch, S3, etc.)

5.2 Spring Boot Outbox Pattern Implementation

// OutboxEvent.java - Outbox entity
@Entity
@Table(name = "outbox_events")
public class OutboxEvent {
    @Id
    @GeneratedValue(strategy = GenerationType.UUID)
    private UUID id;

    @Column(name = "aggregate_type", nullable = false)
    private String aggregateType;

    @Column(name = "aggregate_id", nullable = false)
    private String aggregateId;

    @Column(name = "event_type", nullable = false)
    private String eventType;

    @Column(name = "payload", columnDefinition = "jsonb", nullable = false)
    private String payload;

    @Column(name = "created_at", nullable = false)
    private Instant createdAt;

    // Default constructor, getters, setters omitted

    public static OutboxEvent create(String aggregateType, String aggregateId,
                                     String eventType, Object payload) {
        OutboxEvent event = new OutboxEvent();
        event.aggregateType = aggregateType;
        event.aggregateId = aggregateId;
        event.eventType = eventType;
        event.payload = toJson(payload);
        event.createdAt = Instant.now();
        return event;
    }

    private static String toJson(Object obj) {
        try {
            return new ObjectMapper()
                .registerModule(new JavaTimeModule())
                .writeValueAsString(obj);
        } catch (JsonProcessingException e) {
            throw new RuntimeException("JSON serialization failed", e);
        }
    }
}

// OrderService.java - Order service with Outbox Pattern applied
@Service
@RequiredArgsConstructor
public class OrderService {

    private final OrderRepository orderRepository;
    private final OutboxEventRepository outboxRepository;

    @Transactional  // Single transaction persists both business data and event
    public Order createOrder(OrderRequest request) {
        // 1. Execute business logic
        Order order = Order.builder()
            .userId(request.getUserId())
            .items(request.getItems())
            .totalAmount(calculateTotal(request.getItems()))
            .status(OrderStatus.CREATED)
            .build();

        Order savedOrder = orderRepository.save(order);

        // 2. Record event in Outbox table (same transaction)
        OrderCreatedPayload eventPayload = OrderCreatedPayload.builder()
            .orderId(savedOrder.getId().toString())
            .userId(savedOrder.getUserId())
            .totalAmount(savedOrder.getTotalAmount())
            .items(savedOrder.getItems())
            .createdAt(savedOrder.getCreatedAt())
            .build();

        OutboxEvent outboxEvent = OutboxEvent.create(
            "Order",                              // aggregate_type -> determines Kafka topic
            savedOrder.getId().toString(),         // aggregate_id -> Kafka partition key
            "OrderCreated",                        // event_type
            eventPayload                           // payload
        );

        outboxRepository.save(outboxEvent);

        return savedOrder;
    }

    @Transactional
    public Order cancelOrder(UUID orderId) {
        Order order = orderRepository.findById(orderId)
            .orElseThrow(() -> new OrderNotFoundException(orderId));

        if (order.getStatus() != OrderStatus.CREATED) {
            throw new IllegalStateException(
                "Only orders in CREATED status can be cancelled. Current status: " + order.getStatus()
            );
        }

        order.setStatus(OrderStatus.CANCELLED);
        Order savedOrder = orderRepository.save(order);

        OutboxEvent outboxEvent = OutboxEvent.create(
            "Order",
            savedOrder.getId().toString(),
            "OrderCancelled",
            Map.of(
                "orderId", savedOrder.getId().toString(),
                "reason", "User request",
                "cancelledAt", Instant.now().toString()
            )
        );

        outboxRepository.save(outboxEvent);

        return savedOrder;
    }
}

6. Event Ordering Guarantees and Idempotency

6.1 Event Ordering Guarantees

Event ordering in the Outbox Pattern follows these principles:

  • Ordering is guaranteed for the same Aggregate: By using aggregate_id as the Kafka partition key, events for the same entity are stored in order within the same partition
  • Ordering is not guaranteed across different Aggregates: This is by design and aligns with the loose coupling principle between microservices
  • Exercise caution when changing partition counts: Changing the number of partitions for a Kafka topic breaks the existing key-to-partition mapping. It is safest to avoid changing partition counts in production

6.2 Idempotency Handling (Python Consumer Example)

import json
import uuid
from datetime import datetime
from kafka import KafkaConsumer
from sqlalchemy import create_engine, text
from sqlalchemy.orm import Session

# Processing history table for idempotency guarantees
# CREATE TABLE processed_events (
#     event_id UUID PRIMARY KEY,
#     processed_at TIMESTAMP NOT NULL DEFAULT NOW()
# );

engine = create_engine('postgresql://user:pass@localhost:5432/inventorydb')

consumer = KafkaConsumer(
    'events.Order',
    bootstrap_servers=['localhost:9092'],
    group_id='inventory-service',
    auto_offset_reset='earliest',
    enable_auto_commit=False,          # Manual commit for precise control
    value_deserializer=lambda m: json.loads(m.decode('utf-8')),
    max_poll_records=100,
)

def process_order_event(event_data: dict, event_id: str) -> None:
    """Process events with idempotency guarantees"""
    with Session(engine) as session:
        with session.begin():
            # 1. Check if the event has already been processed
            result = session.execute(
                text("SELECT 1 FROM processed_events WHERE event_id = :id"),
                {"id": event_id}
            )
            if result.fetchone():
                print(f"Skipping already processed event: {event_id}")
                return

            # 2. Execute business logic
            event_type = event_data.get('eventType', '')

            if event_type == 'OrderCreated':
                items = event_data.get('items', [])
                for item in items:
                    session.execute(
                        text("""
                            UPDATE inventory
                            SET reserved_quantity = reserved_quantity + :qty
                            WHERE product_id = :product_id
                              AND available_quantity >= :qty
                        """),
                        {"qty": item['quantity'], "product_id": item['productId']}
                    )

            elif event_type == 'OrderCancelled':
                order_id = event_data.get('orderId')
                session.execute(
                    text("""
                        UPDATE inventory i
                        SET reserved_quantity = reserved_quantity - oi.quantity
                        FROM order_items oi
                        WHERE oi.order_id = :order_id
                          AND oi.product_id = i.product_id
                    """),
                    {"order_id": order_id}
                )

            # 3. Record processing history (same transaction)
            session.execute(
                text("""
                    INSERT INTO processed_events (event_id, processed_at)
                    VALUES (:id, :now)
                """),
                {"id": event_id, "now": datetime.utcnow()}
            )

    print(f"Event processed successfully: {event_id} ({event_type})")


# Main consumer loop
print("Starting event consumer...")
try:
    for message in consumer:
        try:
            event_data = message.value
            # Extract event_id from headers set by Debezium Outbox Event Router
            headers = {k: v.decode('utf-8') for k, v in message.headers}
            event_id = headers.get('id', str(uuid.uuid4()))

            process_order_event(event_data, event_id)

            # Commit offset only after successful processing
            consumer.commit()

        except Exception as e:
            print(f"Event processing failed: {e}")
            # On failure, send to DLQ (Dead Letter Queue) or apply retry logic
            # Here we simply log and move on to the next event

except KeyboardInterrupt:
    print("Consumer shutting down")
finally:
    consumer.close()

The key to idempotency handling is wrapping the event processing and the processing history record within the same database transaction. This ensures that if a failure occurs during event processing, the processing history is not recorded, allowing the same event to be reprocessed upon restart. Events that have already been processed are identified via the processing history table and skipped.

7. Polling vs. CDC: A Comparison

Comparison CriteriaPolling ApproachCDC Approach (Debezium)
LatencyDepends on polling interval (seconds to minutes)Near real-time at millisecond level
DB LoadPeriodic SELECT query overheadMinimal load via WAL/binlog reading
Implementation ComplexityLow (CRON + SQL)High (Kafka Connect + Debezium)
Infrastructure RequirementsOnly DB neededKafka + Kafka Connect + Debezium
DELETE DetectionNot possible (requires soft delete)Possible (recorded in log)
Intermediate State CaptureNot possible (only last state)Possible (captures all changes)
Ordering GuaranteesTimestamp-based (fragile)Log-based (robust)
ScalabilityDB connection overhead increasesKafka Connect horizontally scalable
Operational ComplexityLowHigh (requires Kafka cluster management)
Failure RecoverySimple retry based on status columnSophisticated recovery based on Kafka offsets
Best Suited ForSmall scale, tolerant of higher latencyLarge scale, real-time processing required

When first adopting the Outbox Pattern, starting with the polling approach and incrementally transitioning to CDC as traffic grows is a valid strategy. Even the polling approach fully resolves the Dual Write problem, and may be sufficient if latency requirements are on the order of seconds.

8. Debezium vs. Maxwell vs. Canal

Comparison CriteriaDebeziumMaxwellCanal
Supported DBsPostgreSQL, MySQL, MongoDB, SQL Server, Oracle, Cassandra, etc.MySQL onlyMySQL only
ArchitectureDistributed via Kafka ConnectSingle Java processSingle Java process
Message BrokersKafka (default), Pulsar, NATS, etc.Kafka, RabbitMQ, Redis, etc.Kafka, RocketMQ, etc.
Outbox SupportNative via EventRouter SMTRequires custom implementationRequires custom implementation
Schema EvolutionSchema Registry integrationLimitedLimited
ScalabilityHorizontally scalable via Kafka Connect distributed modeLimited to single processLimited to single process
Configuration ComplexityHigh (requires Kafka Connect knowledge)Low (simple configuration)Medium
CommunityVery active (Red Hat sponsored)SmallChina-centric (Alibaba)
Operational MaturityHighMediumMedium
Best Suited ForLarge scale, multi-DB, enterpriseSmall services using only MySQLMySQL-based Chinese ecosystem

Selection Guide: For most production environments, Debezium is the recommended choice. It supports a wide range of databases, achieves high availability through Kafka Connect's distributed mode, and provides native Outbox Event Router support. If you only use MySQL and need a quick PoC, Maxwell's simplicity is appealing. Canal was developed by Alibaba and is primarily used within the Chinese ecosystem, with relatively lower adoption internationally.

9. Failure Scenarios and Recovery Procedures

9.1 Scenario 1: Debezium Connector Failure

Symptoms: The connector status in Kafka Connect changes to FAILED, and new events are no longer published to Kafka.

Common Causes: DB connection loss, WAL slot deletion, schema change compatibility issues, inability to connect to Kafka brokers, etc.

Recovery Procedure:

# 1. Check connector status
curl -s http://localhost:8083/connectors/order-outbox-connector/status | jq .

# 2. Attempt to restart the connector task
curl -X POST http://localhost:8083/connectors/order-outbox-connector/tasks/0/restart

# 3. If restart fails, delete and re-register the connector
curl -X DELETE http://localhost:8083/connectors/order-outbox-connector
curl -X POST http://localhost:8083/connectors \
  -H "Content-Type: application/json" \
  -d @order-outbox-connector.json

# 4. Check PostgreSQL WAL slot status
psql -c "SELECT * FROM pg_replication_slots;"

# 5. Re-create slot if necessary
psql -c "SELECT pg_drop_replication_slot('order_outbox_slot');"
# The slot is automatically created when the connector is re-registered

Important Note: If Debezium is stopped for an extended period while the WAL slot remains active, PostgreSQL cannot delete WAL files after that slot's position, which can fill up the disk. You must monitor WAL size and set an upper limit using the max_slot_wal_keep_size parameter.

9.2 Scenario 2: Kafka Broker Failure

Symptoms: The Debezium connector cannot publish events, and consumers cannot consume events.

Recovery Procedure:

  1. Restore the Kafka broker
  2. Debezium automatically resumes from the last successfully committed offset (managed by Kafka Connect's offset management)
  3. Consumers also resume from the last committed offset
  4. With the CDC approach, no data loss occurs as long as the database's WAL is preserved

9.3 Scenario 3: Consumer Processing Failure

Symptoms: A specific event repeatedly fails processing, preventing the consumer from making progress (poison pill).

Recovery Procedure:

  1. Apply a retry policy (maximum 3-5 attempts with exponential backoff)
  2. After exceeding the maximum retries, route the message to a DLQ (Dead Letter Queue) topic
  3. Manually analyze messages in the DLQ, fix the issue, and republish to the original topic

9.4 Scenario 4: Outbox Table Bloat

Symptoms: The Outbox table grows to millions of rows, degrading database performance.

Recovery Procedure:

  1. With the CDC approach, delete rows that have been captured after a certain retention period
  2. For PostgreSQL, apply partitioning and DROP old partitions
  3. Run periodic VACUUM operations to clean up dead tuples
-- Daily partitioned Outbox table
CREATE TABLE outbox_events (
    id              UUID NOT NULL DEFAULT gen_random_uuid(),
    aggregate_type  VARCHAR(255) NOT NULL,
    aggregate_id    VARCHAR(255) NOT NULL,
    event_type      VARCHAR(255) NOT NULL,
    payload         JSONB NOT NULL,
    created_at      TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
) PARTITION BY RANGE (created_at);

-- Automatic daily partition creation (using pg_partman)
SELECT partman.create_parent(
    'public.outbox_events',
    'created_at',
    'native',
    'daily'
);

-- Automatically drop partitions older than 7 days
UPDATE partman.part_config
SET retention = '7 days', retention_keep_table = false
WHERE parent_table = 'public.outbox_events';

10. Production Operations Checklist

Debezium/CDC Configuration:

  • Verify that PostgreSQL's wal_level is set to logical and the server has been restarted
  • Set max_replication_slots and max_wal_senders generously above the number of connectors
  • Configure max_slot_wal_keep_size to prevent WAL disk exhaustion
  • Always set Debezium connector's heartbeat.interval.ms to ensure offset updates
  • Choose snapshot.mode carefully (initial for first deployment, schema_only for subsequent restarts)

Event Design:

  • Include all information the consumer needs in the event payload (self-contained events)
  • Design for schema evolution: allow field additions but avoid removing existing fields
  • Use aggregate_id as the Kafka partition key to guarantee event ordering for the same entity
  • Keep event size under 1MB (Kafka's default maximum message size)

Consumer Implementation:

  • Implement idempotency in all consumers (using a processed_events table)
  • Disable auto.commit and commit offsets manually
  • Configure a DLQ to prevent processing stalls caused by poison pill messages
  • Set appropriate session timeout and heartbeat interval for consumer groups

Monitoring:

  • Periodically check Debezium connector status (RUNNING/FAILED)
  • Monitor WAL slot size and disk usage
  • Monitor event publishing latency (CDC lag)
  • Monitor consumer lag
  • Monitor Outbox table size and row count
  • Set up alerts for message counts in the DLQ topic

Failure Preparedness:

  • Run Kafka Connect in distributed mode so tasks are automatically reassigned when a worker node fails
  • Periodically test the connector delete-and-re-register recovery procedure
  • Document WAL slot-related failure scenarios in the operations manual
  • Automate end-to-end testing of the entire pipeline

References

  1. Debezium Official Docs - Outbox Event Router - Complete configuration options and usage for the Outbox Event Router SMT
  2. Debezium Blog - Reliable Microservices Data Exchange With the Outbox Pattern - Principles of microservices data exchange using the Outbox Pattern and Debezium
  3. Thorben Janssen - Implementing the Outbox Pattern with CDC using Debezium - Hands-on guide for implementing the Outbox Pattern in JPA/Hibernate environments
  4. Decodable - Revisiting the Outbox Pattern - A 2024 re-evaluation of the Outbox Pattern and analysis of alternatives
  5. Upsolver - Debezium vs Maxwell - Detailed analysis of CDC tool comparison and selection criteria
  6. RisingWave - Debezium vs Other CDC Tools - Comprehensive comparison of Debezium with competing CDC tools
  7. DEV Community - CDC Maxwell vs Debezium - Architecture and feature comparison between Maxwell and Debezium
  8. Medium - Change Data Capture vs Outbox Pattern - Analysis of the differences between CDC and the Outbox Pattern and appropriate use-case scenarios

Quiz

Q1: What is the main topic covered in "Microservices Data Synchronization with the Outbox Pattern and CDC: A Practical Debezium Guide"?

A comprehensive guide to microservices data synchronization using the Outbox Pattern and CDC (Change Data Capture).

Q2: Describe the Outbox Pattern Architecture. 1.1 Core Idea The core idea of the Outbox Pattern is straightforward: instead of publishing events directly to a message broker, write them to an Outbox table within the same transaction as the business data.

Q3: Describe the Outbox Table Design. 2.1 Basic Schema 2.2 Key Design Considerations Use aggregate_id as the Kafka partition key: Events with the same aggregate_id land in the same partition, ensuring event ordering for that entity.

Q4: What are the key aspects of CDC Concepts and How It Works? 3.1 What Is CDC? CDC (Change Data Capture) is a technology that captures database changes (INSERT, UPDATE, DELETE) in real time and delivers them to external systems. The key to CDC is reading the database's transaction log directly.

Q5: What are the key steps for Debezium Installation and Connector Configuration?

4.1 Debezium Overview Debezium is an open-source CDC platform led by Red Hat that runs on top of the Kafka Connect framework. It supports a wide range of databases including PostgreSQL, MySQL, MongoDB, SQL Server, Oracle, Cassandra, and Vitess.