#Bus 접근 패턴 Cookbook

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


#개요

Bus는 Ranvier의 타입 안전 리소스 컨테이너입니다. 모든 Transition은 &mut Bus를 받아 파이프라인 단계 간 상태를 공유합니다. 이 Cookbook에서는 자주 쓰이는 Bus 활용 패턴을 소개합니다.

기초 결정 가이드는 Bus 접근 패턴을 참조하세요.


#1. 접근 방법 결정 매트릭스

메서드 반환 부재 시 사용 시점
insert::<T>(value) -- 덮어쓰기 하위 단계를 위해 값 쓰기
read::<T>() Option<&T> None 선택적 데이터 -- 없어도 진행 가능
read_mut::<T>() Option<&mut T> None 기존 값을 제자리 수정
get::<T>() Result<&T, BusAccessError> Err 오류 메시지가 필요한 프로덕션 코드
get_mut::<T>() Result<&mut T, BusAccessError> Err 오류 보고가 필요한 가변 접근
require::<T>() &T panic 프레임워크가 존재를 보장
try_require::<T>() Option<&T> None read()의 의미론적 별칭
has::<T>() bool false 빌려오지 않고 존재 여부 확인
remove::<T>() Option<T> None 소유권 가져오기, Bus에서 제거
provide::<T>(value) -- 삽입 insert의 별칭, "제공" 의미 강조

#2. 충돌 방지를 위한 Newtype 래퍼

String이나 u64 같은 일반 타입은 같은 타입의 여러 값이 Bus에 있을 때 충돌합니다. 항상 Newtype 래퍼를 사용하세요.

#문제: 타입 충돌

// 나쁜 예: 둘 다 String을 삽입, 두 번째가 첫 번째를 덮어씀
bus.insert::<String>("user-123".into());     // UserId?
bus.insert::<String>("order-456".into());    // OrderId? UserId를 덮어씀!

#해결: Newtype 래퍼

#[derive(Debug, Clone)]
struct UserId(pub String);

#[derive(Debug, Clone)]
struct OrderId(pub String);

#[derive(Debug, Clone)]
struct TenantId(pub String);

// 각 타입이 Bus에서 자신만의 슬롯을 차지
bus.insert(UserId("user-123".into()));
bus.insert(OrderId("order-456".into()));
bus.insert(TenantId("tenant-789".into()));

// 각각 독립적으로 읽기
let user = bus.read::<UserId>();    // Some(UserId("user-123"))
let order = bus.read::<OrderId>();  // Some(OrderId("order-456"))

#관례

Bus 타입은 생산하거나 소비하는 모듈 근처에 정의합니다. Guard Bus 타입 (RequestOrigin, ClientIdentity, CorsHeaders 등)은 ranvier-guard에 정의됩니다.


#3. 데이터베이스 풀 공유

회로 실행 전에 Bus에 연결 풀을 삽입하여 모든 Transition에서 공유합니다.

use sqlx::PgPool;

#[derive(Debug, Clone)]
struct DbPool(pub PgPool);

// bus_injector로 풀 삽입 (요청당 한 번 실행)
Ranvier::http()
    .bus_injector({
        let pool = PgPool::connect(&database_url).await?;
        move |_parts: &http::request::Parts, bus: &mut Bus| {
            bus.insert(DbPool(pool.clone()));
        }
    })
    .get("/api/users", list_users_circuit)
    .run(())
    .await?;

Transition 내에서 풀을 읽습니다:

#[async_trait]
impl Transition<(), Vec<User>> for ListUsers {
    type Error = String;
    type Resources = ();

    async fn run(
        &self,
        _input: (),
        _resources: &Self::Resources,
        bus: &mut Bus,
    ) -> Outcome<Vec<User>, Self::Error> {
        let pool = &bus.require::<DbPool>().0;
        let users = sqlx::query_as::<_, User>("SELECT * FROM users")
            .fetch_all(pool)
            .await
            .map_err(|e| e.to_string())?;
        Outcome::next(users)
    }
}

대안: 풀이 모든 요청에 동일하다면 Bus 대신 Resources를 사용하세요. Resources는 전체 Axon 수명 동안 공유되고, Bus는 요청당 생성됩니다.


#4. Transition 간 데이터 전달

Bus는 파이프라인 내 Transition 간 데이터를 전달하는 표준 방법입니다. 앞 단계에서 insert한 값을 뒷 단계에서 read합니다.

use ranvier_core::prelude::*;
use ranvier_runtime::Axon;

#[derive(Debug, Clone)]
struct ValidationResult {
    is_valid: bool,
    warnings: Vec<String>,
}

#[derive(Debug, Clone)]
struct EnrichedOrder {
    original: CreateOrder,
    tax_amount: f64,
    shipping_cost: f64,
}

let order_pipeline = Axon::typed::<CreateOrder, String>("order-pipeline")
    // 단계 1: 검증하고 결과를 Bus에 쓰기
    .then_fn("validate", |order, _res, bus| async move {
        let result = ValidationResult {
            is_valid: order.quantity > 0,
            warnings: vec![],
        };
        bus.insert(result.clone());
        if result.is_valid {
            Outcome::next(order)
        } else {
            Outcome::fault("검증 실패".to_string())
        }
    })
    // 단계 2: 보강하고 보강된 데이터를 Bus에 쓰기
    .then_fn("enrich", |order, _res, bus| async move {
        let enriched = EnrichedOrder {
            tax_amount: order.quantity as f64 * 0.08,
            shipping_cost: 5.99,
            original: order,
        };
        bus.insert(enriched.clone());
        Outcome::next(enriched)
    })
    // 단계 3: 이전 두 결과 읽기
    .then_fn("finalize", |enriched, _res, bus| async move {
        let validation = bus.require::<ValidationResult>();
        let total = enriched.tax_amount + enriched.shipping_cost;
        Outcome::next(format!(
            "주문 확인: ${:.2} 총액, {} 경고",
            total,
            validation.warnings.len()
        ))
    });

