- Published on
DDD 이벤트 중복·순서꼬임? Outbox+Debezium 해법
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서로 다른 마이크로서비스가 느슨하게 결합되려면 이벤트 기반 통신이 자연스럽습니다. DDD(도메인 주도 설계)에서는 애그리거트가 상태를 변경할 때 도메인 이벤트를 생성하고, 이를 다른 바운디드 컨텍스트가 구독해 후속 처리를 수행합니다. 문제는 “도메인 이벤트를 언제, 어떻게 발행하느냐”가 생각보다 까다롭다는 점입니다.
실무에서 가장 자주 터지는 사고는 두 가지입니다.
- 중복 발행(duplicate delivery): 같은 이벤트가 2번 이상 소비되어 결제/적립/재고 차감 같은 부작용이 중복 수행됨
- 순서 꼬임(out-of-order):
OrderCreated보다OrderPaid가 먼저 도착하거나, 같은 주문의 이벤트가 뒤섞여 소비됨
이 글에서는 왜 이런 문제가 생기는지, 그리고 Outbox 패턴 + Debezium(CDC) 조합이 왜 “현실적인 표준 해법”으로 자리 잡았는지, 구현 포인트와 운영 체크리스트까지 한 번에 정리합니다.
> 참고: 이벤트 파이프라인이 불안정하면 결국 API 레벨에서도 502/504, 타임아웃으로 증상이 번집니다. 쿠버네티스/ALB 환경에서 장애가 겉으로 드러나는 양상은 아래 글도 함께 보면 도움이 됩니다. > - AWS ALB 502·504 난사 - 원인별 해결 체크리스트 > - EKS Pod→RDS 504 타임아웃 - SG·NACL·NAT 10분 진단
왜 DDD 이벤트가 중복되거나 순서가 꼬일까?
1) “DB 트랜잭션”과 “메시지 발행”은 원자적으로 묶기 어렵다
가장 흔한 안티패턴은 아래 흐름입니다.
- 애그리거트 변경 → DB 커밋
- 커밋 성공 후 Kafka/RabbitMQ로 이벤트 발행
이때 2번이 네트워크 오류로 실패하면, DB에는 반영됐는데 이벤트는 유실됩니다. 반대로 아래처럼 순서를 바꾸면:
- 이벤트 발행
- DB 커밋
DB 커밋이 실패하면 이벤트는 발행됐는데 실제 상태는 바뀌지 않는 유령 이벤트가 됩니다.
이 문제를 “분산 트랜잭션(2PC)”로 풀려는 시도도 있지만, 운영 복잡도/성능/가용성 비용이 너무 큽니다. 그래서 업계는 보통 최종 일관성 + at-least-once 전달 + 멱등 처리로 현실적인 균형을 잡습니다.
2) at-least-once의 대가: 중복은 정상이다
Kafka Connect, Debezium, 대부분의 메시징/CDC 파이프라인은 장애 상황에서 재시도하며, 이때 중복 전달이 발생할 수 있습니다. 즉 “중복이 생기지 않게”가 아니라 “중복이 생겨도 안전하게”가 목표가 됩니다.
3) 순서 보장은 ‘키 단위’로만 되는 경우가 많다
Kafka는 같은 파티션 내에서는 순서를 보장하지만, 파티션이 다르면 보장하지 않습니다.
- 주문 단위 순서가 필요하다면
orderId를 파티션 키로 고정해야 합니다. - 서비스가 여러 DB 트랜잭션에서 이벤트를 만들거나, 이벤트가 여러 토픽으로 나뉘면, 소비 측에서 순서가 더 쉽게 깨집니다.
4) 소비자 재밸런싱/재시도는 순서를 흔든다
컨슈머 그룹 리밸런싱, 처리 실패 후 재시도(특히 DLQ/재처리), 배치성 폴링 등은 이벤트 처리 시점을 흔들어 순서 문제가 “가끔” 터지게 만듭니다. 이게 가장 디버깅이 어렵습니다.
Outbox 패턴: “발행”을 DB 트랜잭션 안으로 넣기
Outbox 패턴의 핵심은 간단합니다.
- 애그리거트 상태 변경과 **이벤트 기록(outbox 테이블 insert)**을 같은 DB 트랜잭션으로 묶는다.
- 그 다음 outbox 테이블을 읽어 메시지 브로커로 전달한다.
즉, 이벤트 발행을 “외부 시스템 호출”이 아니라 “DB에 레코드로 남기는 것”으로 바꿔 원자성을 확보합니다.
Outbox 테이블 설계 예시
PostgreSQL 기준 예시입니다.
CREATE TABLE outbox_event (
id UUID PRIMARY KEY,
aggregate_type TEXT NOT NULL,
aggregate_id TEXT NOT NULL,
event_type TEXT NOT NULL,
payload JSONB NOT NULL,
occurred_at TIMESTAMPTZ NOT NULL DEFAULT now(),
-- 순서 제어/디버깅을 위해 단조 증가 값을 두는 경우가 많습니다.
-- Postgres라면 BIGSERIAL, 혹은 DB가 제공하는 트랜잭션 로그 위치(LSN)를 활용할 수도 있습니다.
seq BIGSERIAL NOT NULL,
published_at TIMESTAMPTZ NULL
);
CREATE INDEX idx_outbox_unpublished ON outbox_event(published_at) WHERE published_at IS NULL;
CREATE INDEX idx_outbox_aggregate_seq ON outbox_event(aggregate_id, seq);
포인트:
id: 이벤트 고유 ID(멱등 키로 사용)aggregate_id + seq: 같은 애그리거트 내 순서를 재구성할 때 유용published_at: 폴링 방식이면 발행 완료 마킹에 사용(단, Debezium 방식이면 굳이 필요 없을 수 있음)
애그리거트 변경 + outbox insert를 같은 트랜잭션으로
아래는 Spring/JPA 느낌의 의사 코드입니다.
@Transactional
public void payOrder(UUID orderId, Money amount) {
Order order = orderRepository.findByIdForUpdate(orderId)
.orElseThrow();
order.pay(amount);
OrderPaid event = new OrderPaid(order.getId(), amount, Instant.now());
outboxRepository.save(
OutboxEvent.of(
UUID.randomUUID(),
"Order",
order.getId().toString(),
"OrderPaid",
toJson(event)
)
);
// 같은 트랜잭션에서 order 저장 + outbox 저장
orderRepository.save(order);
}
여기까지 하면 “이벤트 유실”은 크게 줄어듭니다. 하지만 outbox 테이블을 어떻게 브로커로 옮길까요?
Debezium: Outbox를 ‘폴링’이 아니라 ‘CDC’로 발행한다
Outbox 테이블을 브로커로 옮기는 방법은 크게 두 가지입니다.
- 폴링 퍼블리셔: 주기적으로 outbox 테이블을 조회해 발행하고
published_at업데이트 - CDC(Change Data Capture): DB 변경 로그를 읽어 outbox insert를 이벤트로 변환해 발행
Debezium은 대표적인 CDC 솔루션입니다. DB의 WAL/binlog를 읽어 “outbox_event에 insert가 발생했다”를 감지하고 Kafka로 흘려보냅니다.
Debezium + Outbox의 장점
- 애플리케이션이 메시지 브로커에 직접 의존하지 않아도 됨(네트워크 장애/재시도 로직 단순화)
- 폴링 쿼리 부하가 없음(대량 트래픽에서 특히 유리)
- 이벤트 유실 가능성이 낮고, 운영 관측성이 좋아짐(커넥터 lag 등)
Debezium Outbox Event Router 개념
Debezium은 outbox 테이블의 행을 읽어 다음을 수행합니다.
payload를 Kafka 메시지 value로aggregate_id를 key로(파티션 키)event_type에 따라 토픽 라우팅(옵션)
즉, “DB에 insert”가 곧 “이벤트 발행”이 됩니다.
중복(duplicate)과 순서(out-of-order)를 어떻게 다룰까?
Outbox+Debezium은 유실을 크게 줄이지만, 중복과 순서 문제를 ‘완전히 제거’하진 않습니다. 대신 다룰 수 있는 형태로 바꿉니다.
1) 중복은 소비자에서 멱등 처리로 막는다
가장 안전한 방법은 이벤트 ID 기반 멱등성입니다.
- 이벤트마다 전역 유니크
eventId를 둔다(outbox의id) - 소비자는 처리 전
processed_event테이블(또는 Redis set 등)에 eventId가 있는지 확인 - 이미 처리했으면 스킵
PostgreSQL로 멱등 처리 예시:
CREATE TABLE processed_event (
consumer_name TEXT NOT NULL,
event_id UUID NOT NULL,
processed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (consumer_name, event_id)
);
소비자 처리 흐름(의사 코드):
def handle_event(consumer_name, event_id, payload):
with db.transaction():
inserted = db.execute(
"""
INSERT INTO processed_event(consumer_name, event_id)
VALUES (%s, %s)
ON CONFLICT DO NOTHING
""",
[consumer_name, event_id]
)
if inserted.rowcount == 0:
return # duplicate
# 부작용 로직(적립, 상태 변경 등)
apply_side_effect(payload)
핵심은 부작용 로직과 processed_event insert를 같은 트랜잭션으로 묶는 것입니다.
2) 순서가 정말 중요하면 “키 단위 순서”를 강제한다
대부분의 비즈니스는 “전체 이벤트의 총순서”가 아니라 “같은 애그리거트 단위 순서”만 필요합니다.
- Kafka를 쓴다면
aggregate_id를 message key로 사용해 같은 파티션으로 가게 만듭니다. - Debezium Outbox Router에서
aggregate_id를 key로 설정합니다.
그럼에도 소비자 재시도/DLQ 재처리로 순서가 깨질 수 있습니다. 이때는 이벤트에 seq(단조 증가)나 version(애그리거트 버전)을 넣고, 소비자가 다음을 수행합니다.
- 마지막으로 처리한
seq를 저장 - 더 작은
seq가 오면 무시(중복/지연) - 더 큰
seq가 “건너뛰어” 오면 보류하거나(버퍼링) 재조회로 보정
간단한 테이블 예시:
CREATE TABLE aggregate_event_checkpoint (
consumer_name TEXT NOT NULL,
aggregate_id TEXT NOT NULL,
last_seq BIGINT NOT NULL,
PRIMARY KEY (consumer_name, aggregate_id)
);
3) “순서가 필요 없는 이벤트”로 설계를 바꾸는 것도 해법
가끔은 기술로 순서를 맞추는 것보다, 이벤트 자체를 **상태 스냅샷/사실(fact)**로 만들어 순서 민감도를 낮추는 게 더 좋습니다.
- (나쁨)
StatusChanged: CREATED -> PAID -> SHIPPED를 순서대로 강제 - (대안)
OrderPaid(orderId, paidAt, amount)처럼 사실 이벤트로 두고, 소비자는 “이미 결제됐는지”를 idempotent하게 처리
Outbox+Debezium 아키텍처 구성 예시
전체 흐름
- 서비스 A가 주문 결제를 처리
- 동일 트랜잭션에서
orders업데이트 +outbox_eventinsert - Debezium 커넥터가 DB WAL/binlog를 읽어 outbox insert를 감지
- Kafka 토픽으로
OrderPaid발행 - 서비스 B(적립), 서비스 C(배송)가 구독
- 각 소비자는
processed_event로 멱등 처리
운영에서 꼭 보는 지표/알람
- Debezium connector lag(소스 DB 변경 대비 Kafka 반영 지연)
- Kafka consumer lag(다운스트림 처리 지연)
- outbox 테이블 row 증가 추이(청소/보관 정책 필요)
- DLQ 유입률(스키마 변경, 데이터 품질 문제의 신호)
실무 구현 디테일: 자주 놓치는 함정들
1) outbox 테이블 청소 전략
Debezium을 쓰면 outbox는 “발행 큐”가 아니라 “발행 로그”에 가깝습니다. 너무 오래 쌓이면 비용이 됩니다.
- 일정 기간 보관 후 파티션 드롭/아카이빙
occurred_at기준 파티셔닝(월 단위 등)- GDPR/개인정보가 payload에 섞이지 않도록 주의(가능하면 식별자만 담고 조회는 별도)
2) 스키마 진화(호환성) 전략
이벤트는 API보다 더 오래 살아남습니다.
- payload에
schemaVersion필드 추가 - 소비자는 구버전/신버전을 모두 처리할 수 있게 방어
- 가능하면 “필드 추가는 허용, 필드 삭제/의미 변경은 금지” 같은 규칙을 둠
3) 정확히-한번(exactly-once)을 과신하지 말 것
Kafka EOS, 트랜잭션 프로듀서 등으로 정확히-한번에 가까워질 수는 있지만, 전체 시스템(소비자 부작용, 외부 API 호출, DB 업데이트)을 포함하면 여전히 경계가 많습니다.
현실적인 목표는 보통 이 조합입니다.
- 전달: at-least-once
- 소비: 멱등
- 순서: 키 단위 보장 + 필요 시 seq/version으로 방어
4) 장애 시 “겉 증상”은 502/504로 보일 수 있다
이벤트 기반으로 비동기화했더라도, 결국 사용자 요청은 어딘가에서 동기 경로를 탑니다. 예를 들어 결제 후 “적립 내역 조회”가 바로 이어지면, 적립 컨슈머 지연이 API 타임아웃으로 보일 수 있습니다.
인프라 레벨에서 502/504가 늘어날 때는 애플리케이션/이벤트 지연도 같이 의심해야 합니다.
최소 구성 예시: “Outbox 테이블 + Debezium + Kafka + 멱등 소비자”
아래는 구현을 시작할 때의 체크리스트 형태로 정리한 최소 구성입니다.
Producer(도메인 서비스)
- 애그리거트 변경 트랜잭션 안에서 outbox insert
- 이벤트에
eventId,aggregateId,eventType,occurredAt, (선택)seq/version
Debezium
- outbox 테이블만 캡처
- key를
aggregateId로 설정(파티션 키) - 토픽 라우팅 규칙(예:
event_type별 토픽) 결정
Consumer
processed_event로 멱등 처리- (필요 시)
last_seq체크로 순서 방어 - 실패 시 재시도/백오프/DLQ 설계
결론
DDD에서 도메인 이벤트는 “설계가 예쁘면 끝”이 아니라, 발행의 신뢰성이 확보되어야 진짜 가치가 생깁니다. 중복과 순서 꼬임은 이벤트 기반 시스템에서 자연스러운 현상이며, 이를 억지로 없애기보다 Outbox로 유실을 막고, Debezium CDC로 발행을 안정화한 뒤, 소비자에서 멱등/순서 방어로 마무리하는 것이 가장 실전적인 접근입니다.
요약하면:
- 유실 방지: Outbox(같은 DB 트랜잭션)
- 발행 안정화: Debezium CDC
- 중복 대응: eventId 기반 멱등
- 순서 대응: aggregateId 파티션 키 + seq/version 방어
이 4가지를 기준으로 설계하면, “가끔씩만 터지는” 이벤트 중복/순서 버그를 구조적으로 줄이고, 운영 관측성까지 함께 가져갈 수 있습니다.