버전: 0.43.0 | 업데이트: 2026-03-28 | 카테고리: 튜토리얼

#Ranvier로 결제 파이프라인 만들기

Saga 보상, 분기, Schematic 검사까지 포함한 엔드투엔드 결제 파이프라인입니다.

읽기 20분 | 실습 40분


#개요

주문 검증부터 결제 정산까지 7단계로 프로덕션급 파이프라인을 구축합니다.

ValidateOrder → DetectFraud → Authorize → Capture → RoutePayment
                                 ↕ Saga      ↕ Saga       ├ card → SettleCard
                              ReverseAuth  RefundCap      └ bank → SettleBank

#1단계: 프로젝트 생성

cargo new payment-pipeline && cd payment-pipeline
cargo add ranvier-core ranvier-runtime ranvier-test tokio --features tokio/full
cargo add async-trait serde --features serde/derive
cargo add serde_json thiserror uuid --features uuid/v4
use serde::{Deserialize, Serialize};

#[derive(Clone, Debug, Serialize, Deserialize)]
struct Order {
    id: String, amount_cents: u64, currency: String,
    payment_method: PaymentMethod, customer_id: String,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
enum PaymentMethod { Card { token: String }, BankTransfer { iban: String } }
#[derive(Clone, Debug, Serialize, Deserialize)]
struct PaymentResult { order_id: String, transaction_id: String, status: String }
#[derive(Clone, Debug, Serialize, Deserialize)]
struct AuthorizationId(String);
#[derive(Clone, Debug, Serialize, Deserialize)]
struct CaptureId(String);
#[derive(Clone, Debug)]
struct FraudScore(f64);

#[derive(Clone, Debug, thiserror::Error, Serialize, Deserialize)]
enum PaymentError {
    #[error("validation: {0}")]  Validation(String),
    #[error("fraud: {0}")]       Fraud(String),
    #[error("declined: {0}")]    Declined(String),
    #[error("settlement: {0}")]  Settlement(String),
}

#2단계: Transition 정의

핵심 Transition을 작성합니다. 코드는 영어, 설명은 한국어로 유지합니다.

use async_trait::async_trait;
use ranvier_core::prelude::*;

#[derive(Clone)]
struct ValidateOrder;

#[async_trait]
impl Transition<Order, Order> for ValidateOrder {
    type Error = PaymentError;
    type Resources = ();
    async fn run(&self, input: Order, _res: &(), _bus: &mut Bus)
        -> Outcome<Order, Self::Error> {
        if input.amount_cents == 0 {
            return Outcome::Fault(PaymentError::Validation("amount must be positive".into()));
        }
        Outcome::Next(input)
    }
}

#[derive(Clone)]
struct DetectFraud;

#[async_trait]
impl Transition<Order, Order> for DetectFraud {
    type Error = PaymentError;
    type Resources = ();
    async fn run(&self, input: Order, _res: &(), bus: &mut Bus)
        -> Outcome<Order, Self::Error> {
        let score = input.amount_cents as f64 / 100_000.0;
        bus.insert(FraudScore(score));
        if score > 0.9 {
            return Outcome::Fault(PaymentError::Fraud(format!("score {:.2}", score)));
        }
        Outcome::Next(input)
    }
}
// SettleCard: Transition<Order, PaymentResult> — status "settled"
// SettleBank: Transition<Order, PaymentResult> — status "pending_settlement"

#3단계: Branch 분기 로직 추가

RoutePayment가 결제 수단별로 Outcome::Branch를 반환합니다.

#[derive(Clone)]
struct RoutePayment;

#[async_trait]
impl Transition<Order, Order> for RoutePayment {
    type Error = PaymentError;
    type Resources = ();
    async fn run(&self, input: Order, _res: &(), _bus: &mut Bus)
        -> Outcome<Order, Self::Error> {
        let label = match &input.payment_method {
            PaymentMethod::Card { .. } => "card",
            PaymentMethod::BankTransfer { .. } => "bank_transfer",
        };
        Outcome::Branch { label: label.into(), value: input }
    }
}
use ranvier_runtime::Axon;

let payment_axon = Axon::typed::<Order, PaymentError>("payment-pipeline")
    .then(ValidateOrder).then(DetectFraud).then(RoutePayment)
    .branch("card", |b| b.then(SettleCard))
    .branch("bank_transfer", |b| b.then(SettleBank));

#4단계: Saga 보상 체인 추가

then_compensated(forward, compensate)로 보상 쌍을 등록합니다. 실패 시 LIFO 순서로 자동 롤백됩니다.

#[derive(Clone)]
struct AuthorizePayment;

#[async_trait]
impl Transition<Order, Order> for AuthorizePayment {
    type Error = PaymentError;
    type Resources = ();
    async fn run(&self, input: Order, _res: &(), bus: &mut Bus)
        -> Outcome<Order, Self::Error> {
        bus.insert(AuthorizationId(format!("auth-{}", input.id)));
        Outcome::Next(input)
    }
}

#[derive(Clone)]
struct ReverseAuthorization;

#[async_trait]
impl Transition<Order, Order> for ReverseAuthorization {
    type Error = PaymentError;
    type Resources = ();
    async fn run(&self, input: Order, _res: &(), bus: &mut Bus)
        -> Outcome<Order, Self::Error> {
        let auth = bus.get_cloned::<AuthorizationId>()
            .unwrap_or(AuthorizationId("unknown".into()));
        tracing::info!("reversing authorization: {}", auth.0);
        Outcome::Next(input)
    }
}
// CapturePayment / RefundCapture — 동일 패턴, CaptureId 사용

Saga를 포함한 최종 Axon:

let payment_axon = Axon::typed::<Order, PaymentError>("payment-pipeline")
    .with_saga_policy(SagaPolicy::Enabled)
    .then(ValidateOrder).then(DetectFraud)
    .then_compensated(AuthorizePayment, ReverseAuthorization)
    .then_compensated(CapturePayment, RefundCapture)
    .then(RoutePayment)
    .branch("card", |b| b.then(SettleCard))
    .branch("bank_transfer", |b| b.then(SettleBank));

CapturePayment 실패 시: (1) RefundCapture (2) ReverseAuthorization — LIFO 순서.


#5단계: Schematic 추출과 diff 확인

ranvier schematic export --output schematic.json
ranvier schematic diff schematic-v1.json schematic-v2.json

Schematic은 노드, 에지, 분기 레이블, 보상 쌍을 JSON으로 직렬화합니다. CI에서 diff를 실행하면 PR 리뷰 과정에서 파이프라인 구조 변경을 바로 확인할 수 있습니다.


#6단계: Inspector 연동

use ranvier_runtime::Inspector;

let inspector = Inspector::new(payment_axon.schematic(), 9090);
tokio::spawn(async move { inspector.serve().await.expect("inspector failed") });
curl http://localhost:9090/schematic       # 구조 조회
curl http://localhost:9090/api/v1/traces   # 실행 트레이스
websocat ws://localhost:9090/ws/events     # 실시간 이벤트

개발 환경에서는 인증 없이 바로 사용할 수 있습니다. 프로덕션에서는 X-Ranvier-Role 헤더 기반 인증을 활성화하세요.


#7단계: ranvier-test로 테스트

#성공 케이스

#[cfg(test)]
mod tests {
    use super::*;
    use ranvier_test::prelude::*;

