성능 최적화 29편 - Saga 패턴으로 여러 서비스에 걸친 트랜잭션 처리하기
11편 Kafka, 23편 서킷 브레이커, 28편 Elasticsearch 동기화까지, 시스템이 여러 서비스로 나뉜 구조를 계속 다뤄왔습니다. 그런데 정작 가장 근본적인 질문 하나를 남겨뒀습니다. 주문, 결제, 재고가 각각 다른 서비스와 다른 데이터베이스에 있다면, 하나의 트랜잭션으로 묶을 수 없는 이 작업들을 어떻게 안전하게 처리할까요? 이번 글에서는 이 문제를 푸는 Saga 패턴을 정리합니다.
1. 단일 데이터베이스에서는 당연했던 것
같은 데이터베이스 안에서는 @Transactional로 여러 작업을 하나로 묶고, 하나라도 실패하면 전부 롤백시킬 수 있었습니다.
@Transactional
public void createOrder(OrderCreateDto dto) {
orderRepository.save(order);
stockService.decreaseStock(dto.getProductId(), dto.getQuantity());
// 재고 차감이 실패하면 주문 저장도 자동으로 롤백됨
}
하지만 주문 서비스와 재고 서비스가 22편에서처럼 서로 다른 데이터베이스를 쓰는 별도 서비스로 나뉘면, 이 롤백이 더 이상 자동으로 일어나지 않습니다.
주문 서비스 : 주문 저장 성공 (DB A에 커밋됨)
재고 서비스 : 재고 부족으로 차감 실패
-> 주문은 이미 커밋됐는데, 재고는 차감되지 않은 불일치 상태 발생
2. 분산 트랜잭션의 어려움 - 2PC의 한계
여러 데이터베이스에 걸친 트랜잭션을 원자적으로 처리하는 전통적인 방법으로 2단계 커밋(2PC, Two-Phase Commit)이 있습니다. 모든 참여자에게 "커밋할 준비가 됐는지" 먼저 묻고, 전부 준비됐을 때만 실제로 커밋하는 방식입니다.
1단계(Prepare) : 조정자가 모든 서비스에 "커밋 가능한가?" 질의
2단계(Commit) : 전부 "가능"이면 전체 커밋, 하나라도 "불가능"이면 전체 롤백
이론적으로는 깔끔하지만, 실무의 MSA 환경에서는 잘 쓰이지 않습니다. 참여하는 모든 서비스가 트랜잭션 완료 시점까지 락을 걸고 대기해야 해서, 서비스 하나만 느려져도 전체가 묶여버리고 처리량이 크게 떨어지기 때문입니다. 23편에서 다룬 것처럼 서비스 간 강한 결합은 장애 전파의 원인이 되기도 합니다.
3. Saga 패턴의 접근 - 보상 트랜잭션
Saga 패턴은 하나의 큰 트랜잭션을, 각 서비스가 처리하는 여러 개의 작은 로컬 트랜잭션으로 나눕니다. 각 단계는 자신의 데이터베이스 안에서 즉시 커밋되고, 중간에 실패하면 이미 완료된 이전 단계들을 하나씩 거꾸로 되돌리는 보상 트랜잭션(Compensating Transaction)을 실행합니다.
[정상 흐름]
1. 주문 서비스: 주문 생성 (커밋)
2. 재고 서비스: 재고 차감 (커밋)
3. 결제 서비스: 결제 처리 (커밋)
-> 모두 성공
[실패 흐름 - 결제 단계에서 실패]
1. 주문 서비스: 주문 생성 (커밋)
2. 재고 서비스: 재고 차감 (커밋)
3. 결제 서비스: 결제 실패
4. 보상 트랜잭션 - 재고 서비스: 차감했던 재고 원복
5. 보상 트랜잭션 - 주문 서비스: 주문 상태를 '취소'로 변경
2PC처럼 모든 참여자가 락을 걸고 기다리는 대신, 각 단계가 즉시 커밋되고 문제가 생기면 이미 지나온 단계들을 역순으로 되돌리는 방식이라 서비스 간 결합이 훨씬 느슨합니다.
4. Choreography 방식 - 이벤트 기반으로 각자 반응하기
Saga를 구현하는 방법 중 하나는, 11편에서 다룬 Kafka를 활용해 각 서비스가 이벤트를 발행하고 구독하며 스스로 다음 행동을 결정하는 방식입니다.
// 주문 서비스
@Transactional
public void createOrder(OrderCreateDto dto) {
Order order = orderRepository.save(Order.from(dto));
eventPublisher.publish(new OrderCreatedEvent(order.getId(), dto.getProductId(), dto.getQuantity()));
}
// 재고 서비스 - 주문 생성 이벤트를 구독
@KafkaListener(topics = "order-created")
public void handleOrderCreated(OrderCreatedEvent event) {
try {
stockService.decreaseStock(event.getProductId(), event.getQuantity());
eventPublisher.publish(new StockDecreasedEvent(event.getOrderId()));
} catch (InsufficientStockException e) {
eventPublisher.publish(new StockDecreaseFailedEvent(event.getOrderId()));
}
}
// 주문 서비스 - 재고 차감 실패 이벤트를 구독해 보상 처리
@KafkaListener(topics = "stock-decrease-failed")
public void handleStockDecreaseFailed(StockDecreaseFailedEvent event) {
orderService.cancelOrder(event.getOrderId()); // 보상 트랜잭션
}
각 서비스가 중앙의 지시 없이, 다른 서비스가 발행한 이벤트를 보고 스스로 다음 행동(정상 처리 또는 보상)을 결정합니다. 구조가 단순하고 서비스 간 결합도가 낮다는 장점이 있지만, 참여 서비스가 많아질수록 전체 흐름을 한눈에 파악하기 어려워지는 단점이 있습니다.
5. Orchestration 방식 - 중앙에서 지휘하기
참여 서비스가 많아지면, 별도의 오케스트레이터가 전체 흐름을 중앙에서 지휘하는 방식이 더 관리하기 쉬울 수 있습니다.
@Service
@RequiredArgsConstructor
public class OrderSagaOrchestrator {
private final OrderServiceClient orderClient;
private final StockServiceClient stockClient;
private final PaymentServiceClient paymentClient;
public void processOrder(OrderCreateDto dto) {
Long orderId = orderClient.createOrder(dto);
try {
stockClient.decreaseStock(dto.getProductId(), dto.getQuantity());
} catch (Exception e) {
orderClient.cancelOrder(orderId); // 보상
throw e;
}
try {
paymentClient.processPayment(orderId, dto.getTotalPrice());
} catch (Exception e) {
stockClient.restoreStock(dto.getProductId(), dto.getQuantity()); // 보상
orderClient.cancelOrder(orderId); // 보상
throw e;
}
}
}
오케스트레이터가 각 단계를 순서대로 호출하고, 실패 지점에 따라 어떤 보상 트랜잭션을 실행할지 명시적으로 관리합니다. 전체 흐름이 한 곳에 코드로 드러나기 때문에 파악과 디버깅이 쉬운 대신, 오케스트레이터 자체가 여러 서비스를 알아야 하는 결합도를 갖게 됩니다.
6. Choreography vs Orchestration
| 구분 | Choreography | Orchestration |
|---|---|---|
| 흐름 제어 | 각 서비스가 이벤트에 반응해 스스로 결정 | 중앙 오케스트레이터가 지휘 |
| 서비스 간 결합도 | 낮음 | 오케스트레이터에 결합 |
| 전체 흐름 파악 | 여러 서비스에 흩어져 있어 어려움 | 한 곳에 모여있어 쉬움 |
| 적합한 규모 | 참여 서비스가 적을 때 | 참여 서비스가 많고 복잡할 때 |
7. 보상 트랜잭션 설계 시 주의할 점
- 모든 작업이 되돌릴 수 있는 것은 아닙니다. 예를 들어 이미 발송된 이메일은 취소할 수 없으므로, 그 경우 "취소 안내 메일 발송"처럼 상쇄하는 후속 작업으로 대신합니다.
- 보상 트랜잭션 자체도 실패할 수 있습니다. 재시도 로직(23편의 Retry)을 보상 단계에도 함께 적용해야 합니다.
- 최종 일관성을 전제로 설계해야 합니다. 실패와 보상이 처리되는 짧은 시간 동안은 시스템 전체가 일시적으로 불일치 상태에 놓일 수 있다는 점을 감안해야 합니다.
8. 정리
- 서비스가 여러 데이터베이스로 나뉘면 하나의 트랜잭션으로 묶이지 않으므로, 실패 시 되돌리는 방법을 별도로 설계해야 한다
- 2PC는 모든 참여자가 락을 걸고 대기해야 해서 MSA 환경에서는 처리량 저하 문제로 잘 쓰이지 않는다
- Saga 패턴은 각 단계를 로컬 트랜잭션으로 즉시 커밋하고, 실패 시 보상 트랜잭션으로 이전 단계를 되돌린다
- Choreography는 서비스 간 결합도가 낮지만 흐름 파악이 어렵고, Orchestration은 흐름이 명확하지만 오케스트레이터에 결합도가 생긴다
'성능 최적화' 카테고리의 다른 글
| 성능 최적화 STEP 31 - Caffeine + Redis로 멀티 레벨 캐싱 구성하기 (0) | 2026.08.05 |
|---|---|
| 성능 최적화 STEP 30 - Transactional Outbox 패턴 (0) | 2026.07.27 |
| 성능 최적화 STEP 28 - Elasticsearch로 검색 성능 개선하기 (0) | 2026.07.24 |
| 성능 최적화 STEP 27 - CDN으로 정적 자원 전송 속도 개선하기 (0) | 2026.07.24 |
| 성능 최적화 STEP 26 - 가상 스레드(Virtual Threads) (0) | 2026.07.23 |