- Published on
MSA 트랜잭션 아웃박스 패턴으로 중복·유실 막기
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
MSA에서 서비스 간 상태를 이벤트로 동기화할 때 가장 자주 터지는 장애는 중복 발행과 유실입니다. 특히 DB 트랜잭션 커밋과 메시지 브로커 발행이 서로 다른 시스템에 걸쳐 있을 때, 둘을 완벽히 원자적으로 묶기 어렵습니다. 그 결과 “DB에는 주문이 생성됐는데 이벤트는 안 나감”, 혹은 “이벤트는 나갔는데 DB는 롤백됨” 같은 불일치가 생깁니다.
트랜잭션 아웃박스(Transactional Outbox) 패턴은 이 문제를 현실적으로 해결하는 대표 해법입니다. 핵심은 간단합니다.
- 도메인 변경과 함께
outbox테이블에 이벤트 레코드를 같은 DB 트랜잭션으로 저장 - 별도 퍼블리셔가 outbox를 읽어 브로커(Kafka 등)에 발행
- 소비자는 멱등 처리로 중복을 흡수
이 글에서는 아웃박스가 왜 필요한지, 어떤 설계 포인트가 중복·유실을 실제로 막는지, 운영에서 자주 놓치는 함정은 무엇인지까지 한 번에 정리합니다.
문맥상 Kafka 기반 구현을 더 깊게 보고 싶다면 다음 글도 함께 참고하세요.
왜 중복·유실이 생기나: Dual write의 본질
가장 흔한 안티패턴은 아래 흐름입니다.
- 애플리케이션이 DB에 주문 저장
- DB 커밋 성공
- 애플리케이션이 Kafka에
OrderCreated발행
겉보기엔 문제 없어 보이지만, 2)와 3) 사이에서 네트워크 오류, 프로세스 크래시, 타임아웃이 발생하면 이벤트가 유실됩니다. 반대로 발행은 성공했는데 애플리케이션이 “성공 응답을 받기 전에” 재시도하면 같은 이벤트가 중복 발행될 수 있습니다.
브로커의 exactly-once 옵션만으로는 해결이 안 되는 경우가 많습니다. 이유는 “DB 커밋”과 “브로커 발행”을 하나의 원자적 단위로 묶기 어렵기 때문입니다. 과거에는 2PC 같은 분산 트랜잭션을 떠올리지만, 운영 복잡도와 성능/가용성 비용 때문에 MSA에서는 대부분 기피합니다.
트랜잭션 아웃박스 패턴의 핵심 아이디어
아웃박스 패턴은 dual write를 다음처럼 바꿉니다.
- 애플리케이션은 브로커에 직접 발행하지 않고,
outbox테이블에 이벤트를 쌓는다. - 도메인 데이터 변경과 outbox insert를 같은 로컬 트랜잭션으로 묶는다.
- 퍼블리셔(별도 프로세스)가 outbox를 읽어 브로커에 발행한다.
이렇게 하면 “DB에는 반영됐는데 이벤트는 유실”이라는 클래스의 문제가 크게 줄어듭니다. 왜냐하면 이벤트가 DB에 남아 있으므로, 퍼블리셔가 재시도하면 결국 발행되기 때문입니다.
다만 아웃박스는 중복을 0으로 만들기보다는, 중복이 발생해도 시스템이 안전하도록 설계하는 쪽에 가깝습니다. 즉 At-least-once 발행 + 멱등 소비가 표준 조합입니다.
아웃박스 테이블 설계: 최소 컬럼으로도 운영 가능한가
아웃박스 스키마는 단순해 보이지만, 운영에서 필요한 필드가 빠지면 관측/복구가 어려워집니다. 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,
headers jsonb not null default '{}'::jsonb,
created_at timestamptz not null default now(),
published_at timestamptz,
publish_attempts int not null default 0,
last_error text
);
create index idx_outbox_unpublished
on outbox_event (published_at, created_at)
where published_at is null;
설계 포인트는 다음과 같습니다.
id는 이벤트의 전역 유니크 키입니다. 소비자 중복제거의 기준이 됩니다.aggregate_type,aggregate_id는 디버깅과 재처리 범위를 좁히는 데 유용합니다.published_at,publish_attempts,last_error는 운영에서 “왜 안 나가냐”를 추적하는 핵심입니다.idx_outbox_unpublished같은 부분 인덱스는 폴링 성능을 좌우합니다.
payload는 JSON으로 두는 경우가 많지만, 스키마 진화가 잦고 타입 안전성이 중요하면 Avro/Protobuf를 쓰고 payload에는 바이너리나 스키마 버전을 함께 저장하기도 합니다.
애플리케이션 코드: 도메인 변경과 outbox 저장을 한 트랜잭션으로
Spring 기반 의사코드로 핵심만 보면 아래 구조입니다.
@Transactional
public void createOrder(CreateOrderCommand cmd) {
Order order = orderRepository.save(Order.create(cmd));
OutboxEvent evt = OutboxEvent.of(
UUID.randomUUID(),
"Order",
order.getId().toString(),
"OrderCreated",
Map.of("orderId", order.getId(), "amount", order.getAmount()),
Map.of("traceId", MDC.get("traceId"))
);
outboxRepository.save(evt);
}
여기서 중요한 건 “이 메서드가 성공하면” 주문과 outbox 이벤트가 항상 같이 커밋된다는 점입니다. 즉, 애플리케이션 프로세스가 죽어도 outbox 레코드는 남아 재발행의 근거가 됩니다.
퍼블리셔 구현 2가지: 폴링 vs CDC
아웃박스에서 브로커로 옮기는 방식은 크게 두 가지입니다.
1) 폴링 퍼블리셔(가장 단순, 가장 흔함)
주기적으로 미발행 레코드를 가져와 발행하고, 성공하면 published_at을 업데이트합니다.
-- 배치로 가져올 때 동시성 안전하게 잠그기
select *
from outbox_event
where published_at is null
order by created_at
limit 100
for update skip locked;
for update skip locked는 다중 워커가 떠 있어도 같은 행을 중복으로 집지 않게 해줍니다. PostgreSQL에서 특히 강력한 패턴입니다.
발행 후 업데이트는 반드시 “해당 행을 잡은 트랜잭션”에서 처리합니다.
update outbox_event
set published_at = now(),
publish_attempts = publish_attempts + 1,
last_error = null
where id = :id;
실패 시에는 publish_attempts와 last_error를 남기고, 백오프 정책을 적용합니다(예: publish_attempts 기반 지수 백오프).
2) CDC(Change Data Capture)
Debezium 같은 CDC로 outbox 테이블의 insert를 캡처해서 Kafka로 흘립니다. 장점은 폴링 부하가 없고 지연이 낮을 수 있다는 점입니다. 단점은 운영 복잡도가 증가하고, 스키마/커넥터 관리가 필요하다는 점입니다.
팀의 운영 성숙도가 높지 않다면 폴링부터 시작해도 충분히 좋은 결과를 얻는 경우가 많습니다.
중복을 “없애는” 게 아니라 “안전하게” 만드는 법
아웃박스는 재시도 구조를 만들기 때문에 중복 가능성을 내포합니다. 예를 들어 퍼블리셔가 Kafka에 발행은 했지만, published_at 업데이트 전에 장애가 나면 같은 이벤트가 다시 발행될 수 있습니다.
따라서 소비자는 아래 중 하나(또는 조합)로 멱등성을 보장해야 합니다.
1) 소비자 측 중복제거 테이블(Processed events)
이벤트 id를 키로 저장하고, 이미 처리한 이벤트면 스킵합니다.
create table processed_event (
event_id uuid primary key,
processed_at timestamptz not null default now()
);
소비자 트랜잭션에서 다음 순서로 처리합니다.
processed_event에event_idinsert 시도- 유니크 제약 위반이면 이미 처리한 이벤트이므로 ACK 후 종료
- insert 성공이면 비즈니스 로직 처리
이때도 “비즈니스 처리”와 “processed_event 기록”은 같은 트랜잭션으로 묶는 게 일반적입니다.
2) 비즈니스 키 기반 멱등
예: orderId로 최종 상태를 upsert하거나, 상태 전이를 단조롭게 설계합니다. 다만 이 방식은 이벤트 종류가 늘어날수록 예외 케이스가 많아지고, 운영 중 데이터 모델 변경에 취약할 수 있습니다.
실무에서는 이벤트 id 기반 중복제거가 가장 예측 가능하고 장애 대응이 쉽습니다.
순서 보장과 재처리: “정확한 순서”가 필요하면 무엇이 달라지나
많은 서비스는 “최종 일관성”만 맞으면 되고, 이벤트 순서는 크게 중요하지 않습니다. 하지만 결제/정산/재고처럼 순서가 중요해지는 도메인이 있습니다.
이 경우 고려할 포인트는 다음입니다.
- Kafka라면
aggregate_id를 메시지 키로 사용해 같은 엔티티는 같은 파티션으로 가게 한다. - outbox 조회 시에도
aggregate_id별로 순서를 유지하도록 설계하거나, 최소한created_at과 단조 증가 키(예: DB 시퀀스)를 함께 둔다. - 재처리 시 특정 aggregate만 골라 재발행할 수 있도록 outbox에 식별자를 반드시 남긴다.
또한 이벤트 스키마에는 version을 넣어 진화(필드 추가/의미 변경) 시 소비자 호환성을 유지하는 것이 좋습니다.
운영 체크리스트: 장애는 보통 디테일에서 난다
아웃박스는 개념은 단순하지만, 운영에서 자주 터지는 문제는 대부분 디테일에서 발생합니다.
퍼블리셔 병목과 DB 부하
- 폴링 주기를 너무 짧게 하면 DB에 불필요한 부하가 걸립니다.
- 반대로 너무 길면 이벤트 지연이 커집니다.
- 해결:
limit기반 배치,skip locked, 적절한 인덱스, 워커 수 조절
outbox 테이블 무한 증가
- 발행 완료 행을 영구히 쌓아두면 스토리지/인덱스가 비대해집니다.
- 해결:
published_at기준 TTL 아카이빙/파티셔닝(월 단위) 또는 주기적 purge
퍼블리셔의 중복 발행과 소비자 멱등 누락
- “아웃박스 도입했는데 중복이 왜 나와요?”는 정상입니다.
- 해결: 소비자 멱등(Processed events) 없으면 아웃박스는 반쪽짜리입니다.
DB 커넥션 고갈
퍼블리셔 워커가 커넥션을 많이 잡아먹으면 API 트래픽과 경쟁합니다. 커넥션 풀 튜닝이 필요할 수 있습니다.
사가(Saga)와의 관계: 아웃박스는 “이벤트 전달”을 맡는다
사가 패턴은 분산 트랜잭션을 “보상 트랜잭션”으로 풀어내는 오케스트레이션/코레오그래피 전략입니다. 아웃박스는 그 사가에서 필요한 이벤트 전달을 신뢰성 있게 만드는 기반으로 자주 같이 쓰입니다.
정리하면 역할이 다릅니다.
- 아웃박스:
이벤트를 잃지 않고(유실 방지) 결국 전달하는 전달 계층의 신뢰성 - 사가:
여러 서비스에 걸친 비즈니스 프로세스를 완료/보상으로 수렴
둘을 같이 쓸 때도 소비자 멱등은 여전히 필수입니다.
결론: 아웃박스의 목표는 “정확히 한 번”이 아니라 “망가져도 복구 가능한 전달”
MSA에서 중복·유실 문제를 완전히 제거하는 은탄환은 드뭅니다. 트랜잭션 아웃박스 패턴은 현실적인 비용으로 다음을 달성합니다.
- 도메인 변경과 이벤트 기록을 로컬 트랜잭션으로 묶어 유실을 크게 줄인다.
- 퍼블리셔 재시도로 결국 발행되게 만들어 복구 가능성을 높인다.
- 소비자 멱등으로 중복을 안전하게 흡수한다.
도입 순서는 보통 outbox 테이블 + 폴링 퍼블리셔 + 소비자 중복제거까지를 한 세트로 잡는 것이 좋습니다. 여기까지 갖추면, 장애가 나도 데이터가 남고 재처리가 가능해지며 “중복·유실로 인한 장기 불일치”를 실질적으로 줄일 수 있습니다.