    #[tokio::test]
    async fn test_card_payment_succeeds() {
        let mut bus = Bus::new();
        let order = Order {
            id: "ord-001".into(), amount_cents: 5000, currency: "USD".into(),
            payment_method: PaymentMethod::Card { token: "tok_visa".into() },
            customer_id: "cust-1".into(),
        };
        let axon = Axon::typed::<Order, PaymentError>("test-payment")
            .then(ValidateOrder).then(DetectFraud).then(RoutePayment)
            .branch("card", |b| b.then(SettleCard))
            .branch("bank_transfer", |b| b.then(SettleBank));
        let result = axon.execute(order, &(), &mut bus).await;
        match result {
            Outcome::Next(p) => assert_eq!(p.status, "settled"),
            other => panic!("expected Next, got: {:?}", other),
        }
    }
}

#보상 케이스

#[tokio::test]
async fn test_saga_compensation_on_capture_failure() {
    let mut bus = Bus::new();
    let order = Order {
        id: "ord-002".into(), amount_cents: 3000, currency: "USD".into(),
        payment_method: PaymentMethod::Card { token: "tok_test".into() },
        customer_id: "cust-2".into(),
    };
    let axon = Axon::typed::<Order, PaymentError>("test-saga")
        .with_saga_policy(SagaPolicy::Enabled)
        .then(ValidateOrder)
        .then_compensated(AuthorizePayment, ReverseAuthorization)
        .then_compensated(FailingCapture, RefundCapture);
    let result = axon.execute(order, &(), &mut bus).await;
    assert!(result.is_fault());
    assert!(bus.get_cloned::<AuthorizationId>().is_ok()); // 보상 후에도 Bus 유지
}

#다음 단계

주제 문서
Saga 보상 심화 Saga 보상 Cookbook
Bus 접근 패턴 Bus 접근 패턴 Cookbook
Guard 패턴 Guard 패턴 Cookbook
멀티스텝 파이프라인 Multi-Step Pipeline Cookbook
HTTP 수신 패턴 HTTP Ingress Cookbook
Inspector 운영 Inspector 프로덕션 가이드
테스트 전략 테스팅 가이드