#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