버전: 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/v4use 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.jsonSchematic은 노드, 에지, 분기 레이블, 보상 쌍을 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 프로덕션 가이드 |
| 테스트 전략 | 테스팅 가이드 |