← all posts
DEV 2026.05.02 · 14 min read Intermediate

MSA에서 분산 트랜잭션을 어떻게 다룰 것인가

2PC의 가용성 함정부터 Saga의 보상 트랜잭션 설계, 멱등성 보장, Dead Saga 감지까지 — MSA 일관성의 실전 패턴을 추적한다.


단일 데이터베이스 시대에는 BEGIN TRANSACTIONROLLBACK 한 쌍이 모든 걸 해결해줬다. 마이크로서비스 환경에서는 그 사치가 사라진다. 결제 서비스, 주문 서비스, 재고 서비스가 각자의 DB를 들고 있을 때, “모두 성공하거나 모두 실패하는 것”을 어떻게 보장할 수 있는가?

2PC가 MSA에 맞지 않는 이유

2PC(Two-Phase Commit)는 분산 데이터베이스 세계에서 수십 년을 살아남은 프로토콜이다. 코디네이터가 Prepare를 보내 모든 참여자로부터 YES를 받은 뒤 Commit을 내리는 구조는 수학적으로 우아하다. 문제는 “모든 참여자가 응답해야만 진행”이라는 조건이다.

Coordinator        PaymentDB        InventoryDB
    │── Prepare ──→│                  │
    │              │✓ Lock 획득       │
    │── Prepare ────────────────────→│
    │                                 │✓ Lock 획득
    │← YES ────────│                 │
    │← YES ──────────────────────────│
    │── Commit ──→ │    [네트워크 단절]
    │              │⏳ Lock 유지      │⏳ Lock 유지
    │  (복구 불가 — 수동 개입 필요)

코디네이터가 다운되거나 네트워크가 분리되면, 모든 참여자는 Lock을 들고 무한정 기다린다. CAP Theorem의 언어로 표현하면 2PC는 CP를 선택한다 — 일관성을 지키는 대신 가용성을 포기한다. 네트워크 장애가 불가피한 클라우드 환경에서는 이 선택이 치명적이다.

Saga: 보상으로 일관성을 얻다

Saga는 전혀 다른 철학에서 출발한다. 분산 트랜잭션을 하나의 원자 단위로 묶는 대신, 각 서비스의 로컬 트랜잭션을 순서대로 실행하고 실패하면 **보상 트랜잭션(Compensating Transaction)**으로 이미 완료된 단계를 역순으로 취소한다.

OrderService     PaymentService     InventoryService
    │── 주문생성 (로컬 TX, 즉시 커밋)
    │── 결제 요청 ──────────────→│
    │              결제완료 (로컬 TX)
    │── 재고 요청 ─────────────────────────────→│
    │                                 ❌ 재고 부족!
    │← 실패 통지 ──────────────────────────────│
    │── 환불 요청 (보상 TX) ──→│
    │── 주문취소 (보상 TX)

보상 트랜잭션은 ACID 롤백이 아니다. “결제를 없던 것으로 만드는” 것이 아니라, 이미 외부 시스템에 전송된 결제를 환불이라는 새로운 작업으로 상쇄한다. 이 차이가 설계의 모든 것을 결정한다.

트레이드오프

Saga는 결과적 일관성(Eventual Consistency)을 선택한다. 주문이 생성되고 결제가 완료된 뒤 재고가 부족하다면, 시스템은 잠깐 “주문됐지만 아직 재고 미확보” 상태에 있다. 이 중간 상태가 외부에 노출될 수 있으므로, 주문 상태를 PENDING, AWAITING_PAYMENT, COMPLETED 같이 세분화해 관리해야 한다.

Choreography vs Orchestration

Saga를 구현하는 두 가지 방식은 서로 다른 문제를 해결한다.

Choreography는 각 서비스가 이벤트를 발행하고 다른 서비스가 구독해 자율적으로 반응한다. OrderService가 OrderCreatedEvent를 발행하면 PaymentService가 이를 수신해 결제를 처리하고 PaymentCompletedEvent를 발행하는 식이다. 서비스 간 결합도가 낮고, 새 서비스 추가가 기존 코드에 영향을 주지 않는다. 대신 전체 흐름이 여러 파일에 분산되므로 3-4단계를 넘으면 흐름 파악이 어려워진다.

Orchestration은 중앙의 Saga Orchestrator가 명시적으로 각 서비스에 명령을 보내고 응답을 받아 다음 단계를 결정한다. 상태 머신으로 진행 상황을 추적하므로 “지금 어느 단계인가”를 즉시 알 수 있다. 복잡한 조건부 로직과 병렬 처리도 Orchestrator 한 곳에서 표현할 수 있다. 대신 Orchestrator가 모든 서비스를 알아야 하므로 결합도가 높다.

