#Saga Compensation 패턴 — 심층 가이드

버전: 0.43.0 | 업데이트: 2026-03-28 | 적용 대상: ranvier-runtime 0.36+ | 카테고리: 쿡북


#개요

Saga 패턴은 여러 단계에 걸친 분산 트랜잭션을 관리하기 위한 Ranvier의 접근 방식입니다. N번째 단계가 실패하면, 1번부터 N-1번까지의 단계들이 역순으로 "보상"(롤백)되어야 합니다. 이 쿡북은 cookbook_saga_compensation.md에서 다룬 기본 패턴을 넘어서는 고급 saga 패턴들을 다룹니다.

사전 요구사항: cookbook_saga_compensation.md (기본 saga 패턴)


#1. 패턴 구조

모든 saga는 세 가지 구성 요소를 가집니다:

Forward Steps:          A → B → C → D
Compensation Steps:     A'← B'← C'
Control:                Outcome::Fault triggers compensation

핵심 원칙:

  • LIFO 순서: 보상은 역순으로 실행됩니다 (C' → B' → A')
  • 멱등성: 각 보상은 재시도해도 안전해야 합니다
  • 격리: 각 단계는 자신만의 상태 부분집합에서 동작합니다

#2. 적용 도메인: 결제 주문 처리

정석적인 saga 예제 — 재고 예약, 결제 청구, 배송 확인.

use ranvier_runtime::Axon;
use ranvier_core::saga::SagaPolicy;

let order_saga = Axon::typed::<CreateOrderRequest, String>("order-saga")
    .with_saga_policy(SagaPolicy::Enabled)
    .then(create_order)
    .then_compensated(reserve_inventory, release_inventory)
    .then_compensated(authorize_payment, refund_payment)
    .then_compensated(confirm_shipping, cancel_shipment)
    .then(complete_order);

#왜 여기서 Saga를?

각 단계는 외부 부수 효과(재고 예약, 결제 청구, 배송 라벨)를 가지며, 이는 데이터베이스 트랜잭션으로 자동 롤백될 수 없습니다. Saga 패턴은 보상을 명시적이고 감사 가능하게 만듭니다.


#3. 적용 도메인: 보험 청구 처리

다단계 청구: 보험 검증 → 손해 평가 → 지급 승인 → 지불 예약.

let claim_saga = Axon::typed::<ClaimRequest, String>("claim-saga")
    .with_saga_policy(SagaPolicy::Enabled)
    .then(validate_policy)
    .then_compensated(assess_damage, void_assessment)
    .then_compensated(approve_payout, revoke_approval)
    .then_compensated(schedule_disbursement, cancel_disbursement)
    .then(close_claim);

#보상의 뉘앙스

  • void_assessment: 평가를 삭제하지 않고 무효로 표시합니다 (감사 추적)
  • revoke_approval: 청구인에게 취소 통지를 발송합니다
  • cancel_disbursement: 자금이 계좌를 떠나지 않은 경우에만 가능합니다

#4. 적용 도메인: 멀티 서비스 온보딩

사용자 등록: 계정 생성 → 리소스 프로비저닝 → 환영 이메일 발송 → 활성화.

let onboarding_saga = Axon::typed::<SignupRequest, String>("onboarding")
    .with_saga_policy(SagaPolicy::Enabled)
    .then(create_account)
    .then_compensated(provision_resources, deprovision_resources)
    .then_compensated(send_welcome_email, send_cancellation_email)
    .then(activate_account);

#핵심 인사이트

create_account는 보상이 없습니다 — 첫 번째 단계이기 때문입니다. 실패하면 보상이 필요 없습니다. then() (not then_compensated()) 구분은 의도적입니다.


#5. 고급: 조건부 보상

때로는 보상 로직이 실패 컨텍스트에 따라 달라집니다. Bus를 사용하여 실패 메타데이터를 보상 transition으로 전달하세요.

#[transition]
async fn authorize_payment(
    input: serde_json::Value,
    bus: &mut Bus,
) -> Outcome<serde_json::Value, String> {
    let result = payment_gateway.charge(input["amount"].as_f64().unwrap()).await;
    match result {
        Ok(tx_id) => {
            bus.insert(tx_id); // Store for compensation
            Outcome::Next(input)
        }
        Err(e) => Outcome::Fault(format!("Payment failed: {}", e)),
    }
}

#[transition]
async fn refund_payment(
    input: serde_json::Value,
    bus: &mut Bus,
) -> Outcome<serde_json::Value, String> {
    // Only refund if we have a transaction ID
    if let Ok(tx_id) = bus.get_cloned::<String>() {
        payment_gateway.refund(&tx_id).await.ok();
        tracing::warn!(tx_id = %tx_id, "Payment refunded");
    }
    Outcome::Next(input)
}

#6. Saga 보상 테스트

ranvier-test를 사용하여 보상 순서를 검증하세요:

#[tokio::test]
async fn compensation_runs_in_reverse() {
    let saga = Axon::typed::<OrderRequest, String>("test-saga")
        .with_saga_policy(SagaPolicy::Enabled)
        .then(step_a)
        .then_compensated(step_b, comp_b)
        .then_compensated(step_c_fails, comp_c)
        .then(step_d);

    let mut bus = Bus::new();
    let result = saga.execute(order, &(), &mut bus).await;

    // step_c_fails returns Fault → comp_c runs, then comp_b
    assert!(matches!(result, Outcome::Fault(_)));
}

#빠른 참조

시나리오 Saga 사용? 이유
멀티 서비스 쓰기 각 서비스는 명시적 롤백이 필요합니다
단일 DB 트랜잭션 아니오 대신 데이터베이스 ACID를 사용하세요
읽기 전용 파이프라인 아니오 보상할 것이 없습니다
멱등적 연산 경우에 따라 재시도가 안전하더라도 Saga는 명확성을 추가합니다

#관련 문서

  • cookbook_saga_compensation.md — 기본 saga 패턴
  • saga-compensation 예제 — 순수 Axon saga 데모 (HTTP 없음)
  • reference-ecommerce-order 예제 — 감사 기능이 포함된 전체 HTTP saga