모놀리틱에서는 하나의 @Transactional로 끝나던 주문-결제-재고 처리가, 서비스를 분리하는 순간 왜 이렇게 복잡해지는 걸까요?

모놀리틱 아키텍처에서는 하나의 데이터베이스 트랜잭션으로 여러 테이블의 일관성을 보장할 수 있었습니다. 하지만 마이크로서비스에서는 각 서비스가 자신만의 데이터베이스를 갖기 때문에, 서비스를 넘나드는 트랜잭션을 하나의 ACID 트랜잭션으로 묶을 수 없습니다. Saga 패턴은 이 문제를 로컬 트랜잭션의 연쇄 + 보상 트랜잭션 으로 해결합니다.

왜 2PC는 비실용적인가

2PC(Two-Phase Commit)는 분산 트랜잭션의 전통적인 해법이지만, MSA 환경에서는 치명적인 단점이 있습니다.

2PC의 동작 방식:

  1. Prepare Phase: 코디네이터가 모든 참여자에게 "커밋 준비됐나?" 질의
  2. Commit Phase: 모두 OK면 커밋, 하나라도 실패하면 전체 롤백

문제점:

  • ** 동기적 블로킹 **: 모든 참여자가 응답할 때까지 락을 잡고 대기
  • ** 단일 장애점 **: 코디네이터가 죽으면 참여자들이 무한 대기
  • ** 가용성 저하 **: 하나의 서비스가 느리면 전체 트랜잭션이 지연
  • ** 서비스 독립성 훼손 **: 모든 서비스가 같은 트랜잭션 매니저에 종속

공부하다 보니, "MSA에서 2PC를 안 쓰는 이유"는 기술적 한계라기보다 ** 설계 철학의 충돌 **이었습니다. MSA는 서비스 독립성과 가용성을 최우선으로 하는데, 2PC는 강한 결합과 동기 처리를 요구합니다.

Saga란

Saga는 ** 각 서비스의 로컬 트랜잭션을 순차적으로 실행 **하고, 중간에 실패하면 이미 완료된 트랜잭션을 ** 보상 트랜잭션(Compensating Transaction)**으로 되돌리는 패턴입니다.

PLAINTEXT
[주문 생성] → [결제 처리] → [재고 차감] → [배송 요청]
     ↓              ↓              ↓
[주문 취소] ← [결제 취소] ← [재고 복원]  (보상 트랜잭션)

핵심 포인트:

  • 각 단계는 ** 로컬 트랜잭션 **으로 즉시 커밋 (다른 서비스의 완료를 기다리지 않음)
  • 실패 시 ** 역순으로 보상 트랜잭션 **을 실행하여 이전 상태로 복원
  • 최종 일관성(Eventual Consistency)을 보장 (즉시 일관성은 아님)

Choreography vs Orchestration

Saga를 구현하는 두 가지 방식이 있고, 각각 명확한 장단점이 있습니다.

Choreography (안무) 방식

각 서비스가 이벤트를 발행하고, 다음 서비스가 해당 이벤트를 구독하여 처리합니다. 중앙 제어자 없이 서비스들이 자율적으로 협력합니다.

PLAINTEXT
주문 서비스 ──OrderCreated──→ 결제 서비스
                              ──PaymentCompleted──→ 재고 서비스
                                                    ──StockDeducted──→ 배송 서비스

(실패 시)
재고 서비스 ──StockDeductionFailed──→ 결제 서비스 (보상: 결제 취소)
                                      ──PaymentCancelled──→ 주문 서비스 (보상: 주문 취소)

장점:

  • 서비스 간 느슨한 결합
  • 단일 장애점 없음
  • 구현이 비교적 단순 (이벤트 발행/구독만)

단점:

  • 서비스가 많아지면 이벤트 흐름 추적이 ** 매우 어려움**
  • 순환 의존성 발생 가능
  • 전체 Saga의 현재 상태를 파악하기 어려움

Orchestration (오케스트레이션) 방식

