#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