- Published on
MSA에서 Outbox·CDC로 이중쓰기 0건 만들기
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
모놀리식에서 MSA로 넘어오면 가장 먼저 부딪히는 벽 중 하나가 이른바 이중쓰기 문제입니다. 예를 들어 주문 서비스가 주문을 DB에 저장한 뒤, 결제 서비스가 구독하는 OrderCreated 이벤트를 메시지 브로커로 발행해야 한다고 합시다. 애플리케이션 코드에서 DB 트랜잭션 커밋과 메시지 발행을 순차로 처리하면, 둘 중 하나만 성공하는 순간이 반드시 생깁니다.
- DB는 커밋됐는데 이벤트 발행은 실패한다면, 다운스트림은 주문 생성 사실을 영원히 모릅니다.
- 이벤트는 발행됐는데 DB가 롤백된다면, 다운스트림은 존재하지 않는 주문을 처리합니다.
분산 트랜잭션으로 묶으면 되지 않느냐는 질문이 나오지만, 현실의 MSA에서는 성능, 가용성, 운영 복잡도 때문에 2PC 같은 접근은 피하는 경우가 많습니다. 대신 DB 트랜잭션 하나로 원자성을 확보하고, 이벤트 전달은 비동기로 “결국 전달된다”를 보장하는 패턴을 씁니다. 그 대표가 Outbox 패턴이며, Outbox를 실제로 흘려보내는 방법으로 CDC를 결합하면 이중쓰기 0건에 가까운 구조를 만들 수 있습니다.
이 글은 Outbox·CDC 조합을 통해 “DB 쓰기와 이벤트 발행을 분리하되, 일관성은 잃지 않는” 구현을 실전 관점으로 정리합니다. Saga로 전체 비즈니스 흐름을 엮는 방법은 별도 글인 MSA Saga 보상 트랜잭션 설계 실전 가이드도 함께 참고하면 좋습니다.
이중쓰기(dual write)의 본질
이중쓰기는 단순히 “DB에도 쓰고 Kafka에도 쓴다” 수준의 문제가 아닙니다. 핵심은 다음 두 가지가 서로 다른 실패 도메인이라는 점입니다.
- DB 트랜잭션: 로컬 ACID, 커밋 여부가 명확
- 메시지 발행: 네트워크, 브로커 상태, 프로듀서 설정에 따라 성공/실패/타임아웃이 섞임
게다가 애플리케이션이 재시작되거나, 동일 요청이 재시도되거나, 컨슈머가 재밸런싱되면 “중복”과 “순서” 문제가 추가됩니다. 결국 목표는 다음처럼 정리됩니다.
- 서비스는 로컬 트랜잭션만으로 상태 변경을 확정한다.
- 이벤트는 반드시(또는 최소 한 번) 전달된다.
- 중복 이벤트가 와도 안전하게 처리된다(멱등성).
- 운영자가 재처리, 백필, 모니터링을 할 수 있다.
Outbox 패턴: 같은 트랜잭션에 이벤트를 적재
Outbox 패턴은 애플리케이션이 메시지 브로커에 직접 발행하지 않습니다. 대신 같은 DB 트랜잭션 안에서 비즈니스 테이블 업데이트와 함께 Outbox 테이블에 이벤트 레코드를 적재합니다.
즉, “상태 변경”과 “이벤트 생성”이 동일 커밋에 묶이므로 이중쓰기가 사라집니다.
Outbox 테이블 설계 예시
PostgreSQL 기준으로 자주 쓰는 스키마 예시는 아래와 같습니다.
create table outbox_events (
id uuid primary key,
aggregate_type text not null,
aggregate_id text not null,
event_type text not null,
payload jsonb not null,
headers jsonb,
occurred_at timestamptz not null default now(),
published_at timestamptz,
publish_status text not null default 'PENDING',
dedup_key text,
trace_id text
);
create index idx_outbox_pending
on outbox_events (publish_status, occurred_at);
create unique index uq_outbox_dedup
on outbox_events (dedup_key)
where dedup_key is not null;
포인트는 다음입니다.
payload는 다운스트림이 필요한 데이터만 담고, 가능하면 이벤트 버전(schemaVersion)을 포함합니다.dedup_key는 “같은 비즈니스 이벤트가 중복 적재되는 것”을 DB 레벨에서 막고 싶을 때 유용합니다.published_at,publish_status는 폴링 방식에서 특히 중요합니다. CDC 방식만 쓸 거라면 필수는 아니지만, 운영 편의상 남겨두는 경우가 많습니다.
트랜잭션에서 주문 생성과 Outbox 적재를 함께
Spring Boot 예시로 보면 대략 이런 형태가 됩니다. 아래 코드에서 중요한 점은 메시지 브로커 클라이언트를 호출하지 않는다는 것입니다.
@Transactional 관련 함정은 Spring Boot 3 @Transactional 전파·롤백 함정도 함께 보면 실수 확률이 줄어듭니다.
@Transactional
public Order createOrder(CreateOrderCommand cmd) {
Order order = orderRepository.save(Order.create(cmd));
OutboxEvent evt = OutboxEvent.builder()
.id(UUID.randomUUID())
.aggregateType("Order")
.aggregateId(order.getId().toString())
.eventType("OrderCreated")
.payload(objectMapper.valueToTree(Map.of(
"orderId", order.getId().toString(),
"amount", order.getAmount(),
"currency", order.getCurrency(),
"occurredAt", Instant.now().toString(),
"schemaVersion", 1
)))
.dedupKey("OrderCreated:" + order.getId())
.traceId(MDC.get("traceId"))
.build();
outboxRepository.save(evt);
return order;
}
이렇게 하면 “DB 커밋 성공”은 곧 “Outbox 이벤트도 존재”를 의미합니다. 남은 과제는 Outbox를 어떻게 브로커로 전달하느냐입니다.
Outbox 전달 방식 2가지: 폴링 vs CDC
Outbox를 브로커로 전달하는 방식은 크게 둘입니다.
- 폴링 퍼블리셔
- 애플리케이션(또는 별도 워커)이 주기적으로
PENDING이벤트를 조회해 브로커로 발행하고PUBLISHED로 마킹 - 구현이 단순하지만, 폴링 주기, 락 경합, 대량 처리에서 튜닝 포인트가 많습니다.
- CDC 기반 퍼블리셔
- DB의 WAL/binlog를 읽어 Outbox 테이블의 변경을 스트리밍으로 가져가 브로커로 전달
- 애플리케이션이 “발행” 책임에서 빠지고, DB 커밋 로그 기반으로 이벤트가 흘러가므로 이중쓰기 제거 효과가 가장 큽니다.
이 글의 주제는 Outbox·CDC 조합이므로, CDC를 중심으로 설명하되 폴링 방식의 운영 포인트도 짚겠습니다.
CDC로 Outbox를 Kafka로 흘리기: Debezium 전형
가장 흔한 조합은 PostgreSQL 또는 MySQL과 Debezium, Kafka Connect입니다.
구성은 대략 다음과 같습니다.
- 서비스는 비즈니스 테이블과 Outbox 테이블을 같은 DB에 저장
- Debezium 커넥터가 DB WAL/binlog에서 Outbox 테이블 변경을 캡처
- Kafka 토픽으로 이벤트를 발행
- 다운스트림 컨슈머는 토픽을 구독해 자신의 DB를 갱신하거나 후속 처리를 수행
Debezium Outbox Event Router 개념
Debezium에는 Outbox 라우팅을 돕는 SMT가 있습니다. 핵심은 “Outbox 테이블의 한 row를 Kafka 메시지 한 건으로 변환”하고, event_type이나 aggregate_type 같은 컬럼으로 토픽 라우팅을 할 수 있게 하는 것입니다.
예시 설정은 환경에 따라 달라지지만, 형태는 대략 아래처럼 이해하면 됩니다. 부등호가 들어갈 수 있는 값은 모두 인라인 코드로 처리합니다.
{
"name": "orders-outbox-connector",
"config": {
"connector.class": "io.debezium.connector.postgresql.PostgresConnector",
"database.hostname": "postgres",
"database.port": "5432",
"database.user": "debezium",
"database.password": "secret",
"database.dbname": "orders",
"database.server.name": "ordersdb",
"table.include.list": "public.outbox_events",
"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": "${routedByValue}.events"
}
}
이렇게 하면 OrderCreated 같은 이벤트가 Order.events 같은 토픽으로 흘러가도록 구성할 수 있습니다.
CDC에서 “0건”에 가까워지는 이유
CDC는 애플리케이션 프로세스가 메시지를 발행하다가 죽는 문제를 원천적으로 피합니다. DB에 커밋만 되면, WAL/binlog에 기록이 남고, Debezium이 이를 결국 읽어 이벤트를 내보냅니다.
다만 “정확히 한 번”을 기대하면 안 됩니다. 현실적으로는 다음을 목표로 잡습니다.
- Outbox 적재는 정확히 한 번에 가깝게(트랜잭션과 유니크 키로)
- Kafka 전달은 최소 한 번
- 컨슈머 처리는 멱등적으로
중복, 순서, 스키마 진화: 실전에서 터지는 지점
Outbox·CDC를 도입하면 이중쓰기는 줄지만, 이벤트 기반 시스템의 고전 이슈는 남습니다. 여기서 설계를 잘못하면 “이중쓰기 0건” 대신 “중복 처리 장애”가 옵니다.
1) 컨슈머 멱등성: 반드시 필요
중복 이벤트는 다음 상황에서 발생할 수 있습니다.
- 커넥터 재시작, 오프셋 커밋 타이밍
- Kafka 리밸런싱
- 다운스트림 재처리
컨슈머는 이벤트의 고유 ID로 이미 처리했는지 기록해야 합니다. 가장 단순한 방법은 “처리 로그 테이블”을 두고 유니크 제약으로 막는 것입니다.
create table inbox_dedup (
event_id uuid primary key,
processed_at timestamptz not null default now()
);
컨슈머 처리 흐름은 다음처럼 가져갑니다.
- 트랜잭션 시작
inbox_dedup에event_idinsert 시도- 유니크 충돌이면 이미 처리한 이벤트이므로 종료
- 아니면 비즈니스 업데이트 수행 후 커밋
이 패턴은 흔히 Inbox 패턴이라고도 부릅니다.
2) 순서 보장: aggregate 단위로만 기대
Kafka는 같은 파티션 내에서만 순서를 보장합니다. 따라서 aggregate_id를 메시지 키로 잡아 같은 주문의 이벤트가 같은 파티션으로 가게 해야 합니다.
- 키를
orderId로 설정 - 컨슈머는 같은 키의 이벤트가 순서대로 온다는 가정 하에 상태머신을 단순화 가능
단, 서로 다른 주문 간의 전역 순서를 기대하면 안 됩니다.
3) 스키마 진화: payload에 버전 필수
이벤트는 계약입니다. payload에 schemaVersion을 넣고, 컨슈머는 버전별 파서를 유지하거나, 하위 호환을 지키는 방식으로 진화시켜야 합니다.
- 필드 추가는 대체로 안전
- 필드 삭제, 의미 변경은 위험
- 날짜 포맷, 통화 단위 같은 도메인 표현 변경은 특히 위험
폴링 기반 Outbox 퍼블리셔를 쓴다면
CDC를 못 쓰는 환경도 있습니다. 예를 들어 관리형 DB에서 WAL 접근 제약이 있거나, 운영 조직이 Kafka Connect를 허용하지 않는 경우입니다. 그럴 때는 폴링 워커로도 충분히 “이중쓰기 0건”에 가까운 체계를 만들 수 있습니다.
핵심은 동시성 제어입니다. 여러 워커가 같은 이벤트를 집어가지 않게 해야 합니다.
PostgreSQL이라면 for update skip locked가 흔한 해법입니다.
with cte as (
select id
from outbox_events
where publish_status = 'PENDING'
order by occurred_at
limit 100
for update skip locked
)
update outbox_events
set publish_status = 'PROCESSING'
where id in (select id from cte)
returning *;
그 다음 워커가 브로커 발행에 성공하면 PUBLISHED로, 실패하면 재시도를 위해 PENDING으로 되돌리거나 별도 FAILED 상태로 보내고 백오프를 적용합니다.
이 방식은 구현이 쉬운 대신 다음을 꼭 챙겨야 합니다.
- 워커 장애 시
PROCESSING이벤트가 영원히 남지 않게 lease 타임아웃 컬럼 추가 - 배치 크기, 폴링 주기, 인덱스 튜닝
- 발행 성공 후 상태 업데이트가 실패할 수 있으므로, 여기서도 중복 발행을 고려한 설계
운영 체크리스트: “이중쓰기 0건”을 유지하는 관측 포인트
Outbox·CDC는 도입보다 운영이 더 중요합니다. 아래 지표를 대시보드로 만들어두면 장애가 작아집니다.
1) Outbox 적체량과 지연
PENDING또는 미처리 row 수occurred_at기준 가장 오래된 이벤트의 지연 시간
적체가 증가하면 다운스트림 장애, 커넥터 장애, 브로커 장애 중 하나일 가능성이 큽니다.
2) CDC 커넥터 상태와 오프셋
- 커넥터 태스크 상태
- 재시작 횟수
- 레플리케이션 슬롯 지연(예: PostgreSQL replication lag)
서비스가 계속 재시작되는 상황에서 원인 추적 방법은 systemd 서비스가 계속 재시작될 때 원인 추적 같은 접근이 그대로 도움이 됩니다. 커넥터도 결국 프로세스이기 때문입니다.
3) DLQ와 재처리 전략
- 스키마 불일치, 데이터 오류로 컨슈머가 계속 실패하는 이벤트는 DLQ로 격리
- DLQ 재처리 도구를 미리 준비
- 재처리 시에도 멱등성 유지
흔한 실수 5가지
- Outbox를 “그냥 로그 테이블”로만 보고 정합성 요구사항을 안 세움
- 최소 한 번 전달, 중복 가능, 멱등 처리라는 전제를 문서화해야 합니다.
- 이벤트에 고유 ID를 안 넣음
event_id가 없으면 컨슈머 멱등 처리가 어렵습니다.
- 이벤트 payload에 내부 DB 스키마를 그대로 노출
- 다운스트림이 업스트림 DB 컬럼에 결합됩니다. 필요한 최소 정보만 노출하세요.
- 대량 트래픽에서 Outbox 인덱스/파티셔닝을 안 함
occurred_at기반 파티셔닝이나 아카이빙 전략이 필요할 수 있습니다.
- “정확히 한 번”을 가정하고 보상/재처리 플랜이 없음
- 결국 장애는 납니다. 재처리 시나리오를 런북으로 만들어두세요.
정리: Outbox·CDC는 이중쓰기를 없애는 가장 현실적인 답
Outbox는 “이벤트를 DB 트랜잭션 안에 넣는다”로 이중쓰기 문제를 구조적으로 제거합니다. CDC는 그 Outbox를 애플리케이션 바깥에서, DB 커밋 로그 기반으로 안정적으로 전달합니다. 결과적으로 다음을 얻습니다.
- 서비스는 로컬 트랜잭션만 신경 쓰면 됨
- 이벤트 발행 실패로 인한 정합성 깨짐이 사실상 사라짐
- 중복은 가능하지만, 멱등 처리로 안정화 가능
다음 단계로는
- 컨슈머 멱등성(Inbox) 정교화
- 이벤트 스키마 관리(버전, 호환성)
- Saga로 비즈니스 프로세스 완결성 확보
를 진행하면, “이중쓰기 0건”을 넘어 “운영 가능한 이벤트 기반 MSA”에 가까워집니다.