#5. Guard Bus 타입

Guard는 HTTP 수준의 통신에 전용 Bus 타입을 사용합니다. 커스텀 Bus 인젝터를 작성하거나 Guard 출력을 읽을 때 이 타입들을 직접 참조할 수 있습니다.

#회로에서 Guard 출력 읽기

use ranvier_guard::prelude::*;

let circuit = Axon::simple::<String>("user-info")
    .then_fn("read-auth", |_input, _res, bus| async move {
        // AuthGuard가 인증 성공 후 IamIdentity를 삽입
        let identity = bus.get_cloned::<ranvier_core::iam::IamIdentity>();
        let subject = identity.map(|id| id.subject)
            .unwrap_or_else(|_| "anonymous".into());

        // RequestIdGuard가 RequestId (UUID v4)를 삽입
        let request_id = bus.get_cloned::<RequestId>()
            .map(|r| r.0)
            .unwrap_or_default();

        // CompressionGuard가 CompressionConfig를 삽입
        let encoding = bus.read::<CompressionConfig>()
            .map(|c| c.encoding.as_str())
            .unwrap_or("identity");

        Outcome::next(format!(
            "user={}, request={}, encoding={}",
            subject, request_id, encoding
        ))
    });

#6. Bus 접근 정책

Transition이 Bus에서 읽고 쓸 수 있는 타입을 제한합니다. 아키텍처 경계를 명확히 유지할 때 유용합니다.

use ranvier_core::bus::{Bus, BusAccessPolicy, BusTypeRef};

// 특정 타입만 허용
let policy = BusAccessPolicy::allow_only(vec![
    BusTypeRef::of::<UserId>(),
    BusTypeRef::of::<DbPool>(),
]);
bus.set_access_policy(policy);

// 또는 특정 민감한 타입만 거부
let policy = BusAccessPolicy::deny_only(vec![
    BusTypeRef::of::<AuthorizationHeader>(),
]);
bus.set_access_policy(policy);

// 완료 시 정책 제거
bus.clear_access_policy();

접근 정책이 활성화된 상태에서 허용되지 않은 타입을 읽으면 None이 반환되고, tracing::warn!으로 경고 로그가 남습니다.


#7. Bus를 이용한 테스트

단위 테스트에서 Bus::new()를 직접 사용하여 Transition 동작을 검증합니다:

#[tokio::test]
async fn test_order_enrichment() {
    let mut bus = Bus::new();
    bus.insert(DbPool(test_pool().await));

    let transition = EnrichOrder;
    let input = CreateOrder {
        product_id: "prod-1".into(),
        quantity: 2,
        shipping_address: "123 Main St".into(),
    };

    let result = transition.run(input, &(), &mut bus).await;
    assert!(matches!(result, Outcome::Next(_)));

    // Bus 부수 효과 검증
    let enriched = bus.get_cloned::<EnrichedOrder>().expect("Bus에 있어야 함");
    assert!(enriched.tax_amount > 0.0);
}

#9. `Bus::get_cloned()` — 소유 클론 (v0.43)

get_cloned()는 한 번의 호출로 소유 클론을 반환하여, 기존의 bus.read::<T>().cloned() 패턴을 대체합니다:

// BEFORE (v0.42):
let pool = bus.read::<PgPool>().cloned()
    .ok_or_else(|| "PgPool 없음".to_string())?;

// AFTER (v0.43):
let pool = bus.get_cloned::<PgPool>().expect("PgPool");

// Outcome 컨텍스트에서 try_outcome!과 함께:
use ranvier_core::try_outcome;
let pool = try_outcome!(bus.get_cloned::<PgPool>(), "PgPool이 Bus에 없음");

사용 시점: Bus에서 소유 T가 필요하고 해당 타입이 Clone을 구현할 때. 3단계 read() → cloned() → unwrap/expect 체인을 단일 메서드로 대체합니다.


#10. `BusHttpExt` 트레이트 — HTTP 파라미터 추출 (v0.43)

BusHttpExt 트레이트 (ranvier_http)는 HTTP Transition에서 path/query 파라미터를 편리하게 추출:

use ranvier_http::BusHttpExt;  // 또는 prelude를 통해

// Path 파라미터 (자동 파싱): /users/:id
let id: u64 = bus.path_param("id")?;  // Result<T: FromStr, String>

// Query 파라미터 (옵션): /items?page=2
let page: Option<i64> = bus.query_param("page");

// 기본값이 있는 Query: /items?per_page=10
let per_page: i64 = bus.query_param_or("per_page", 10);

Before (수동 PathParams 추출 — 7줄):

let id: u64 = match bus.read::<PathParams>().and_then(|p| p.get("id")) {
    Some(raw) => match raw.parse() {
        Ok(id) => id,
        Err(_) => return Outcome::Fault("유효하지 않은 ID".into()),
    },
    None => return Outcome::Fault("ID 누락".into()),
};

After (BusHttpExt — 4줄):

let id: u64 = match bus.path_param("id") {
    Ok(id) => id,
    Err(e) => return Outcome::Fault(e),
};

#관련 문서

  • Bus 접근 패턴 -- 결정 가이드 -- 기초 참고 문서
  • Guard 패턴 Cookbook -- Guard Bus 연결
  • Saga 보상 Cookbook -- Saga 컨텍스트에서의 Bus
  • Outcome 패턴 Cookbook -- try_outcome!, 컴비네이터
  • JSON Outcomes Cookbook -- 라우트 경계에서의 타입이 지정된 JSON