중앙 오케스트레이터가 전체 Saga 흐름을 관리합니다. 각 서비스는 오케스트레이터의 명령을 받아 실행하고 결과를 반환합니다.

PLAINTEXT
                    ┌──────────────────┐
                    │  Saga Orchestrator │
                    └──────┬───────────┘
           ┌───────────────┼───────────────┐
           ↓               ↓               ↓
      [주문 서비스]    [결제 서비스]    [재고 서비스]

장점:

  • 전체 흐름이 한눈에 보임 (오케스트레이터에 로직이 집중)
  • 복잡한 분기/조건부 로직 처리가 용이
  • 모니터링과 디버깅이 쉬움

단점:

  • 오케스트레이터가 단일 장애점이 될 수 있음
  • 오케스트레이터에 로직이 집중되어 비대해질 수 있음
  • 서비스와 오케스트레이터 간 결합도 증가

어떤 방식을 선택할까

기준ChoreographyOrchestration
Saga 단계 수2-3단계4단계 이상
분기 조건단순복잡 (조건부 보상 등)
모니터링어려움용이
결합도낮음중간
디버깅어려움용이

실무 예시: 주문 처리 Saga

주문 → 결제 → 재고 → 배송 시나리오를 Orchestration 방식으로 설계해보겠습니다.

Saga 상태 머신

PLAINTEXT
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

오케스트레이터 구현 (간소화)

JAVA
@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 구현에 가장 풍부한 기능을 제공합니다.

JAVA
@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와의 통합이 자연스럽습니다.

JAVA
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를 구현할 수 있습니다. 모놀리틱 모듈러 아키텍처나 프로토타이핑 단계에서 유용합니다.

이벤트 정의

JAVA
// 기본 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
}

이벤트 핸들러

JAVA
@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) 보장

네트워크 장애나 재시도로 인해 보상 트랜잭션이 여러 번 실행 될 수 있습니다:

JAVA
@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());
    }
}

순서 보장

보상 트랜잭션은 원래 실행 순서의 역순 으로 실행해야 합니다. 그렇지 않으면 의존 관계가 꼬일 수 있습니다:

PLAINTEXT
실행 순서:  주문 → 결제 → 재고
보상 순서:  재고 복원 → 결제 취소 → 주문 취소  (역순!)

보상이 실패하면?

보상 트랜잭션 자체가 실패하는 케이스도 고려해야 합니다:

JAVA
@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 호출처럼 되돌릴 수 없는 작업도 있습니다:

PLAINTEXT
주문 생성 → 결제 → 이메일 발송(취소 불가) → 배송

이런 경우:

  • 되돌릴 수 없는 단계는 Saga의 마지막에 배치
  • 또는 "취소 이메일 발송"이라는 ** 의미적 보상 **을 구현
  • Pivot Transaction(되돌릴 수 없는 단계) 이전까지만 보상 가능하도록 설계

Saga 프레임워크 선택 가이드

기준Axon FrameworkEventuate TramSpring Events + 수동
학습 비용높음 (CQRS 이해 필요)중간낮음
기능 풍부함매우 높음높음기본만
적합한 규모대규모 MSA중규모 MSA모듈러 모놀리스, 소규모
이벤트 소싱네이티브 지원선택적직접 구현
운영 복잡도Axon Server 필요메시지 브로커 필요추가 인프라 없음

정리

  • MSA에서 2PC는 서비스 독립성과 가용성을 훼손하므로 Saga로 대체
  • Saga는 로컬 트랜잭션의 연쇄 실행 + 실패 시 보상 트랜잭션으로 최종 일관성을 보장
  • Choreography는 단순하지만 추적이 어렵고, Orchestration은 복잡하지만 관리가 용이
  • 보상 트랜잭션 설계의 핵심은 ** 멱등성 과 ** 역순 실행 — 이 두 가지를 놓치면 데이터 불일치가 발생
  • 프레임워크 없이도 Spring Application Events + 수동 보상으로 간단한 Saga 구현이 가능하지만, 규모가 커지면 Axon이나 Eventuate Tram 도입을 검토
댓글 로딩 중...