왜 Outbox 패턴이 필요한가

마이크로서비스 환경에서 DB 저장과 메시지 발행을 동시에 보장하는 것은 어렵다. 아래 코드처럼 작성하면 언제든지 데이터 불일치가 발생할 수 있다.

// 위험한 패턴 — DB 커밋 후 Kafka 발행 실패 가능
@Transactional
public void placeOrder(Order order) {
    orderRepository.save(order);     // DB 저장 성공
    kafkaTemplate.send("orders", order); // 발행 실패 시 불일치 발생
}

두 가지 실패 시나리오:

시나리오 1: DB 저장 성공 → Kafka 발행 실패
  결과: DB에는 주문 있음, 다른 서비스는 주문 모름 → 데이터 불일치

시나리오 2: Kafka 발행 성공 → DB 커밋 실패 (rollback)
  결과: DB에는 주문 없음, 다른 서비스는 주문 처리 시작 → 유령 이벤트

분산 트랜잭션(2PC)으로 해결하려 하면 성능 문제와 가용성 감소를 초래한다. Outbox 패턴은 이 문제를 단일 DB 트랜잭션으로 해결한다.


Outbox 패턴 동작원리

핵심 아이디어

비즈니스 데이터와 발행할 이벤트를 같은 DB 트랜잭션으로 저장한다. 별도 프로세스가 Outbox 테이블을 읽어 Kafka로 발행한다.

┌─────────────────────────────────────────┐
│              Application                │
│                                         │
│  @Transactional                         │
│  ┌──────────────┐  ┌──────────────────┐ │
│  │ orders 테이블 │  │ outbox 테이블    │ │
│  │ INSERT order │  │ INSERT event     │ │
│  └──────────────┘  └──────────────────┘ │
│         ↑ 같은 트랜잭션 (원자적 보장)        │
└─────────────────────────────────────────┘
                 ↓
┌─────────────────────────────────────────┐
│           Message Relay                 │
│  outbox 테이블 폴링 또는 CDC             │
│  → Kafka 발행 → outbox 레코드 삭제/마킹  │
└─────────────────────────────────────────┘
                 ↓
         Kafka Topic

Outbox 테이블 스키마

CREATE TABLE outbox (
    id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    aggregate_type  VARCHAR(255) NOT NULL,  -- 'Order', 'Payment' 등
    aggregate_id    VARCHAR(255) NOT NULL,  -- 엔티티 ID
    event_type      VARCHAR(255) NOT NULL,  -- 'OrderPlaced', 'OrderCancelled'
    payload         JSONB        NOT NULL,  -- 이벤트 본문
    created_at      TIMESTAMP    NOT NULL DEFAULT now(),
    status          VARCHAR(20)  NOT NULL DEFAULT 'PENDING', -- PENDING / SENT
    sent_at         TIMESTAMP
);

CREATE INDEX idx_outbox_status_created ON outbox(status, created_at);

Spring + JPA 구현

@Entity
@Table(name = "outbox")
public class OutboxEvent {
    @Id
    private UUID id = UUID.randomUUID();
    private String aggregateType;
    private String aggregateId;
    private String eventType;
    @Column(columnDefinition = "jsonb")
    private String payload;
    private LocalDateTime createdAt = LocalDateTime.now();
    private String status = "PENDING";
    private LocalDateTime sentAt;
}

@Service
@RequiredArgsConstructor
public class OrderService {

    private final OrderRepository orderRepository;
    private final OutboxRepository outboxRepository;
    private final ObjectMapper objectMapper;

    @Transactional
    public void placeOrder(OrderCommand command) {
        // 1. 비즈니스 로직
        Order order = Order.create(command);
        orderRepository.save(order);

        // 2. 같은 트랜잭션 내 Outbox 저장
        OutboxEvent event = OutboxEvent.builder()
            .aggregateType("Order")
            .aggregateId(order.getId().toString())
            .eventType("OrderPlaced")
            .payload(objectMapper.writeValueAsString(new OrderPlacedEvent(order)))
            .build();
        outboxRepository.save(event);
        // 트랜잭션 커밋 시 두 INSERT가 원자적으로 반영됨
    }
}

Message Relay (폴링 방식)

@Component
@RequiredArgsConstructor
public class OutboxMessageRelay {

    private final OutboxRepository outboxRepository;
    private final KafkaTemplate<String, String> kafkaTemplate;

    @Scheduled(fixedDelay = 1000) // 1초마다 폴링
    @Transactional
    public void relay() {
        List<OutboxEvent> pending = outboxRepository
            .findTop100ByStatusOrderByCreatedAtAsc("PENDING");

        for (OutboxEvent event : pending) {
            try {
                String topic = resolveTopicName(event.getAggregateType());
                kafkaTemplate.send(topic, event.getAggregateId(), event.getPayload())
                    .get(5, TimeUnit.SECONDS); // 동기 대기

                event.markSent();
                outboxRepository.save(event);
            } catch (Exception e) {
                log.error("Outbox relay failed for event {}", event.getId(), e);
                // 실패 시 PENDING 유지 → 다음 폴링에 재시도
            }
        }
    }
}

