#멀티테넌트 격리 Cookbook
버전: 0.40.0 | 업데이트: 2026-03-23 | 적용 대상: ranvier-core 0.40+ | 카테고리: 쿡북
#Overview
멀티테넌시는 SaaS 애플리케이션에서 가장 보편적인 요구사항이다. Ranvier의 Bus + Guard 모델은 프레임워크 확장 없이 테넌트 격리를 제공한다: Guard가 테넌트 ID를 추출하고, Bus가 이를 투명하게 전파하며, 모든 하위 Transition이 이를 읽는다.
이 쿡북은 테넌트 추출, Bus 전파, DB 쿼리 격리, 테넌트별 레이트 리밋, 테스트 전략을 다룬다 — 모두 기존 v0.40 프리미티브만으로 구현한다.
#1. 테넌트 컨텍스트 타입
파이프라인 전체에 테넌트 정보를 전달하는 단일 newtype을 정의한다.
/// Bus 타입: TenantGuard가 추출한 테넌트 식별자.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct TenantContext {
pub tenant_id: String,
pub plan: TenantPlan,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum TenantPlan {
Free,
Pro,
Enterprise,
}
impl TenantContext {
pub fn new(tenant_id: impl Into<String>, plan: TenantPlan) -> Self {
Self {
tenant_id: tenant_id.into(),
plan,
}
}
}TenantContext는 Guard와 Transition 모두에서 import할 수 있도록 공유 모듈에 정의한다.
#2. Tenant Guard — 헤더 추출
가장 간단한 전략: 요청 헤더에서 X-Tenant-Id를 읽는다. 커스텀 Guard가
테넌트를 검증하고 TenantContext를 Bus에 삽입한다.
use async_trait::async_trait;
use ranvier_core::prelude::*;
#[derive(Debug, Clone)]
pub struct TenantGuard<T> {
_marker: std::marker::PhantomData<T>,
}
impl<T> TenantGuard<T> {
pub fn new() -> Self {
Self { _marker: std::marker::PhantomData }
}
}
#[async_trait]
impl<T: Send + Sync + 'static> Transition<T, T> for TenantGuard<T> {
type Error = String;
type Resources = ();
async fn run(
&self,
input: T,
_resources: &Self::Resources,
bus: &mut Bus,
) -> Outcome<T, Self::Error> {
// bus_injector가 주입한 원시 헤더를 읽는다
let headers = bus.get_cloned::<Vec<(String, String)>>();
let tenant_id = headers.ok()
.and_then(|h| {
h.iter()
.find(|(k, _)| k == "x-tenant-id")
.map(|(_, v)| v.clone())
});
match tenant_id {
Some(id) if !id.is_empty() => {
// 테넌트 플랜 조회 (프로덕션에서는 DB/캐시 조회)
let plan = resolve_plan(&id);
bus.insert(TenantContext::new(id, plan));
Outcome::next(input)
}
_ => Outcome::fault("401 Unauthorized: missing X-Tenant-Id header".into()),
}
}
}
fn resolve_plan(tenant_id: &str) -> TenantPlan {
// 스텁 — 실제로는 DB/캐시 조회로 대체
match tenant_id {
id if id.starts_with("ent-") => TenantPlan::Enterprise,
id if id.starts_with("pro-") => TenantPlan::Pro,
_ => TenantPlan::Free,
}
}#3. 대안적 추출 전략
테넌트 ID가 어디에 있느냐에 따라 Guard 본문이 2~3줄만 바뀐다.
#JWT 클레임
// TenantGuard::run() 내부에서
let identity = bus.get_cloned::<ranvier_core::iam::IamIdentity>();
let tenant_id = identity.ok()
.and_then(|id| id.metadata.get("tenant_id").cloned());AuthGuard가 먼저 실행되어 IamIdentity를 Bus에 삽입해야 한다.
#서브도메인
// TenantGuard::run() 내부에서
let host = headers
.and_then(|h| h.iter().find(|(k, _)| k == "host").map(|(_, v)| v.clone()));
let tenant_id = host
.and_then(|h| h.split('.').next().map(String::from))
.filter(|sub| sub != "www" && sub != "api");#경로 프리픽스
// bus_injector에서 PathParams로 추출
use ranvier_http::PathParams;
if let Some(params) = parts.extensions.get::<PathParams>() {
if let Some(tid) = params.get("tenant_id") {
bus.insert(TenantContext::new(tid.clone(), TenantPlan::Free));
}
}
// 라우트: /api/:tenant_id/orders#4. Bus 전파
Guard가 TenantContext를 삽입하면, 모든 하위 Transition이 이를 투명하게 읽는다.
명시적 파라미터 전달이 필요 없다.
use ranvier_core::prelude::*;
use ranvier_runtime::Axon;
let order_pipeline = Axon::typed::<CreateOrder, String>("create-order")
.then_fn("validate", |order, _res, bus| async move {
let tenant = bus.require::<TenantContext>();
tracing::info!(tenant_id = %tenant.tenant_id, "주문 검증 중");
if order.quantity == 0 {
return Outcome::fault("수량은 0보다 커야 합니다".into());
}
Outcome::next(order)
})
.then_fn("persist", |order, _res, bus| async move {
let tenant = bus.require::<TenantContext>();
let pool = &bus.require::<DbPool>().0;
sqlx::query("INSERT INTO orders (tenant_id, product_id, quantity) VALUES ($1, $2, $3)")
.bind(&tenant.tenant_id)
.bind(&order.product_id)
.bind(order.quantity)
.execute(pool)
.await
.map_err(|e| e.to_string())?;
Outcome::next(format!("tenant {}의 주문이 생성됨", tenant.tenant_id))
});TenantContext는 추출 방식을 알 필요 없이 파이프라인 전체를 관통한다.
#5. DB 쿼리 격리
#행 수준 격리 (권장)
가장 단순하고 보편적인 전략. 모든 쿼리에 WHERE tenant_id = $1을 포함한다.
#[async_trait]
impl Transition<(), Vec<Order>> for ListOrders {
type Error = String;
type Resources = ();
async fn run(
&self,
_input: (),
_resources: &Self::Resources,
bus: &mut Bus,
) -> Outcome<Vec<Order>, Self::Error> {
let tenant = bus.require::<TenantContext>();
let pool = &bus.require::<DbPool>().0;
let orders = sqlx::query_as::<_, Order>(
"SELECT id, product_id, quantity, status FROM orders WHERE tenant_id = $1"
)
.bind(&tenant.tenant_id)
.fetch_all(pool)
.await
.map_err(|e| e.to_string())?;
Outcome::next(orders)
}
}#스키마별 격리
더 강한 격리가 필요하면 PostgreSQL 스키마를 사용한다. 쿼리 전에 search_path를 설정한다.
.then_fn("set-schema", |input, _res, bus| async move {
let tenant = bus.require::<TenantContext>();
let pool = &bus.require::<DbPool>().0;
// 이 커넥션의 search_path 설정
let schema = format!("tenant_{}", tenant.tenant_id.replace('-', "_"));
sqlx::query(&format!("SET search_path TO {schema}, public"))
.execute(pool)
.await
.map_err(|e| e.to_string())?;
Outcome::next(input)
})#테넌트별 커넥션 풀 (Enterprise)
최대 격리를 위해 테넌트별로 별도 커넥션 풀을 유지한다. Bus에 테넌트별 풀을 저장한다.
#[derive(Debug, Clone)]
pub struct TenantPoolRegistry(pub std::sync::Arc<dashmap::DashMap<String, PgPool>>);
// bus_injector 또는 Guard에서:
let registry = bus.require::<TenantPoolRegistry>();
let tenant = bus.require::<TenantContext>();
if let Some(pool) = registry.0.get(&tenant.tenant_id) {
bus.insert(DbPool(pool.clone()));
}#6. 테넌트별 레이트 리밋
TenantContext와 RateLimitGuard를 결합하여 테넌트별 요청 한도를 적용한다.
#플랜 기반 한도
use ranvier_guard::prelude::*;
fn tenant_rate_guard() -> impl Fn(&Bus) -> Option<(u64, u64)> {
|bus: &Bus| {
let tenant = bus.get_cloned::<TenantContext>().ok()?;
let (limit, window_ms) = match tenant.plan {
TenantPlan::Free => (100, 60_000), // 100 req/min
TenantPlan::Pro => (1_000, 60_000), // 1K req/min
TenantPlan::Enterprise => (10_000, 60_000), // 10K req/min
};
Some((limit, window_ms))
}
}#커스텀 레이트 리밋 Guard
#[derive(Debug, Clone)]
pub struct TenantRateLimitGuard<T> {
counters: std::sync::Arc<dashmap::DashMap<String, (u64, std::time::Instant)>>,
_marker: std::marker::PhantomData<T>,
}
#[async_trait]
impl<T: Send + Sync + 'static> Transition<T, T> for TenantRateLimitGuard<T> {
type Error = String;
type Resources = ();
async fn run(
&self,
input: T,
_resources: &Self::Resources,
bus: &mut Bus,
) -> Outcome<T, Self::Error> {
let tenant = bus.require::<TenantContext>();
let (limit, window) = match tenant.plan {
TenantPlan::Free => (100u64, std::time::Duration::from_secs(60)),
TenantPlan::Pro => (1_000, std::time::Duration::from_secs(60)),
TenantPlan::Enterprise => (10_000, std::time::Duration::from_secs(60)),
};
let key = tenant.tenant_id.clone();
let mut entry = self.counters.entry(key).or_insert((0, std::time::Instant::now()));
// 윈도우 만료 시 리셋
if entry.1.elapsed() > window {
entry.0 = 0;
entry.1 = std::time::Instant::now();
}
entry.0 += 1;
if entry.0 > limit {
Outcome::fault("429 Too Many Requests: 테넌트 레이트 리밋 초과".into())
} else {
Outcome::next(input)
}
}
}#7. 전체 서버 예제
TenantGuard, 테넌트별 레이트 리밋, DB 격리, bus_injector를 결합한 예제:
use ranvier_http::prelude::*;
use ranvier_guard::prelude::*;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let pool = sqlx::PgPool::connect(&std::env::var("DATABASE_URL")?).await?;
Ranvier::http()
.bind("0.0.0.0:8080")
.bus_injector({
let pool = pool.clone();
move |parts: &http::request::Parts, bus: &mut Bus| {
bus.insert(DbPool(pool.clone()));
// TenantGuard를 위해 원시 헤더를 주입
let headers: Vec<(String, String)> = parts.headers.iter()
.map(|(k, v)| (k.to_string(), v.to_str().unwrap_or("").to_string()))
.collect();
bus.insert(headers);
}
})
// 글로벌 Guard
.guard(RequestIdGuard::new())
.guard(AccessLogGuard::new())
.guard(CorsGuard::<()>::permissive())
.guard(TenantGuard::new()) // TenantContext 추출
.guard(TenantRateLimitGuard::new()) // 테넌트별 레이트 리밋
// 라우트 — 모든 Transition에서 bus.require::<TenantContext>() 가능
.post_typed("/api/orders", order_pipeline())
.get("/api/orders", list_orders_pipeline())
.run(())
.await?;
Ok(())
}#8. 테넌트별 감사 추적
테넌트 컨텍스트와 Ranvier의 감사 해시 체인을 결합하여 테넌트별 감사 로그를 남긴다.
use ranvier_audit::prelude::*;
.then_fn("audit-log", |result, _res, bus| async move {
let tenant = bus.require::<TenantContext>();
let audit = bus.require::<AuditTrail>();
audit.record(AuditEntry {
action: "order.created".into(),
actor: tenant.tenant_id.clone(),
resource: result.clone(),
timestamp: chrono::Utc::now(),
}).await;
Outcome::next(result)
})규제 준수를 위해 각 테넌트의 감사 추적은 독립적으로 검증 가능해야 한다. 감사 해시 체인은 테넌트 수에 관계없이 불변성을 보장한다.
#9. 멀티테넌트 격리 테스트
#Bus 단위 테스트
#[tokio::test]
async fn test_tenant_isolation() {
let mut bus_a = Bus::new();
bus_a.insert(TenantContext::new("tenant-a", TenantPlan::Pro));
bus_a.insert(DbPool(test_pool().await));
let mut bus_b = Bus::new();
bus_b.insert(TenantContext::new("tenant-b", TenantPlan::Free));
bus_b.insert(DbPool(test_pool().await));
let transition = ListOrders;
let result_a = transition.run((), &(), &mut bus_a).await;
let result_b = transition.run((), &(), &mut bus_b).await;
// 각 테넌트는 자신의 데이터만 볼 수 있는지 검증
match (&result_a, &result_b) {
(Outcome::Next(orders_a), Outcome::Next(orders_b)) => {
assert!(orders_a.iter().all(|o| o.tenant_id == "tenant-a"));
assert!(orders_b.iter().all(|o| o.tenant_id == "tenant-b"));
}
_ => panic!("둘 다 성공해야 함"),
}
}#Guard 거부 테스트
#[tokio::test]
async fn test_missing_tenant_header() {
let mut bus = Bus::new();
// 헤더 미주입 — TenantGuard가 거부해야 함
bus.insert::<Vec<(String, String)>>(vec![]);
let guard = TenantGuard::<String>::new();
let result = guard.run("input".into(), &(), &mut bus).await;
assert!(matches!(result, Outcome::Fault(_)));
}
#[tokio::test]
async fn test_valid_tenant_header() {
let mut bus = Bus::new();
bus.insert(vec![
("x-tenant-id".to_string(), "pro-acme".to_string()),
]);
let guard = TenantGuard::<String>::new();
let result = guard.run("input".into(), &(), &mut bus).await;
assert!(matches!(result, Outcome::Next(_)));
let ctx = bus.get_cloned::<TenantContext>().expect("Bus에 있어야 함");
assert_eq!(ctx.tenant_id, "pro-acme");
assert_eq!(ctx.plan, TenantPlan::Pro);
}#테넌트별 레이트 리밋 테스트
#[tokio::test]
async fn test_free_tenant_rate_limit() {
let guard = TenantRateLimitGuard::<String>::new();
let mut bus = Bus::new();
bus.insert(TenantContext::new("free-user", TenantPlan::Free));
// 처음 100개 요청은 통과해야 함
for _ in 0..100 {
let result = guard.run("ok".into(), &(), &mut bus).await;
assert!(matches!(result, Outcome::Next(_)));
}
// 101번째는 거부되어야 함
let result = guard.run("ok".into(), &(), &mut bus).await;
assert!(matches!(result, Outcome::Fault(_)));
}#See Also
- Guard Patterns Cookbook — Guard 조합 패턴
- Bus Access Patterns Cookbook — Bus 읽기/쓰기 의사 결정 가이드
- HttpIngress Patterns Cookbook — 라우트 등록 패턴
- Saga Compensation Cookbook — 테넌트 프로비저닝 Saga 패턴