이벤트 기반 아키텍처에서 데이터 정합성을 지키는 현실적인 방법
MSA나 이벤트 기반 아키텍처를 이야기할 때 자주 등장하는 주제가 있다. 바로 **“DB 저장과 이벤트 발행을 어떻게 일관성 있게 처리할 것인가”**이다.
겉으로 보기에는 단순하다. 주문이 생성되면 주문 데이터를 저장하고, 그 뒤에 OrderCreated 이벤트를 발행하면 된다. 하지만 실무에서는 이 단순한 흐름이 자주 깨진다.
예를 들어 주문 데이터는 정상적으로 저장되었는데 Kafka 전송이 실패하면 어떻게 될까?
DB에는 주문이 존재하지만, 다른 서비스들은 그 주문이 생성된 사실을 알지 못한다. 반대로 이벤트는 발행되었는데 DB 트랜잭션이 롤백되면 더 심각하다. 존재하지 않는 주문을 기준으로 후속 처리가 시작될 수 있다.
이런 문제를 해결하기 위해 등장한 대표적인 방식이 아웃박스 패턴(Outbox Pattern) 이다.
왜 아웃박스 패턴이 필요한가
전통적인 서비스 구조에서는 하나의 요청 안에서 다음과 같은 코드를 쉽게 작성한다.
kafkaTemplate.send("order.created", event);
코드만 보면 자연스럽다.
하지만 실제로는 두 작업의 성격이 다르다.
- orderRepository.save(order) 는 로컬 DB 트랜잭션
- kafkaTemplate.send(...) 는 외부 메시지 브로커와의 통신
즉, 하나는 내 DB 안의 작업이고, 다른 하나는 네트워크를 타는 외부 시스템 호출이다.
이 둘은 기본적으로 하나의 트랜잭션으로 묶이지 않는다.
그래서 다음과 같은 문제가 생긴다.
1. DB 저장 성공, 이벤트 발행 실패
주문은 생성되었지만 재고, 알림, 정산 서비스는 이 사실을 모른다.
2. 이벤트 발행 성공, DB 롤백
존재하지 않는 주문에 대해 후속 처리가 시작될 수 있다.
3. 재시도 과정에서 중복 이벤트 발생
같은 이벤트가 여러 번 소비될 수 있다.
이벤트 기반 아키텍처에서 가장 무서운 문제는 **“데이터는 바뀌었는데 이벤트가 안 나갔다”**는 상황이다.
아웃박스 패턴은 바로 이 문제를 줄이기 위한 방법이다.
아웃박스 패턴이란
아웃박스 패턴은 한 문장으로 정리하면 이렇다.
도메인 데이터 저장과 이벤트 정보를 같은 DB 트랜잭션 안에서 함께 저장하고, 이후 별도 프로세스가 이벤트를 안전하게 발행하도록 하는 패턴이다.
즉, 이벤트를 바로 Kafka로 보내지 않는다.
먼저 Outbox 테이블에 이벤트를 저장한다.
예를 들어 주문 생성 시 다음 두 작업을 동일 트랜잭션 안에서 처리한다.
- orders 테이블에 주문 저장
- outbox_events 테이블에 OrderCreated 이벤트 저장
그리고 트랜잭션이 커밋된 이후, 별도의 퍼블리셔가 outbox_events를 읽어서 Kafka나 RabbitMQ로 이벤트를 발행한다.
동작 방식
흐름은 보통 다음과 같다.
1단계. 서비스 로직 수행
사용자가 주문을 생성한다.
2단계. 비즈니스 데이터와 이벤트를 함께 저장
하나의 트랜잭션 안에서:
- orders 테이블에 주문 저장
- outbox_events 테이블에 이벤트 저장
3단계. 커밋 완료
둘 다 정상적으로 커밋되면 “주문도 있고, 이벤트 기록도 있는 상태”가 된다.
4단계. 아웃박스 퍼블리셔 실행
스케줄러나 전용 프로세스가 outbox_events에서 미발행 이벤트를 조회한다.
5단계. 메시지 브로커로 발행
Kafka 등으로 이벤트를 전송한다.
6단계. 발행 상태 업데이트
성공하면 PUBLISHED 상태로 변경하거나 발행 시각을 기록한다.
핵심 아이디어
아웃박스 패턴의 핵심은 복잡하지 않다.
“이벤트 발행을 즉시 하지 말고, 먼저 DB 안에 안전하게 남겨두자.”
이렇게 하면 메시지 브로커가 잠시 죽어 있어도 괜찮다.
DB에는 이미 이벤트가 저장되어 있으므로, 나중에 복구되었을 때 다시 발행하면 된다.
즉, 아웃박스 패턴은 즉시성보다 신뢰성을 선택한 방식이다.
예시 테이블 구조
보통 아웃박스 테이블은 다음과 같은 컬럼을 가진다.
id BIGSERIAL PRIMARY KEY,
aggregate_type VARCHAR(100) NOT NULL,
aggregate_id VARCHAR(100) NOT NULL,
event_type VARCHAR(100) NOT NULL,
payload JSONB NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'INIT',
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
published_at TIMESTAMP NULL
);
예를 들면 다음과 같은 값이 들어간다.
- aggregate_type: Order
- aggregate_id: 주문 ID
- event_type: OrderCreated
- payload: JSON 형태 이벤트 본문
- status: INIT, PUBLISHED, FAILED
이벤트 자체를 메시지 브로커에 바로 보내지 않고, 먼저 이 테이블에 쌓아두는 것이다.
코드 관점에서의 구조
서비스 로직은 대략 이런 형태가 된다.
public void createOrder(CreateOrderCommand command) {
Order order = orderRepository.save(Order.create(command));
OutboxEvent event = OutboxEvent.of(
"Order",
order.getId().toString(),
"OrderCreated",
objectMapper.writeValueAsString(new OrderCreatedEvent(order.getId()))
);
outboxRepository.save(event);
}
중요한 점은 order 저장과 outboxEvent 저장이 같은 트랜잭션이라는 것이다.
즉, 주문 저장이 실패하면 이벤트도 저장되지 않고, 이벤트 저장이 실패하면 주문도 롤백된다.
그다음 퍼블리셔가 이렇게 동작한다.
public void publishOutboxEvents() {
List<OutboxEvent> events = outboxRepository.findTop100ByStatusOrderByCreatedAtAsc("INIT");
for (OutboxEvent event : events) {
try {
kafkaTemplate.send("order.created", event.getPayload());
event.markPublished();
outboxRepository.save(event);
} catch (Exception e) {
// 로그 기록 및 재시도 대상 유지
}
}
}
장점
1. 데이터 정합성 확보
도메인 데이터와 이벤트 정보가 함께 저장되므로
“DB는 반영됐는데 이벤트가 사라지는 문제”를 크게 줄일 수 있다.
2. 메시지 브로커 장애에 강함
Kafka가 일시적으로 죽어 있어도 이벤트는 DB에 남아 있다.
복구 후 재발행하면 된다.
3. 재시도 전략 구현 가능
미발행 상태 이벤트만 다시 조회해서 재처리할 수 있다.
4. 감사 로그 역할도 가능
어떤 이벤트가 언제 생성되고 발행되었는지 추적하기 쉽다.
단점과 주의점
좋은 패턴이지만 만능은 아니다. 실무에서는 아래를 같이 고민해야 한다.
1. 중복 발행 가능성
퍼블리셔가 Kafka 전송에는 성공했는데 상태 업데이트 전에 죽으면, 같은 이벤트가 다시 발행될 수 있다.
그래서 소비자 쪽에는 멱등성(Idempotency) 이 필요하다.
즉, 이벤트 소비자는 “같은 이벤트를 두 번 받아도 한 번 처리한 것처럼 동작”해야 한다.
2. 즉시성 한계
직접 발행이 아니라 outbox를 거치므로 약간의 지연이 생긴다.
보통 수 초 이내 수준이지만, 완전 실시간은 아니다.
3. 테이블 관리 필요
outbox 테이블은 시간이 지나면 계속 커진다.
아카이빙, 삭제 정책, 파티셔닝 등을 고민해야 한다.
4. 운영 복잡도 증가
상태 관리, 재시도, 실패 로그, dead-letter 처리 등 운영 포인트가 늘어난다.
Polling 방식과 CDC 방식
아웃박스 이벤트를 발행하는 방법은 크게 두 가지가 많다.
1. Polling 방식
애플리케이션 스케줄러가 outbox 테이블을 주기적으로 조회해서 발행한다.
장점:
- 구현이 단순하다
- 애플리케이션만으로 구성 가능하다
단점:
- polling 주기만큼 지연이 생긴다
- 대량 처리 시 DB polling 부담이 있다
2. CDC(Change Data Capture) 방식
Debezium 같은 도구가 DB 변경 로그를 읽어서 outbox 이벤트를 Kafka로 보낸다.
장점:
- 더 실시간에 가깝다
- 대량 처리에 유리할 수 있다
단점:
- 인프라가 복잡해진다
- 운영 난이도가 높아진다
초기에는 Polling으로 시작하고, 규모가 커지면 CDC로 확장하는 방식이 현실적이다.
어떤 경우에 특히 유용한가
아웃박스 패턴은 다음과 같은 시스템에서 특히 잘 맞는다.
- 주문 생성 후 결제, 재고, 알림, 배송이 이어지는 커머스 시스템
- 정산 상태 변경 후 회계, 통계, 알림 처리가 이어지는 정산 시스템
- 회원 가입 후 쿠폰 발급, CRM 적재, 메시지 발송이 이어지는 서비스
- 여러 마이크로서비스가 이벤트를 기준으로 연결되는 구조
즉, 하나의 상태 변경이 여러 후속 시스템으로 전파되어야 하는 경우에 거의 필수급으로 검토된다.
아웃박스 패턴과 2PC의 차이
이 문제를 처음 접하면 “그냥 분산 트랜잭션 쓰면 안 되나?”라는 질문이 나온다.
이론상 가능하지만, 실무에서는 2PC(Two-Phase Commit)는 잘 쓰지 않는다.
이유는 단순하다.
- 복잡하다
- 성능 부담이 크다
- 메시지 브로커와의 궁합이 좋지 않다
- 장애 대응이 까다롭다
그래서 실무에서는 강한 일관성을 끝까지 밀기보다,
로컬 트랜잭션 + 재시도 + 멱등성 조합을 선택하는 경우가 많다.
아웃박스 패턴은 그 대표적인 해법이다.
실무에서 같이 붙는 개념들
아웃박스 패턴은 혼자 쓰이지 않는다. 보통 아래 개념들과 같이 간다.
멱등성(Idempotency)
중복 이벤트를 여러 번 받아도 결과가 한 번만 반영되도록 처리
재시도(Retry)
발행 실패 시 일정 횟수 또는 백오프로 재시도
Dead Letter Queue
지속적으로 실패하는 이벤트를 별도 저장소로 분리
모니터링
미발행 건수, 실패 건수, 지연 시간 등을 메트릭으로 수집
즉, 아웃박스 패턴은 단순히 테이블 하나 추가하는 게 아니라
이벤트 신뢰성 운영 체계의 시작점이라고 보는 게 맞다.
정리
아웃박스 패턴은 이벤트 기반 아키텍처에서 매우 현실적인 선택이다.
이 패턴의 목적은 거창하지 않다.
“비즈니스 데이터와 이벤트를 함께 안전하게 기록하고, 나중에라도 반드시 발행되게 하자.”
이 한 가지 목적 때문에 많은 시스템이 outbox를 사용한다.
특히 커머스, 정산, 결제처럼
이벤트 하나가 여러 후속 처리의 출발점이 되는 시스템에서는 아웃박스 패턴의 가치가 크다.
즉시 발행보다 조금 느릴 수는 있어도, 신뢰성과 추적 가능성을 얻는다.
실무 시스템은 결국 “잘 돌아가는 구조”가 중요하다.
아웃박스 패턴은 그 관점에서 볼 때, 이벤트 기반 시스템의 이상론보다 훨씬 현실적인 설계 패턴이다.
한 줄 결론
아웃박스 패턴은 ‘이벤트를 바로 보내지 않고, 먼저 DB에 안전하게 기록한 뒤 확실하게 발행하는 방식’이다.
'Software > Maker(Spring & Python & node)' 카테고리의 다른 글
| 바이브 코딩 유의점과 파인튜닝이 필요한 상황 (0) | 2026.04.22 |
|---|---|
| Java 17은 왜 Kotlin과 잘 맞고, Java 25에서는 무엇을 다시 점검해야 할까 (0) | 2026.04.21 |
| Claude Design의 등장, 그리고 Figma와 Canva의 엇갈린 운명 (2) | 2026.04.21 |
| 지기지우(知己之友) — 한 사람의 마법을 알아보는 눈 (0) | 2026.04.21 |
| Spring AI 기반 RAG 아키텍처 구현 예시 (0) | 2026.04.17 |