Saga 패턴 — 마이크로서비스에서 분산 트랜잭션을 대체하는 방법
모놀리틱에서는 하나의
@Transactional로 끝나던 주문-결제-재고 처리가, 서비스를 분리하는 순간 왜 이렇게 복잡해지는 걸까요?
모놀리틱 아키텍처에서는 하나의 데이터베이스 트랜잭션으로 여러 테이블의 일관성을 보장할 수 있었습니다. 하지만 마이크로서비스에서는 각 서비스가 자신만의 데이터베이스를 갖기 때문에, 서비스를 넘나드는 트랜잭션을 하나의 ACID 트랜잭션으로 묶을 수 없습니다. Saga 패턴은 이 문제를 로컬 트랜잭션의 연쇄 + 보상 트랜잭션 으로 해결합니다.
왜 2PC는 비실용적인가
2PC(Two-Phase Commit)는 분산 트랜잭션의 전통적인 해법이지만, MSA 환경에서는 치명적인 단점이 있습니다.
2PC의 동작 방식:
- Prepare Phase: 코디네이터가 모든 참여자에게 "커밋 준비됐나?" 질의
- Commit Phase: 모두 OK면 커밋, 하나라도 실패하면 전체 롤백
문제점:
- ** 동기적 블로킹 **: 모든 참여자가 응답할 때까지 락을 잡고 대기
- ** 단일 장애점 **: 코디네이터가 죽으면 참여자들이 무한 대기
- ** 가용성 저하 **: 하나의 서비스가 느리면 전체 트랜잭션이 지연
- ** 서비스 독립성 훼손 **: 모든 서비스가 같은 트랜잭션 매니저에 종속
공부하다 보니, "MSA에서 2PC를 안 쓰는 이유"는 기술적 한계라기보다 ** 설계 철학의 충돌 **이었습니다. MSA는 서비스 독립성과 가용성을 최우선으로 하는데, 2PC는 강한 결합과 동기 처리를 요구합니다.
Saga란
Saga는 ** 각 서비스의 로컬 트랜잭션을 순차적으로 실행 **하고, 중간에 실패하면 이미 완료된 트랜잭션을 ** 보상 트랜잭션(Compensating Transaction)**으로 되돌리는 패턴입니다.
[주문 생성] → [결제 처리] → [재고 차감] → [배송 요청]
↓ ↓ ↓
[주문 취소] ← [결제 취소] ← [재고 복원] (보상 트랜잭션)
핵심 포인트:
- 각 단계는 ** 로컬 트랜잭션 **으로 즉시 커밋 (다른 서비스의 완료를 기다리지 않음)
- 실패 시 ** 역순으로 보상 트랜잭션 **을 실행하여 이전 상태로 복원
- 최종 일관성(Eventual Consistency)을 보장 (즉시 일관성은 아님)
Choreography vs Orchestration
Saga를 구현하는 두 가지 방식이 있고, 각각 명확한 장단점이 있습니다.
Choreography (안무) 방식
각 서비스가 이벤트를 발행하고, 다음 서비스가 해당 이벤트를 구독하여 처리합니다. 중앙 제어자 없이 서비스들이 자율적으로 협력합니다.
주문 서비스 ──OrderCreated──→ 결제 서비스
──PaymentCompleted──→ 재고 서비스
──StockDeducted──→ 배송 서비스
(실패 시)
재고 서비스 ──StockDeductionFailed──→ 결제 서비스 (보상: 결제 취소)
──PaymentCancelled──→ 주문 서비스 (보상: 주문 취소)
장점:
- 서비스 간 느슨한 결합
- 단일 장애점 없음
- 구현이 비교적 단순 (이벤트 발행/구독만)
단점:
- 서비스가 많아지면 이벤트 흐름 추적이 ** 매우 어려움**
- 순환 의존성 발생 가능
- 전체 Saga의 현재 상태를 파악하기 어려움
Orchestration (오케스트레이션) 방식
중앙 오케스트레이터가 전체 Saga 흐름을 관리합니다. 각 서비스는 오케스트레이터의 명령을 받아 실행하고 결과를 반환합니다.
┌──────────────────┐
│ Saga Orchestrator │
└──────┬───────────┘
┌───────────────┼───────────────┐
↓ ↓ ↓
[주문 서비스] [결제 서비스] [재고 서비스]
장점:
- 전체 흐름이 한눈에 보임 (오케스트레이터에 로직이 집중)
- 복잡한 분기/조건부 로직 처리가 용이
- 모니터링과 디버깅이 쉬움
단점:
- 오케스트레이터가 단일 장애점이 될 수 있음
- 오케스트레이터에 로직이 집중되어 비대해질 수 있음
- 서비스와 오케스트레이터 간 결합도 증가
어떤 방식을 선택할까
| 기준 | Choreography | Orchestration |
|---|---|---|
| Saga 단계 수 | 2-3단계 | 4단계 이상 |
| 분기 조건 | 단순 | 복잡 (조건부 보상 등) |
| 모니터링 | 어려움 | 용이 |
| 결합도 | 낮음 | 중간 |
| 디버깅 | 어려움 | 용이 |
실무 예시: 주문 처리 Saga
주문 → 결제 → 재고 → 배송 시나리오를 Orchestration 방식으로 설계해보겠습니다.
Saga 상태 머신
STARTED → PAYMENT_PENDING → PAYMENT_COMPLETED
→ STOCK_PENDING → STOCK_DEDUCTED
→ SHIPPING_PENDING → COMPLETED
(실패 경로)
PAYMENT_FAILED → CANCELLED
STOCK_DEDUCTION_FAILED → PAYMENT_COMPENSATING → CANCELLED
SHIPPING_FAILED → STOCK_COMPENSATING → PAYMENT_COMPENSATING → CANCELLED
오케스트레이터 구현 (간소화)
@Service
@RequiredArgsConstructor
@Slf4j
public class OrderSagaOrchestrator {
private final OrderService orderService;
private final PaymentClient paymentClient;
private final InventoryClient inventoryClient;
private final ShippingClient shippingClient;
private final SagaStateRepository sagaStateRepository;
@Transactional
public OrderResult executeOrderSaga(OrderRequest request) {
// Saga 상태 생성
SagaState saga = SagaState.create(request.getOrderId());
sagaStateRepository.save(saga);
try {
// Step 1: 주문 생성
saga.updateStep("ORDER_CREATED");
Order order = orderService.createOrder(request);
// Step 2: 결제 처리
saga.updateStep("PAYMENT_PENDING");
PaymentResult payment = paymentClient.processPayment(
new PaymentRequest(order.getId(), request.getAmount())
);
// Step 3: 재고 차감
saga.updateStep("STOCK_PENDING");
inventoryClient.deductStock(
new StockRequest(request.getProductId(), request.getQuantity())
);
// Step 4: 배송 요청
saga.updateStep("SHIPPING_PENDING");
shippingClient.requestShipping(
new ShippingRequest(order.getId(), request.getAddress())
);
saga.complete();
return OrderResult.success(order);
} catch (PaymentFailedException e) {
log.error("결제 실패, 주문 취소 보상 실행: {}", e.getMessage());
compensateOrder(saga, request);
return OrderResult.failed("결제 실패");
} catch (StockDeductionFailedException e) {
log.error("재고 부족, 결제 취소 보상 실행: {}", e.getMessage());
compensatePayment(saga, request);
compensateOrder(saga, request);
return OrderResult.failed("재고 부족");
} catch (ShippingFailedException e) {
log.error("배송 실패, 전체 보상 실행: {}", e.getMessage());
compensateStock(saga, request);
compensatePayment(saga, request);
compensateOrder(saga, request);
return OrderResult.failed("배송 요청 실패");
}
}
// 보상 트랜잭션들
private void compensateOrder(SagaState saga, OrderRequest request) {
saga.updateStep("ORDER_COMPENSATING");
orderService.cancelOrder(request.getOrderId());
}
private void compensatePayment(SagaState saga, OrderRequest request) {
saga.updateStep("PAYMENT_COMPENSATING");
paymentClient.cancelPayment(request.getOrderId());
}
private void compensateStock(SagaState saga, OrderRequest request) {
saga.updateStep("STOCK_COMPENSATING");
inventoryClient.restoreStock(
new StockRequest(request.getProductId(), request.getQuantity())
);
}
}
위 코드는 이해를 위해 동기 방식으로 단순화한 것입니다. 실제 프로덕션에서는 비동기 이벤트 기반으로 구현하는 경우가 대부분입니다.
Axon Framework
Axon Framework는 CQRS + Event Sourcing + Saga를 지원하는 Java 프레임워크로, Saga 구현에 가장 풍부한 기능을 제공합니다.
@Saga
public class OrderSaga {
private transient CommandGateway commandGateway;
@StartSaga
@SagaEventHandler(associationProperty = "orderId")
public void handle(OrderCreatedEvent event) {
// 결제 요청 커맨드 전송
commandGateway.send(new ProcessPaymentCommand(
event.getOrderId(),
event.getAmount()
));
}
@SagaEventHandler(associationProperty = "orderId")
public void handle(PaymentCompletedEvent event) {
// 재고 차감 커맨드 전송
commandGateway.send(new DeductStockCommand(
event.getOrderId(),
event.getProductId(),
event.getQuantity()
));
}
@SagaEventHandler(associationProperty = "orderId")
public void handle(StockDeductedEvent event) {
// 배송 요청
commandGateway.send(new RequestShippingCommand(
event.getOrderId()
));
}
@EndSaga
@SagaEventHandler(associationProperty = "orderId")
public void handle(ShippingRequestedEvent event) {
// Saga 완료
}
// 보상 트랜잭션
@SagaEventHandler(associationProperty = "orderId")
public void handle(PaymentFailedEvent event) {
// 주문 취소 보상
commandGateway.send(new CancelOrderCommand(event.getOrderId()));
}
@SagaEventHandler(associationProperty = "orderId")
public void handle(StockDeductionFailedEvent event) {
// 결제 취소 보상
commandGateway.send(new CancelPaymentCommand(event.getOrderId()));
}
}
Axon의 장점:
@Saga,@StartSaga,@EndSaga로 Saga 생명주기를 선언적으로 관리- Event Store 기반으로 Saga 상태를 자동 추적
- Deadline Manager로 타임아웃 처리 내장
- Axon Server(이벤트 스토어)와 통합하면 이벤트 재생(replay) 가능
Axon의 단점:
- 학습 곡선이 가파름 (CQRS + Event Sourcing 전체 이해 필요)
- Axon Server 의존성이 생길 수 있음
- 단순한 Saga에는 오버엔지니어링
Eventuate Tram Saga
Axon보다 가벼운 대안으로, Spring Boot와의 통합이 자연스럽습니다.
public class CreateOrderSaga implements SimpleSaga<CreateOrderSagaData> {
private final SagaDefinition<CreateOrderSagaData> sagaDefinition =
step()
.invokeLocal(this::createOrder)
.withCompensation(this::rejectOrder)
.step()
.invokeParticipant(this::reserveCredit)
.withCompensation(this::releaseCredit)
.step()
.invokeParticipant(this::deductStock)
.withCompensation(this::restoreStock)
.step()
.invokeParticipant(this::requestShipping)
.build();
// 각 단계의 실행 메서드
private void createOrder(CreateOrderSagaData data) {
// 주문 생성 로컬 트랜잭션
}
private CommandWithDestination reserveCredit(CreateOrderSagaData data) {
return send(new ReserveCreditCommand(data.getOrderId(), data.getAmount()))
.to("payment-service");
}
private CommandWithDestination releaseCredit(CreateOrderSagaData data) {
return send(new ReleaseCreditCommand(data.getOrderId()))
.to("payment-service");
}
// ... 나머지 단계들
}
Eventuate Tram의 특징:
- **DSL로 Saga 정의 **:
step().invokeParticipant().withCompensation()체이닝 - ** 메시지 브로커 기반 **: Kafka 또는 RabbitMQ를 통한 비동기 통신
- **Outbox 패턴 내장 **: 메시지 발행의 원자성 보장
Spring Application Events로 간단한 Saga 구현
프레임워크 없이 Spring의 기본 기능만으로도 간단한 Saga를 구현할 수 있습니다. 모놀리틱 모듈러 아키텍처나 프로토타이핑 단계에서 유용합니다.
이벤트 정의
// 기본 Saga 이벤트
public abstract class SagaEvent {
private final String sagaId;
private final LocalDateTime timestamp;
protected SagaEvent(String sagaId) {
this.sagaId = sagaId;
this.timestamp = LocalDateTime.now();
}
}
// 각 단계별 이벤트
public class OrderCreatedEvent extends SagaEvent {
private final Long orderId;
private final BigDecimal amount;
private final Long productId;
private final int quantity;
// 생성자, getter
}
public class PaymentCompletedEvent extends SagaEvent {
private final Long orderId;
private final String transactionId;
// 생성자, getter
}
public class PaymentFailedEvent extends SagaEvent {
private final Long orderId;
private final String reason;
// 생성자, getter
}
이벤트 핸들러
@Component
@RequiredArgsConstructor
@Slf4j
public class PaymentSagaHandler {
private final PaymentService paymentService;
private final ApplicationEventPublisher eventPublisher;
@TransactionalEventListener(phase = AFTER_COMMIT)
public void handleOrderCreated(OrderCreatedEvent event) {
try {
// 로컬 트랜잭션으로 결제 처리
String txId = paymentService.processPayment(
event.getOrderId(), event.getAmount()
);
// 성공 이벤트 발행
eventPublisher.publishEvent(
new PaymentCompletedEvent(event.getSagaId(),
event.getOrderId(), txId)
);
} catch (Exception e) {
log.error("결제 실패: {}", e.getMessage());
// 실패 이벤트 발행 → 보상 트리거
eventPublisher.publishEvent(
new PaymentFailedEvent(event.getSagaId(),
event.getOrderId(), e.getMessage())
);
}
}
}
@Component
@RequiredArgsConstructor
@Slf4j
public class CompensationHandler {
private final OrderService orderService;
private final PaymentService paymentService;
@TransactionalEventListener(phase = AFTER_COMMIT)
public void handlePaymentFailed(PaymentFailedEvent event) {
log.info("보상 트랜잭션 실행: 주문 취소 (orderId={})", event.getOrderId());
orderService.cancelOrder(event.getOrderId());
}
@TransactionalEventListener(phase = AFTER_COMMIT)
public void handleStockFailed(StockDeductionFailedEvent event) {
log.info("보상 트랜잭션 실행: 결제 취소 (orderId={})", event.getOrderId());
paymentService.cancelPayment(event.getOrderId());
orderService.cancelOrder(event.getOrderId());
}
}
이 방식은 ** 단일 JVM 내에서만 동작 **한다는 제약이 있습니다. 서비스가 물리적으로 분리된 MSA 환경에서는 Kafka나 RabbitMQ 같은 메시지 브로커를 통해 이벤트를 교환해야 합니다.
보상 트랜잭션 설계 시 주의점
보상 트랜잭션은 "원래 상태로 되돌리는 것"이 아니라, "비즈니스적으로 취소 처리하는 것" 입니다. 이 차이를 이해하는 게 중요합니다.
멱등성(Idempotency) 보장
네트워크 장애나 재시도로 인해 보상 트랜잭션이 여러 번 실행 될 수 있습니다:
@Service
public class PaymentCompensationService {
@Transactional
public void cancelPayment(Long orderId) {
Payment payment = paymentRepository.findByOrderId(orderId)
.orElseThrow();
// 멱등성 체크: 이미 취소되었으면 무시
if (payment.getStatus() == PaymentStatus.CANCELLED) {
log.info("이미 취소된 결제, 스킵: orderId={}", orderId);
return;
}
payment.cancel();
paymentRepository.save(payment);
// 실제 PG사 취소 요청 (PG사도 멱등성 지원 확인 필요)
pgClient.cancelPayment(payment.getTransactionId());
}
}
순서 보장
보상 트랜잭션은 원래 실행 순서의 역순 으로 실행해야 합니다. 그렇지 않으면 의존 관계가 꼬일 수 있습니다:
실행 순서: 주문 → 결제 → 재고
보상 순서: 재고 복원 → 결제 취소 → 주문 취소 (역순!)
보상이 실패하면?
보상 트랜잭션 자체가 실패하는 케이스도 고려해야 합니다:
@Component
@RequiredArgsConstructor
@Slf4j
public class CompensationRetryHandler {
private final CompensationTaskRepository taskRepo;
// 보상 실패 시 재시도 큐에 저장
public void handleCompensationFailure(
String sagaId, String step, Exception e) {
log.error("보상 트랜잭션 실패 — 재시도 예약: saga={}, step={}",
sagaId, step, e);
CompensationTask task = CompensationTask.builder()
.sagaId(sagaId)
.step(step)
.retryCount(0)
.maxRetries(5)
.nextRetryAt(LocalDateTime.now().plusMinutes(1))
.build();
taskRepo.save(task);
}
// 스케줄러로 주기적 재시도
@Scheduled(fixedDelay = 30000)
public void retryFailedCompensations() {
List<CompensationTask> pending = taskRepo
.findRetryable(LocalDateTime.now());
for (CompensationTask task : pending) {
try {
executeCompensation(task);
taskRepo.delete(task);
} catch (Exception e) {
task.incrementRetry();
if (task.isMaxRetriesExceeded()) {
// 최대 재시도 초과 → 수동 개입 필요
log.error("보상 최대 재시도 초과, 수동 처리 필요: {}",
task.getSagaId());
task.markAsFailed();
// 알림 발송 (Slack, PagerDuty 등)
}
taskRepo.save(task);
}
}
}
}
실무에서 보상 트랜잭션이 반복 실패하면 결국 사람이 개입 해야 합니다. 이를 위해 Dead Letter Queue나 알림 시스템을 연동하는 게 필수입니다.
보상 불가능한 트랜잭션
이메일 발송이나 외부 API 호출처럼 되돌릴 수 없는 작업도 있습니다:
주문 생성 → 결제 → 이메일 발송(취소 불가) → 배송
이런 경우:
- 되돌릴 수 없는 단계는 Saga의 마지막에 배치
- 또는 "취소 이메일 발송"이라는 ** 의미적 보상 **을 구현
- Pivot Transaction(되돌릴 수 없는 단계) 이전까지만 보상 가능하도록 설계
Saga 프레임워크 선택 가이드
| 기준 | Axon Framework | Eventuate Tram | Spring Events + 수동 |
|---|---|---|---|
| 학습 비용 | 높음 (CQRS 이해 필요) | 중간 | 낮음 |
| 기능 풍부함 | 매우 높음 | 높음 | 기본만 |
| 적합한 규모 | 대규모 MSA | 중규모 MSA | 모듈러 모놀리스, 소규모 |
| 이벤트 소싱 | 네이티브 지원 | 선택적 | 직접 구현 |
| 운영 복잡도 | Axon Server 필요 | 메시지 브로커 필요 | 추가 인프라 없음 |
정리
- MSA에서 2PC는 서비스 독립성과 가용성을 훼손하므로 Saga로 대체
- Saga는 로컬 트랜잭션의 연쇄 실행 + 실패 시 보상 트랜잭션으로 최종 일관성을 보장
- Choreography는 단순하지만 추적이 어렵고, Orchestration은 복잡하지만 관리가 용이
- 보상 트랜잭션 설계의 핵심은 ** 멱등성 과 ** 역순 실행 — 이 두 가지를 놓치면 데이터 불일치가 발생
- 프레임워크 없이도 Spring Application Events + 수동 보상으로 간단한 Saga 구현이 가능하지만, 규모가 커지면 Axon이나 Eventuate Tram 도입을 검토