핵심 요약
- 원자적 커밋(Atomic Commit) — 비즈니스 로직 처리와 이벤트 발행 의도를 단일 DB 트랜잭션으로 묶어 데이터 정합성을 보장합니다.
- 이중 쓰기(Dual Write) 해결 — 네트워크 불안정으로 인해 메시지 큐 발행이 실패하더라도, DB에 저장된 Outbox 데이터를 통해 최소 한 번(At-least-once) 전송을 보장합니다.
- 폴링 vs CDC — 초기에는 간편한 폴링(Polling) 방식을 사용하되, 트래픽이 증가하면 Debezium 등을 활용한 로그 테일링(Log Tailing) 방식으로 고도화합니다.
목차
- 1. 분산 환경의 난제: 이중 쓰기(Dual Write) 문제
- 2. Outbox 패턴의 아키텍처와 원리
- 3. [심층 구현] Java와 Spring Boot로 만드는 안전한 Outbox
- 4. 프로덕션 레벨의 고려사항: CDC와 멱등성
“주문은 성공했는데 배송 시스템으로 메시지가 안 넘어갔어요” 등 데이터 유실 상황을 방지하는 Transactional Outbox Pattern에 대해 깊이 있게 파헤쳐 보겠습니다.
1. 분산 환경의 난제: 이중 쓰기(Dual Write) 문제
마이크로서비스 환경에서 가장 흔한 실수는 @Transactional 안에서 외부 시스템(Kafka, REST API)을 호출하는 것입니다.
데이터베이스와 메시지 큐는 서로 다른 트랜잭션 자원이기 때문에, 둘 중 하나만 성공하는 상황이 반드시 발생합니다.
실패 시나리오 분석
| 시나리오 | 상황 | 결과 (데이터 불일치) |
|---|---|---|
| DB 커밋 후 메시지 발송 실패 | 주문 데이터는 DB에 저장되었으나, 네트워크 타임아웃으로 Kafka 발행 실패 | 주문은 접수되었으나, 하위 서비스(배송, 결제)가 이를 모름. |
| 메시지 발송 후 DB 롤백 | Kafka 발행은 성공했으나, DB 트랜잭션 커밋 중 제약조건 위반으로 롤백 | 존재하지 않는 주문에 대해 배송이 시작됨 (Ghost Data). |
핵심 포인트: “비즈니스 처리”와 “메시지 발행”을 분리할 수 없는 하나의 원자적 단위(Atomic Unit)로 만들어야 합니다.
2. Outbox 패턴의 아키텍처와 원리
해결책은 로컬 트랜잭션의 힘을 빌리는 것입니다. RDBMS는 단일 트랜잭션 내에서 여러 테이블에 데이터를 넣을 때 완벽한 원자성을 보장합니다. 이 점을 이용해 메시지를 ‘큐’가 아닌 ‘DB 테이블’에 먼저 저장합니다.
처리 흐름
- 트랜잭션 시작: 서비스 로직이 시작됩니다.
- 비즈니스 저장:
Orders테이블에 주문 정보를 INSERT 합니다. - 이벤트 저장:
Outbox테이블에 발행할 메시지(JSON)를 INSERT 합니다. - 트랜잭션 커밋: 위 두 작업이 동시에 확정됩니다. (실패 시 둘 다 롤백)
- 비동기 릴레이: 별도의 프로세스가
Outbox테이블을 읽어 Kafka로 전송하고, 완료 처리를 합니다.
핵심 포인트: 메시지 발행의 신뢰성을 DB 트랜잭션의 신뢰성 수준으로 끌어올리는 전략입니다.
3. [심층 구현] Java와 Spring Boot로 만드는 안전한 Outbox
제공해주신 코드를 기반으로, 실무에서 놓치기 쉬운 JSON 직렬화와 이벤트 구조화 부분을 보강하여 구현해보겠습니다.
3.1. Outbox 엔티티 설계
단순한 Payload 외에도 트레이싱을 위한 메타데이터나 이벤트 타입을 명확히 구분하는 것이 좋습니다.
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Table(name = "outbox_events")
public class OutboxEvent {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String aggregateId; // 도메인 ID (예: Order ID)
@Column(nullable = false)
private String eventType; // 이벤트 타입 (예: OrderCreated)
@Lob
@Column(nullable = false, columnDefinition = "TEXT")
private String payload; // JSON Body
@Column(nullable = false)
private LocalDateTime createdAt;
private boolean processed = false; // 발행 여부 체크
// 정적 팩토리 메서드로 생성 로직 캡슐화
public static OutboxEvent create(String aggregateId, String eventType, String payload) {
OutboxEvent event = new OutboxEvent();
event.aggregateId = aggregateId;
event.eventType = eventType;
event.payload = payload;
event.createdAt = LocalDateTime.now();
return event;
}
public void markAsProcessed() {
this.processed = true;
}
}
3.2. 비즈니스 로직 (원자적 저장)
서비스 계층에서는 ObjectMapper를 사용하여 도메인 객체를 JSON으로 변환해 저장합니다.
이때 반드시 동일한 @Transactional 범위 내에 있어야 합니다.
@Service
@RequiredArgsConstructor
public class OrderService {
private final OrderRepository orderRepository;
private final OutboxRepository outboxRepository;
private final ObjectMapper objectMapper;
@Transactional // 핵심: 주문 저장과 Outbox 저장이 하나의 트랜잭션으로 묶임
public void createOrder(OrderCommand command) {
// 1. 비즈니스 도메인 저장
Order order = new Order(command.getUserId(), command.getProductId());
orderRepository.save(order);
// 2. 이벤트 발행을 위한 Outbox 저장
// Kafka로 바로 보내지 않고 DB에 '기록'만 함
String payload = safeJsonConvert(order);
OutboxEvent outboxEvent = OutboxEvent.create(
order.getId().toString(),
"ORDER_CREATED",
payload
);
outboxRepository.save(outboxEvent);
}
// 예외 처리를 포함한 안전한 변환 메서드
private String safeJsonConvert(Object data) {
try {
return objectMapper.writeValueAsString(data);
} catch (JsonProcessingException e) {
throw new RuntimeException("JSON 변환 오류", e);
}
}
}
3.3. Relay 프로세스 (Polling Publisher 방식)
DB에 쌓인 이벤트를 읽어 Kafka로 전송하는 릴레이(Relay)입니다. 실무에서는 여러 인스턴스가 동시에 같은 이벤트를 읽지 않도록 비관적 락(Pessimistic Lock)이나 Skip Locked 기능을 활용하기도 합니다.
@Component
@RequiredArgsConstructor
@Slf4j
public class OutboxRelay {
private final OutboxRepository outboxRepository;
private final KafkaTemplate<String, String> kafkaTemplate;
@Scheduled(fixedDelay = 1000) // 1초마다 실행
public void relay() {
// 처리되지 않은 이벤트 조회 (실무에선 Limit을 걸어 배치 처리 권장)
List<OutboxEvent> events = outboxRepository.findByProcessedFalse();
for (OutboxEvent event : events) {
try {
// 1. Kafka 전송 (동기식 또는 비동기 콜백 처리)
// 신뢰성을 위해 send().get()으로 동기 대기하거나,
// 콜백 내부에서 상태 업데이트 로직을 수행해야 함
kafkaTemplate.send("orders", event.getAggregateId(), event.getPayload())
.addCallback(
success -> {
log.info("이벤트 발행 성공: {}", event.getId());
// 별도 트랜잭션으로 상태 업데이트 필요
markProcessedInNewTransaction(event.getId());
},
failure -> log.error("이벤트 발행 실패", failure)
);
} catch (Exception e) {
log.error("Relay 중 예외 발생", e);
}
}
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void markProcessedInNewTransaction(Long eventId) {
outboxRepository.findById(eventId).ifPresent(OutboxEvent::markAsProcessed);
}
}
핵심 포인트: 릴레이는 DB 부하를 줄이기 위해 적절한 조회 주기와 배치 크기(Batch Size) 설정이 중요합니다.
4. 프로덕션 레벨의 고려사항: CDC와 멱등성
위의 폴링 방식은 구현이 쉽지만, DB에 지속적인 부하를 줍니다. 이를 개선하기 위해 실무에서는 CDC 기술을 도입합니다.
CDC (Change Data Capture) 활용
Debezium 같은 도구를 사용하면 애플리케이션에서 스케줄러를 돌릴 필요가 없습니다. CDC 커넥터가 DB의 트랜잭션 로그(Binlog)를 실시간으로 감지하여 Kafka로 이벤트를 쏘아줍니다. 애플리케이션 코드는 저장(Insert)에만 집중하면 되므로 결합도가 낮아집니다.
중복 처리와 멱등성 (Idempotency)
Outbox 패턴은 ‘적어도 한 번(At-least-once)’ 전송을 보장합니다. 즉, Kafka로 메시지는 보냈는데 DB 업데이트에 실패하여 재발송되는 경우가 발생할 수 있습니다. 따라서 메시지를 받는 쪽(Consumer)은 반드시 멱등성을 고려해야 합니다.
- 메시지 키 활용: 주문 ID를 키로 사용하여 중복을 감지합니다.
- 처리 이력 테이블: 이미 처리한 메시지 ID를 별도 테이블에 저장하여 중복 처리를 방지합니다.
지금까지 Outbox 패턴을 통해 분산 시스템의 데이터 정합성을 확보하는 방법을 상세히 알아보았습니다. 다음 포스트에서는 이 패턴의 확장판인 Saga 패턴을 통해 여러 마이크로서비스 간의 긴 트랜잭션을 관리하는 법을 다뤄보겠습니다.