폴링 방식의 한계

문제 설명
지연 폴링 주기만큼 발행 지연 발생
DB 부하 주기적 SELECT/UPDATE로 DB 부하 증가
확장 어려움 다중 인스턴스 배포 시 중복 처리 위험

이를 해결하는 것이 CDC(Change Data Capture) 방식이다.


CDC (Change Data Capture)

CDC란?

DB의 변경 이력(binlog, WAL 등)을 실시간으로 캡처하여 다른 시스템에 전달하는 기술이다. 애플리케이션 코드 변경 없이 DB 레벨에서 변경사항을 스트리밍한다.

┌──────────────┐    binlog/WAL    ┌──────────────┐    ┌───────────┐
│  MySQL /     │ ─────────────→  │   Debezium   │ →  │   Kafka   │
│  PostgreSQL  │                 │  Connector   │    │   Topic   │
└──────────────┘                 └──────────────┘    └───────────┘

DB별 CDC 메커니즘

MySQL — Binary Log (binlog)

binlog 활성화 필요:
[mysqld]
log_bin = mysql-bin
binlog_format = ROW        # STATEMENT 아닌 ROW 필수
binlog_row_image = FULL    # 변경 전후 전체 행 기록
server_id = 1

PostgreSQL — Write-Ahead Log (WAL)

postgresql.conf:
wal_level = logical         # logical replication 활성화
max_replication_slots = 10
max_wal_senders = 10

논리적 복제 슬롯 생성:
SELECT pg_create_logical_replication_slot('debezium_slot', 'pgoutput');

Debezium CDC 구현

Debezium 아키텍처

┌──────────────────────────────────────────────────────────┐
│                    Kafka Connect                         │
│                                                          │
│  ┌─────────────────────────────────────────────────┐    │
│  │              Debezium Connector                  │    │
│  │                                                  │    │
│  │  ┌──────────────┐    ┌────────────────────────┐ │    │
│  │  │ binlog/WAL   │    │   Event Transformation  │ │    │
│  │  │   Reader     │ →  │   (SMT 적용 가능)       │ │    │
│  │  └──────────────┘    └────────────────────────┘ │    │
│  └─────────────────────────────────────────────────┘    │
└──────────────────────────────────────────────────────────┘
           ↑                          ↓
      MySQL/PostgreSQL           Kafka Topic

Debezium Connector 설정 (MySQL)

{
  "name": "order-outbox-connector",
  "config": {
    "connector.class": "io.debezium.connector.mysql.MySqlConnector",
    "database.hostname": "mysql",
    "database.port": "3306",
    "database.user": "debezium",
    "database.password": "dbz",
    "database.server.id": "184054",
    "database.server.name": "orderdb",
    "database.include.list": "orderservice",
    "table.include.list": "orderservice.outbox",
    "database.history.kafka.bootstrap.servers": "kafka:9092",
    "database.history.kafka.topic": "schema-changes.orderdb",

    "transforms": "outbox",
    "transforms.outbox.type": "io.debezium.transforms.outbox.EventRouter",
    "transforms.outbox.table.field.event.id": "id",
    "transforms.outbox.table.field.event.key": "aggregate_id",
    "transforms.outbox.table.field.event.type": "event_type",
    "transforms.outbox.table.field.event.payload": "payload",
    "transforms.outbox.route.by.field": "aggregate_type",
    "transforms.outbox.route.topic.replacement": "outbox.${routedByValue}"
  }
}

Debezium이 생성하는 이벤트 구조

{
  "before": null,
  "after": {
    "id": "550e8400-e29b-41d4-a716-446655440000",
    "aggregate_type": "Order",
    "aggregate_id": "12345",
    "event_type": "OrderPlaced",
    "payload": "{\"orderId\":\"12345\",\"amount\":50000}",
    "created_at": "2026-05-01T10:00:00",
    "status": "PENDING"
  },
  "op": "c",       // c=create, u=update, d=delete, r=read(snapshot)
  "ts_ms": 1746097200000,
  "source": {
    "db": "orderservice",
    "table": "outbox",
    "server_id": 184054,
    "pos": 123456789
  }
}

EventRouter SMT (Single Message Transformation)

Debezium의 Outbox EventRouter SMT는 outbox 테이블의 INSERT 이벤트를 받아 aggregate_type 컬럼 값을 기반으로 자동으로 라우팅한다.

outbox INSERT (aggregate_type='Order')
  → Kafka topic: outbox.Order

outbox INSERT (aggregate_type='Payment')
  → Kafka topic: outbox.Payment

Outbox vs CDC 비교

