- Authors
- Name
- 들어가며: Dual Write 문제
- 1. Outbox 패턴 아키텍처
- 2. Outbox 테이블 설계
- 3. CDC 개념과 동작 원리
- 4. Debezium 설치 및 커넥터 구성
- 5. Kafka Connect 파이프라인
- 6. 이벤트 순서 보장과 멱등성 처리
- 7. Polling 방식 vs CDC 방식 비교
- 8. Debezium vs Maxwell vs Canal 비교
- 9. 실패 시나리오와 복구 절차
- 10. 운영 시 주의사항 체크리스트
- 참고자료

들어가며: 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 테이블의 변경 사항을 메시지 브로커로 전달하는 방식은 크게 두 가지다.
- Polling 방식: 주기적으로 Outbox 테이블을 쿼리하여 미발행 이벤트를 가져온다
- 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 방식 대비 여러 장점이 있다.
- 극도로 낮은 지연시간: 트랜잭션 커밋 후 밀리초 단위로 변경 사항 캡처
- DB 부하 최소화: 인덱스 기반 쿼리가 아닌 로그 스트림을 읽으므로 DB에 추가 부하가 거의 없음
- DELETE 캡처 가능: Polling 방식에서는 삭제된 행을 감지하기 어렵지만, 로그에는 DELETE 이벤트도 기록됨
- 모든 변경 포착: 중간 상태의 변경도 놓치지 않음 (Polling은 간격 사이의 변경을 놓칠 수 있음)
- 스키마 변경 추적: 테이블 구조 변경도 로그에 기록되어 스키마 진화를 처리 가능
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.field를aggregate_type으로 설정하여 도메인별로 Kafka 토픽이 자동 생성된다. 예를 들어 aggregate_type이 'Order'이면events.Order토픽에 발행된다table.field.event.key를aggregate_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 파이프라인의 전체 흐름은 다음과 같다.
- 애플리케이션이 비즈니스 데이터와 Outbox 이벤트를 같은 트랜잭션으로 DB에 기록한다
- Debezium Source Connector가 PostgreSQL의 WAL을 읽어 Outbox 테이블의 변경을 감지한다
- Outbox Event Router SMT가 원본 CDC 이벤트를 도메인별 Kafka 토픽으로 라우팅한다
- 컨슈머 마이크로서비스가 해당 토픽을 구독하여 이벤트를 처리한다
- 필요 시 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 비교
| 비교 항목 | Debezium | Maxwell | Canal |
|---|---|---|---|
| 지원 DB | PostgreSQL, 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 커넥터가 이벤트를 발행하지 못하고, 컨슈머도 이벤트를 소비하지 못한다.
복구 절차:
- Kafka 브로커를 복구한다
- Debezium은 마지막으로 성공한 오프셋부터 자동으로 재개된다 (Kafka Connect의 offset 관리)
- 컨슈머도 마지막 커밋된 오프셋부터 재개된다
- CDC 방식에서는 DB의 WAL이 보존되어 있는 한 데이터 손실이 발생하지 않는다
9.3 시나리오 3: 컨슈머 처리 실패
증상: 특정 이벤트가 반복적으로 처리에 실패하여 컨슈머가 진행하지 못한다 (poison pill).
복구 절차:
- 재시도 정책을 적용한다 (최대 3~5회, 지수 백오프)
- 최대 재시도 초과 시 DLQ(Dead Letter Queue) 토픽으로 전송한다
- DLQ의 메시지를 수동으로 분석하고, 수정 후 원래 토픽으로 재발행한다
9.4 시나리오 4: Outbox 테이블 비대화
증상: Outbox 테이블이 수백만 행으로 커져 DB 성능이 저하된다.
복구 절차:
- CDC 방식에서는 이벤트가 캡처된 후 일정 시간이 지난 행을 삭제한다
- PostgreSQL의 경우 파티셔닝을 적용하여 오래된 파티션을 DROP한다
- 정기적인 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_level을logical로 설정하고 재시작했는지 확인한다 max_replication_slots와max_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 테스트를 자동화한다
참고자료
- Debezium 공식 문서 - Outbox Event Router - Outbox Event Router SMT의 전체 설정 옵션과 사용법
- Debezium 블로그 - Reliable Microservices Data Exchange With the Outbox Pattern - Outbox 패턴과 Debezium을 활용한 마이크로서비스 데이터 교환 원리
- Thorben Janssen - Implementing the Outbox Pattern with CDC using Debezium - JPA/Hibernate 환경에서의 Outbox 패턴 구현 실전 가이드
- Decodable - Revisiting the Outbox Pattern - 2024년 관점에서 Outbox 패턴의 재평가와 대안 분석
- Upsolver - Debezium vs Maxwell - CDC 도구 비교와 선택 기준 상세 분석
- RisingWave - Debezium vs Other CDC Tools - Debezium과 경쟁 CDC 도구의 종합 비교
- DEV Community - CDC Maxwell vs Debezium - Maxwell과 Debezium의 아키텍처 및 기능 비교
- Medium - Change Data Capture vs Outbox Pattern - CDC와 Outbox 패턴의 차이와 적합한 사용 시나리오 분석