트랜잭셔널 아웃박스 패턴 (Transactional Outbox Pattern)
분산 서버(MSA) 구조에서
데이터 저장과 이벤트 발행을 하나의 트랜잭션으로 처리하기 위해 이벤트를 DB에 먼저 저장하고,
별도 프로세스에서 실제 메시지를 발행하는 방식
1. 문제 상황: 이중 쓰기(Double Write) 문제
- 서비스 로직에서 DB에 데이터를 저장한 후, 메시지 브로커(Kafka, RabbitMQ 등)에 이벤트를 발행하는 구조에서는
- 이중 쓰기 문제가 발생할 수 있음.
- DB 저장은 성공했지만 이벤트 발행이 실패할 수 있음
- 반대로 이벤트 발행은 성공했지만 DB 트랜잭션이 롤백될 수도 있음
- 이로 인해 시스템 간 데이터 불일치 발생 가능
@Transactional
public void propagateSample() {
Product product = new Product("신규 상품");
productRepository.save(product);
eventPublisher.propagate(new NewProductEvent(product.getId()));
}
2. 해결 방법: 트랜잭셔널 아웃박스 패턴
- DB와 메시지 브로커에 동시에 쓰지 않고,
- 이벤트를 Outbox 테이블에 먼저 저장한 후, 별도 프로세스가 해당 테이블을 읽어 이벤트를 발행하는 구조
- ProductOutboxEvent는 Outbox 테이블의 엔티티
- DB 트랜잭션 내에서 상품 정보와 이벤트 정보를 함께 저장
// 서비스 트랜잭션 내에서 처리
@Transactional
public void propagateSample() {
Product product = new Product("신규 상품");
productRepository.save(product);
ProductOutboxEvent event = new ProductOutboxEvent(product.getId(), "NEW_PRODUCT", LocalDateTime.now(), false);
productOutboxRepository.save(event);
}
3. 이벤트 발행 프로세스: 별도 프로세스에서 처리
- DB에 저장된 이벤트를 주기적으로 조회해 메시지 브로커로 발행하고, 처리 상태를 업데이트
- findUnpublishedEvents()는 아직 전송되지 않은 이벤트를 조회
- 메시지 발행 후 성공 시 published 플래그를 true로 변경
- 실패 시 롤백 없이 레코드를 남겨 재시도 가능
// 스케줄러 기반
@Scheduled(fixedDelay = 1000)
public void publishOutboxEvents() {
List<ProductOutboxEvent> events = productOutboxRepository.findUnpublishedEvents();
for (ProductOutboxEvent event : events) {
try {
eventPublisher.publish(new NewProductEvent(event.getProductId()));
event.markPublished();
productOutboxRepository.save(event);
} catch (Exception e) {
// 로깅 또는 재시도 로직
}
}
}
장점
- DB 저장과 이벤트 저장이 동일 트랜잭션으로 원자성 보장
- 메시지 발행 실패 시에도 이벤트 정보 유실 없음
- 재시도를 통해 안정성 확보
- 시스템 간 데이터 정합성 유지 가능