분산 트랜잭션 — 여러 서비스에 걸친 트랜잭션은 어떻게 관리할까
주문 서비스, 결제 서비스, 재고 서비스가 각각 다른 DB를 사용한다면, 하나의 트랜잭션으로 묶을 수 있을까요?
마이크로서비스 아키텍처에서는 각 서비스가 독립된 DB를 가집니다. @Transactional 하나로는 여러 서비스의 데이터 일관성을 보장할 수 없습니다. 분산 트랜잭션 이 필요한 이유입니다.
왜 @Transactional만으로 안 되는가
주문 서비스 (MySQL) 결제 서비스 (PostgreSQL) 재고 서비스 (MongoDB)
│ │ │
└─── 각각 독립된 DB 트랜잭션 ───┘
@Transactional은 하나의 DB 커넥션 내 에서만 동작합니다. 서로 다른 DB에 걸친 트랜잭션은 관리할 수 없습니다.
2PC (Two-Phase Commit)
전통적인 분산 트랜잭션 방법입니다.
동작 과정
Phase 1 (Prepare):
코디네이터 → 참여자 A: "커밋 준비 됐나?"
코디네이터 → 참여자 B: "커밋 준비 됐나?"
참여자 A → 코디네이터: "준비 완료"
참여자 B → 코디네이터: "준비 완료"
Phase 2 (Commit):
코디네이터 → 참여자 A: "커밋해라"
코디네이터 → 참여자 B: "커밋해라"
2PC의 한계
- **성능 **: 두 번의 통신 라운드트립, 락 보유 시간 증가
- ** 가용성 **: 코디네이터 장애 시 전체 참여자가 블로킹
- ** 확장성 **: 참여자가 늘어날수록 성능이 급격히 저하
- NoSQL: MongoDB, Redis 등은 2PC를 지원하지 않음
이런 한계 때문에 MSA 환경에서는 2PC 대신 SAGA 패턴 을 사용합니다.
SAGA 패턴
SAGA는 각 서비스의 로컬 트랜잭션 을 순차적으로 실행하고, 실패 시 보상 트랜잭션 으로 이전 단계를 되돌리는 패턴입니다.
주문 생성 SAGA 예시
정상 흐름:
1. 주문 생성 → 2. 결제 처리 → 3. 재고 차감 → 4. 배송 요청
실패 시 보상:
3. 재고 차감 실패!
→ 2. 결제 취소 (보상)
→ 1. 주문 취소 (보상)
Choreography (이벤트 기반)
각 서비스가 이벤트를 발행하고, 다른 서비스가 이를 구독하여 자율적으로 동작합니다.
주문 서비스 결제 서비스 재고 서비스
│ │ │
├─ OrderCreated ──→│ │
│ ├─ PaymentDone ───→│
│ │ ├─ StockReduced ──→ 완료
│ │ │
│ │ StockFailed ←──┤
│ PaymentRefunded ←──┤ │
│ │ │
├─ OrderCancelled │ │
// 주문 서비스
@Service
public class OrderService {
@Transactional
public void createOrder(OrderRequest request) {
Order order = orderRepository.save(new Order(request));
eventPublisher.publish(new OrderCreatedEvent(order.getId(), request));
}
@TransactionalEventListener
public void handlePaymentFailed(PaymentFailedEvent event) {
Order order = orderRepository.findById(event.getOrderId()).orElseThrow();
order.cancel(); // 보상 트랜잭션
}
}
이어서 이벤트를 구독하는 리스너를 정의합니다.
// 결제 서비스
@Service
public class PaymentService {
@EventListener
public void handleOrderCreated(OrderCreatedEvent event) {
try {
Payment payment = processPayment(event);
eventPublisher.publish(new PaymentCompletedEvent(payment));
} catch (Exception e) {
eventPublisher.publish(new PaymentFailedEvent(event.getOrderId()));
}
}
}
**Choreography 장점 **: 서비스 간 결합도가 낮음, 새 서비스 추가가 쉬움 **Choreography 단점 **: 전체 흐름 파악이 어려움, 순환 참조 위험
Orchestration (중앙 조율자)
중앙의 오케스트레이터가 전체 흐름을 제어합니다.
@Service
@RequiredArgsConstructor
public class OrderSagaOrchestrator {
private final OrderService orderService;
private final PaymentClient paymentClient;
private final InventoryClient inventoryClient;
public void createOrder(OrderRequest request) {
// Step 1: 주문 생성
Order order = orderService.create(request);
try {
// Step 2: 결제
paymentClient.processPayment(order);
이어서 롤백 처리 로직을 정의합니다.
try {
// Step 3: 재고 차감
inventoryClient.reduceStock(order);
} catch (Exception e) {
// Step 3 실패 → Step 2 보상
paymentClient.refund(order);
orderService.cancel(order);
throw new SagaRollbackException("재고 차감 실패", e);
}
} catch (PaymentException e) {
// Step 2 실패 → Step 1 보상
orderService.cancel(order);
throw new SagaRollbackException("결제 실패", e);
}
}
}
**Orchestration 장점 **: 전체 흐름이 한 곳에서 보임, 디버깅 용이 **Orchestration 단점 **: 오케스트레이터가 단일 장애점(SPOF)이 될 수 있음
Outbox 패턴
로컬 트랜잭션과 이벤트 발행의 원자성을 보장하는 패턴입니다.
문제 상황
@Transactional
public void createOrder(OrderRequest request) {
orderRepository.save(order); // 1. DB 저장 성공
kafkaTemplate.send("order-event", event); // 2. 카프카 전송 실패!
// DB에는 저장됐지만 이벤트는 유실됨
}
Outbox 해결
@Transactional
public void createOrder(OrderRequest request) {
Order order = orderRepository.save(new Order(request));
// 같은 트랜잭션에서 outbox 테이블에 이벤트 저장
outboxRepository.save(new OutboxEvent(
"OrderCreated",
objectMapper.writeValueAsString(new OrderCreatedEvent(order.getId()))
));
// DB 트랜잭션이 커밋되면 둘 다 저장 보장
}
// 별도 프로세스: outbox 테이블을 폴링하여 메시지 브로커에 발행
@Scheduled(fixedDelay = 1000)
@Transactional
public void publishOutboxEvents() {
List<OutboxEvent> events = outboxRepository.findByPublishedFalse();
for (OutboxEvent event : events) {
kafkaTemplate.send("order-events", event.getPayload());
event.markAsPublished();
}
}
또는 CDC(Change Data Capture) 를 사용하여 Debezium 같은 도구가 outbox 테이블의 변경을 감지하고 자동으로 카프카에 발행할 수도 있습니다.
멱등성 보장
분산 시스템에서는 메시지가 중복 전달 될 수 있습니다. 같은 메시지를 여러 번 처리해도 결과가 동일해야 합니다.
@Service
public class PaymentEventHandler {
@Transactional
public void handleOrderCreated(OrderCreatedEvent event) {
// 멱등성 체크: 이미 처리한 이벤트인지 확인
if (processedEventRepository.existsByEventId(event.getEventId())) {
log.info("이미 처리된 이벤트: {}", event.getEventId());
return;
}
Payment payment = processPayment(event);
paymentRepository.save(payment);
// 처리 완료 기록
processedEventRepository.save(new ProcessedEvent(event.getEventId()));
}
}
패턴 비교
| 항목 | 2PC | SAGA Choreography | SAGA Orchestration | Outbox |
|---|---|---|---|---|
| 일관성 | 강한 일관성 | 최종 일관성 | 최종 일관성 | 최종 일관성 |
| 결합도 | 높음 | 낮음 | 중간 | 낮음 |
| 복잡도 | 중간 | 높음 | 중간 | 중간 |
| 성능 | 낮음 | 높음 | 중간 | 높음 |
주의할 점
1. 보상 트랜잭션은 항상 성공하는 것이 아니다
SAGA 패턴에서 보상 트랜잭션(예: 결제 취소) 자체도 실패할 수 있습니다. 보상 트랜잭션이 실패하면 시스템이 불일치 상태에 빠지므로, 보상 로직에도 재시도와 모니터링을 반드시 구현해야 합니다. 최후의 수단으로 수동 개입이 가능한 대시보드를 준비해두는 것이 실무에서는 필수입니다.
2. Outbox 테이블의 폴링 주기와 CDC 지연을 고려해야 한다
Outbox 패턴은 "최종 일관성"이므로 이벤트 발행에 지연이 발생합니다. 폴링 주기를 너무 길게 잡으면 반영이 느려지고, 너무 짧게 잡으면 DB에 부하를 줍니다. CDC(Debezium)를 사용하더라도 커넥터 장애나 지연이 발생할 수 있으므로, 이벤트 전파 지연에 대한 모니터링 알림을 반드시 설정해야 합니다.
3. 멱등성 키 저장소가 단일 장애점이 될 수 있다
멱등성 보장을 위해 processedEventRepository 같은 저장소를 사용하는데, 이 저장소 자체에 장애가 발생하면 중복 처리 여부를 판단할 수 없어 메시지 처리가 멈추거나 중복 실행됩니다. 멱등성 키 저장소의 가용성과 TTL 정책을 사전에 설계해야 합니다.
정리
- 2PC 는 강한 일관성을 보장하지만 성능과 가용성에 한계가 있습니다.
- SAGA 패턴 은 로컬 트랜잭션 + 보상 트랜잭션으로 최종 일관성을 달성합니다.
- Choreography 는 이벤트 기반 자율 동작, Orchestration 은 중앙 조율자가 흐름을 관리합니다.
- Outbox 패턴 은 로컬 트랜잭션과 이벤트 발행의 원자성을 보장합니다.
- 분산 시스템에서는 멱등성 보장이 필수입니다.