실전에서는 하이브리드가 자연스럽다. 주문 → 결제 → 재고처럼 순서와 보상이 중요한 핵심 흐름은 Orchestration으로, 이메일 발송이나 분석 데이터 수집처럼 실패해도 주문에 영향 없는 주변 기능은 이벤트 기반 Choreography로 연결한다.

멱등성 없는 보상은 재앙이다

Saga의 구현에서 가장 놓치기 쉬운 것이 멱등성(Idempotency)이다. Kafka는 최소 한 번(At-Least-Once) 전달을 보장한다. 같은 이벤트가 두 번 도착할 수 있다는 뜻이다. 멱등성 없이 환불 로직을 구현하면 고객 계좌에 같은 금액이 두 번 환불될 수 있다.

해결책은 Idempotency Key 패턴이다.

public void compensatePayment(Long paymentId, String sagaId) {
    String key = "refund:" + paymentId + ":" + sagaId;

    if (idempotencyKeyRepository.existsByKey(key)) {
        return; // 이미 처리됨
    }

    RefundResponse response = externalPGClient.refund(paymentId, key);

    payment.setStatus("REFUNDED");
    paymentRepository.save(payment);

    idempotencyKeyRepository.save(new IdempotencyKey(key, "SUCCESS"));
}

PG사 API 호출에도 같은 key를 전달해 PG사 측에서도 중복을 차단하게 만드는 것이 완전한 구현이다.

또 하나 반드시 식별해야 하는 것이 Pivot Transaction이다. 이메일 발송, SMS 전송처럼 이미 고객에게 전달된 작업은 취소할 수 없다. 이 지점 이후에서 실패가 발생하면 자동 보상이 불가능하고, 사과 이메일 발송이나 관리자 수동 개입이 유일한 대안이다. Saga 설계 단계에서 Pivot Transaction을 명확히 표시해두지 않으면, 보상 로직을 구현할 때 “이건 어떻게 취소하지?”라는 질문 앞에서 막힌다.

Dead Saga — 중간에 멈춘 것들

운영 환경의 가장 현실적인 문제는 Dead Saga다. PaymentService가 타임아웃을 반환한 뒤 30분간 아무도 이 Saga를 건드리지 않는다면, 주문은 영구적으로 중간 상태에 갇힌다.

@Scheduled(fixedRate = 600000) // 10분마다
public void detectDeadSagas() {
    LocalDateTime threshold = LocalDateTime.now().minusMinutes(30);

    List<OrderSaga> deadSagas = sagaRepository
        .findByStateAndUpdatedAtBefore(SagaState.PENDING, threshold);

    for (OrderSaga saga : deadSagas) {
        if (saga.getAutoRecoveryAttempts() >= 3) {
            alertingService.sendAlert("MAX_RETRY_EXCEEDED", saga.getId());
            continue;
        }
        saga.incrementAutoRecoveryAttempts();
        recoveryService.recoverFromLastSuccessfulStep(saga);
    }
}

복구 전략은 두 가지다. Backward Recovery는 마지막 성공한 단계부터 재개하는 안전한 방법이고, Forward Recovery는 다음 단계로 강제 진행하는 위험한 방법으로 이미 완료된 것이 확인된 경우에만 쓴다. 자동 복구가 3회 이상 실패하면 관리자 알림으로 수동 개입을 유도하고, 관리자가 상태를 강제 변경할 때는 반드시 실제 결제 완료 여부를 검증한 뒤 변경해야 한다.

정리

  • 2PC는 “모든 참여자 응답”이라는 조건이 가용성을 해친다. 네트워크 장애가 불가피한 MSA 환경에서는 선택지가 아니다.
  • Saga는 로컬 트랜잭션 + 보상 트랜잭션으로 결과적 일관성을 달성한다. 롤백이 아니라 취소라는 점이 설계 방식을 완전히 바꾼다.
  • Choreography는 느슨한 결합을, Orchestration은 명확한 흐름을 제공한다. 대부분의 실전 시스템은 핵심 흐름에 Orchestration, 주변 기능에 Choreography를 조합한다.
  • 멱등성 없는 보상은 중복 처리 장애를 만든다. Idempotency Key와 Pivot Transaction 식별은 선택이 아닌 필수다.
  • Dead Saga 감지와 복구 전략이 없으면 장애가 조용히 쌓인다.

다음 글에서는 서비스 간 통신 실패를 격리하는 Circuit Breaker 패턴이 Saga와 어떻게 맞물려 동작하는지 추적한다.