구분 Outbox (폴링) Outbox + CDC (Debezium) 직접 CDC
지연 폴링 주기 (수백ms~수초) 수십ms 수십ms
DB 부하 추가 쿼리 부하 binlog 읽기 (낮음) binlog 읽기
코드 변경 필요 (Outbox 저장 로직) 필요 (Outbox 저장 로직) 불필요
이벤트 스키마 명시적 설계 가능 명시적 설계 가능 DB 스키마 의존
멱등성 직접 구현 필요 Kafka at-least-once Kafka at-least-once
운영 복잡도 낮음 중간 (Kafka Connect 필요) 중간
장애 내성 폴링 실패 시 재시도 Connector 재시작으로 복구 복구 가능
구조적 결합도 DB 테이블 의존 DB 테이블 의존 DB 스키마 강결합

언제 무엇을 선택할까

소규모 서비스, 낮은 처리량
  → Outbox 폴링 방식 (단순하고 충분)

대규모 서비스, 낮은 레이턴시 요구
  → Outbox + Debezium CDC

레거시 DB, 코드 변경 불가
  → 직접 CDC (주의: 이벤트 스키마 통제 어려움)

분산 트랜잭션과의 관계

2PC (Two-Phase Commit) 문제

Phase 1 (Prepare):
  Coordinator → DB: "커밋 준비됐나?"
  Coordinator → Kafka: "커밋 준비됐나?"

Phase 2 (Commit):
  Coordinator → DB: "커밋"
  Coordinator → Kafka: "커밋"

문제:
  - Coordinator 장애 시 시스템 전체 블로킹
  - Kafka는 2PC를 지원하지 않음 (XA 트랜잭션 미지원)
  - 성능 저하 (모든 참여자 대기)

Saga 패턴과 Outbox

Outbox는 Saga 패턴과 자연스럽게 조합된다. 각 서비스가 자신의 트랜잭션을 완료하고 다음 서비스를 위한 이벤트를 Outbox에 저장한다.

주문 서비스                재고 서비스              결제 서비스
     │                          │                       │
     │ OrderPlaced 이벤트        │                       │
     │ (Outbox→Kafka)           │                       │
     │ ─────────────────────→   │                       │
     │                          │ StockReserved 이벤트   │
     │                          │ (Outbox→Kafka)        │
     │                          │ ──────────────────→   │
     │                          │                       │ PaymentCompleted
     │                          │                       │ (Outbox→Kafka)

보상 트랜잭션(Compensating Transaction)도 같은 방식으로 Outbox를 통해 발행한다.


실무 고려사항

Outbox 테이블 정리 전략

Outbox 테이블은 지속적으로 쌓이므로 주기적 정리가 필요하다.

-- 24시간 이전 SENT 레코드 삭제
DELETE FROM outbox
WHERE status = 'SENT'
  AND sent_at < NOW() - INTERVAL '24 hours'
LIMIT 10000;
@Scheduled(cron = "0 0 * * * *") // 매 시간
@Transactional
public void cleanupOutbox() {
    int deleted = outboxRepository.deleteByStatusAndSentAtBefore(
        "SENT",
        LocalDateTime.now().minusHours(24)
    );
    log.info("Outbox cleanup: {} records deleted", deleted);
}

멱등성 처리

Outbox 방식은 at-least-once 보장이다. 컨슈머는 반드시 멱등성을 구현해야 한다.

@KafkaListener(topics = "outbox.Order")
@Transactional
public void handleOrderEvent(ConsumerRecord<String, String> record) {
    String eventId = record.headers()
        .lastHeader("debezium.event.id")
        .value().toString();

    // 이미 처리한 이벤트인지 확인
    if (processedEventRepository.existsByEventId(eventId)) {
        log.info("Duplicate event ignored: {}", eventId);
        return;
    }

    // 비즈니스 처리
    processOrder(record.value());

    // 처리 완료 기록
    processedEventRepository.save(new ProcessedEvent(eventId));
}

순서 보장

Outbox에서 같은 aggregate_id를 Kafka 메시지 키로 사용하면 같은 파티션으로 라우팅되어 순서가 보장된다.

kafkaTemplate.send(
    topic,
    event.getAggregateId(),  // 파티셔닝 키 = aggregate_id
    event.getPayload()
);

모니터링 지표

# Prometheus 메트릭 예시
outbox_pending_count         # 미발행 이벤트 수 (높으면 relay 문제)
outbox_relay_duration_ms     # 릴레이 처리 시간
outbox_relay_failure_total   # 릴레이 실패 횟수
debezium_connector_status    # CDC 커넥터 상태
-- Outbox lag 모니터링 쿼리
SELECT
    COUNT(*) AS pending_count,
    MIN(created_at) AS oldest_pending,
    EXTRACT(EPOCH FROM (NOW() - MIN(created_at))) AS lag_seconds
FROM outbox
WHERE status = 'PENDING';

카테고리:

업데이트: