axum + tower 미들웨어 체인에서 요청별 컨텍스트를 타입 안전하게 전파하는 법
Node.js에서 Express 미들웨어를 쓰거나 Go에서 chi 라우터의 context.WithValue를 써본 분이라면, 요청별 데이터를 핸들러까지 전달하는 일이 얼마나 손쉬운지 알 겁니다. 동시에 그게 얼마나 조용히 무너지는지도요. ctx.Value(authKey)가 nil을 반환하는데 컴파일러는 아무 말도 안 하고, 런타임에 패닉이 나거나 인증 없이 핸들러가 실행되는 버그를 잡느라 몇 시간을 쓴 경험, 한 번쯤 있을 겁니다.
Rust의 axum과 tower를 조합하면 이 문제를 상당 부분 컴파일 타임으로 끌어올릴 수 있습니다. 다만 뒤에서 다루겠지만 완전히는 아닙니다. State<T>는 타입 파라미터로 공유 상태를 보장하고, TypedHeader<T>는 헤더 파싱 오류를 추출 시점에 잡으며, Extension<T>는 런타임 타입 맵에 요청 스코프 데이터를 담습니다. 세 메커니즘의 역할을 명확히 구분하고 미들웨어 체인을 올바른 순서로 쌓으면, 인증·트레이싱·레이트리밋 컨텍스트가 요청 흐름을 따라 안전하게 전파됩니다. 대신 Extension<T>가 남기는 런타임 함정은 별도로 방어해야 합니다.
이 글은 axum과 tower를 처음 접하는 분보다는, Node.js나 Go 백엔드를 이미 운영하면서 성능 임계 경로에 Rust를 도입할지 검토 중인 분을 대상으로 합니다. 코드는 2026년 8월 시점 axum 0.8 계열과 그 주변 크레이트 기준입니다. 마이너 버전마다 API가 조금씩 바뀌니, 실제 프로젝트에서는 각자의 Cargo.lock과 문서를 함께 확인해 주세요.
axum이 tower를 그대로 채택한 이유
axum의 설계상 가장 독특한 점은 자체 미들웨어 시스템을 갖지 않는다는 겁니다. 대신 tower::Service와 tower::Layer 트레이트를 그대로 씁니다. 처음엔 왜 추상화를 하나 더 끼워 넣었지 싶었는데, 이 설계가 주는 이점이 상당합니다.
tower는 Hyper, Tonic(gRPC), axum 모두가 공유하는 추상화 계층입니다. tower-http에 이미 구현된 TraceLayer, CorsLayer, CompressionLayer, TimeoutLayer를 axum에서 추가 랩핑 없이 그대로 가져다 쓸 수 있습니다. Go 생태계에서 net/http 미들웨어를 chi나 gorilla/mux에 붙이는 것과 비슷한 느낌인데, 타입 시스템이 훨씬 강합니다.
레이어는 양방향으로 동작합니다. 요청이 내려가면서 각 레이어를 통과하고, 응답이 올라오면서 역순으로 다시 통과합니다. 이 흐름을 머릿속에 그려두면 미들웨어 순서 문제를 이해하기 훨씬 쉽습니다.
컨텍스트 전파의 세 가지 메커니즘
솔직히 처음엔 State랑 Extension이 뭐가 다른지 헷갈렸습니다. 직접 써보고 나서야 역할 분리가 명확해졌습니다.
| 메커니즘 | 타입 안전성 | 주 용도 |
|---|---|---|
State<T> |
컴파일 타임 | DB 풀, 설정 등 공유 불변 상태 |
Extension<T> |
런타임(타입 기반 맵) | 미들웨어가 핸들러로 넘기는 요청 스코프 데이터 |
TypedHeader<T> |
컴파일 타임 | Authorization, Content-Type 등 표준 헤더 |
State<T> 는 라우터에 .with_state()로 한 번 붙이면 해당 타입을 요구하는 핸들러 어디서든 꺼낼 수 있습니다. 컴파일러가 타입을 검사하므로 잘못된 타입을 넣으면 빌드가 실패합니다.
Extension<T> 는 미들웨어가 req.extensions_mut().insert(value)로 삽입하고 핸들러에서 Extension<T> 추출기로 꺼내는 구조입니다. 타입을 키로 쓰는 런타임 맵이라 컴파일 타임 보장이 없습니다. 미들웨어를 적용하지 않은 라우트에서 Extension<T>를 추출하면 컴파일은 통과하지만 런타임 500 에러가 발생합니다. 이 부분이 가장 주의해야 할 함정이며, 뒷 절에서 방어 패턴을 다룹니다.
TypedHeader<T> 는 axum-extra 크레이트에서 제공합니다. TypedHeader는 axum 0.6 시점에 이미 axum-extra로 이동한 API이므로, 최신 버전에서 새로 분리된 것이 아니라 이미 몇 년째 그 위치에 있습니다. 헤더를 구조체로 파싱해 추출기로 핸들러에 직접 주입하고, 파싱 실패 시 400 계열 응답을 반환합니다. 커스텀 헤더에 쓰려면 headers::Header 트레이트를 직접 구현해야 한다는 점을 기억해 두세요(뒤에서 다룹니다).
미들웨어 작성 방식 선택하기
tower에서 미들웨어를 작성하는 방법은 세 가지입니다.
1. axum::middleware::from_fn
가장 간단하고 대부분의 상황에 충분합니다. 다만 인증 로직에서는 헤더 누락 시 응답 시맨틱을 직접 관리해야 합니다. TypedHeader<Authorization<Bearer>>를 그대로 쓰면 헤더 자체가 없을 때 400 계열이 반환되는데, 인증 문맥에서는 401 Unauthorized가 맞습니다. 그래서 헤더를 옵셔널로 받거나 헤더 맵에서 직접 꺼내 명시적으로 401을 만들어 주는 편이 정확합니다.
use std::sync::Arc;
use axum::{
extract::{Request, State},
http::{header, StatusCode},
middleware::Next,
response::Response,
};
async fn auth_middleware(
State(db): State<Arc<Database>>,
mut req: Request,
next: Next,
) -> Result<Response, StatusCode> {
let token = req
.headers()
.get(header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.and_then(|s| s.strip_prefix("Bearer "))
.ok_or(StatusCode::UNAUTHORIZED)?;
let user = validate_token(token, &db)
.await
.map_err(|_| StatusCode::UNAUTHORIZED)?;
req.extensions_mut().insert(user);
Ok(next.run(req).await)
}헤더가 없을 때 401, 토큰이 유효하지 않을 때도 401을 돌려주도록 시맨틱을 통일했습니다. TypedHeader를 굳이 쓰고 싶다면 Option<TypedHeader<Authorization<Bearer>>>로 받아서 None을 401로 매핑하는 방법도 있습니다.
2. FromRequestParts / FromRequest 구현
헤더 파싱이나 쿠키 검증처럼 요청 파트만 필요한 경우엔 FromRequestParts, 바디도 읽어야 하면 FromRequest를 구현합니다. 추출기(extractor) 자체가 인증 로직을 담는 형태로, 핸들러 시그니처에 AuthUser처럼 도메인 타입을 직접 쓸 수 있습니다.
axum 0.8 계열은 Rust 1.75+의 네이티브 async fn in trait(AFIT)을 사용하므로, 예전처럼 #[async_trait]를 붙이지 않아도 됩니다. 이전 axum 0.7 이하 코드베이스에서 옮겨왔다면 어트리뷰트를 제거해야 컴파일이 통과합니다.
use axum::{
extract::{Extension, FromRequestParts},
http::{request::Parts, StatusCode},
RequestPartsExt,
};
pub struct AuthUser(pub User);
impl<S> FromRequestParts<S> for AuthUser
where
S: Send + Sync,
{
type Rejection = StatusCode;
async fn from_request_parts(
parts: &mut Parts,
_state: &S,
) -> Result<Self, Self::Rejection> {
let Extension(user) = parts
.extract::<Extension<User>>()
.await
.map_err(|_| StatusCode::UNAUTHORIZED)?;
Ok(AuthUser(user))
}
}이렇게 감싸두면 핸들러에서 AuthUser(user): AuthUser 형태로 꺼낼 수 있고, Extension<User>가 없을 때 500 대신 명시적인 401을 돌려주게 됩니다. 앞서 지적한 Extension<T> 런타임 함정을 도메인 레이어에서 방어하는 실용적인 패턴입니다.
3. tower::Service 직접 구현
최대 제어권이 필요할 때만 씁니다. Poll::Ready와 BoxFuture 등 Rust 비동기 내부 구조를 이해해야 하므로, 특별한 이유가 없다면 from_fn을 먼저 고려하는 게 좋습니다.
실전 체인 구성: 트레이싱, 레이트리밋, 인증
route_layer와 .layer()의 적용 위치
ServiceBuilder 내부에서 레이어를 쌓는 순서는 실행 순서와 같지만, Router의 .layer()와 .route_layer()를 섞으면 이야기가 조금 달라집니다. 규칙은 이렇습니다.
route_layer()로 붙인 레이어는 해당 라우트에만 적용됩니다..layer()로 붙인 레이어는 그 시점까지 붙은 모든 것을 감싸는 바깥층이 됩니다.- 따라서 인증을
route_layer로 먼저 붙이고TraceLayer를.layer()로 나중에 붙이면, 실행 순서는TraceLayer→ 인증 → 핸들러가 됩니다.
정리하면 "나중에 .layer()로 붙은 것이 더 바깥"이라고 기억하면 됩니다.
use axum::{routing::{get, post}, Router, middleware};
use tower::ServiceBuilder;
use tower_http::trace::TraceLayer;
let public_routes = Router::new()
.route("/health", get(health_handler))
.route("/auth/login", post(login_handler));
let protected_routes = Router::new()
.route("/api/users", get(users_handler))
.route("/api/users/{id}", get(user_handler))
.route_layer(middleware::from_fn_with_state(
state.clone(),
auth_middleware,
));
let app = Router::new()
.merge(public_routes)
.merge(protected_routes)
.layer(
ServiceBuilder::new()
.layer(TraceLayer::new_for_http())
.layer(GovernorLayer { config: governor_config.clone() }),
)
.with_state(state);트레이싱 → 레이트리밋 → 인증 순서로 배치하는 이유가 있습니다. 트레이싱이 가장 바깥에 있어야 인증 실패·레이트리밋 초과 응답도 스팬에 기록됩니다. 또한 인증을 레이트리밋보다 안쪽에 두면, 무효 토큰으로 오는 트래픽도 레이트리밋 카운터를 소모하기 때문에 남용 방지에 유리합니다.
분산 트레이싱: W3C Trace Context 자동 전파
axum-tracing-opentelemetry 크레이트를 쓰면 인바운드 traceparent 헤더를 자동으로 추출해 스팬 컨텍스트를 연결해줍니다. 정확한 버전은 각자 crates.io 페이지에서 확인하는 편이 안전합니다.
use axum_tracing_opentelemetry::middleware::OtelAxumLayer;
use opentelemetry::global;
use opentelemetry_sdk::propagation::TraceContextPropagator;
global::set_text_map_propagator(TraceContextPropagator::new());
let app = Router::new()
.route("/api/resource", get(resource_handler))
.layer(
ServiceBuilder::new()
.layer(OtelAxumLayer::default())
.layer(TraceLayer::new_for_http()),
);핸들러에서 자식 스팬을 만들 때는 동기 코드에서 쓰는 span.enter()를 async 함수 안에서 쓰면 안 됩니다. tokio 런타임에서 태스크가 다른 스레드로 이동하면 스팬 스택이 오염됩니다. 대신 Instrument 트레이트로 future 자체에 스팬을 붙여야 합니다.
use tracing::Instrument;
async fn resource_handler(
Extension(user): Extension<User>,
) -> impl IntoResponse {
async move {
Json(fetch_data().await)
}
.instrument(tracing::info_span!("fetch_resource", user_id = %user.id))
.await
}또는 함수 전체에 스팬을 붙이고 싶다면 #[tracing::instrument] 어트리뷰트를 쓰면 됩니다. 이쪽이 더 관용적입니다.
#[tracing::instrument(skip(user), fields(user_id = %user.id))]
async fn resource_handler(
Extension(user): Extension<User>,
) -> impl IntoResponse {
Json(fetch_data().await)
}IP 기반 레이트리밋
tower-governor는 governor 크레이트의 GCRA 알고리즘 기반입니다. SmartIpKeyExtractor를 쓰면 X-Forwarded-For → X-Real-IP → peer IP 순서로 클라이언트 IP를 결정합니다. 리버스 프록시 뒤에서 신뢰할 헤더 목록은 반드시 인프라 구성과 맞춰 검증하세요.
use std::sync::Arc;
use tower_governor::{governor::GovernorConfigBuilder, GovernorLayer};
let governor_config = Arc::new(
GovernorConfigBuilder::default()
.per_second(50)
.burst_size(100)
.use_headers()
.finish()
.unwrap(),
);
let app = Router::new()
.route("/api/resource", get(resource_handler))
.layer(GovernorLayer { config: governor_config });레이트리밋 초과 시 429 Too Many Requests와 Retry-After 헤더가 자동으로 반환됩니다.
멀티테넌트 컨텍스트: 커넥션 풀과 스키마 격리
API 키로 테넌트를 식별하는 패턴을 흔히 씁니다. 여기서 자주 저지르는 실수 두 가지를 미리 짚고 갑니다.
첫째, TypedHeader<XApiKey>처럼 커스텀 헤더 타입을 쓰려면 headers::Header 트레이트를 직접 구현해야 합니다. 구현 코드 없이 쓰면 컴파일이 안 됩니다. 간단한 API 키라면 그냥 HeaderMap에서 뽑거나 직접 추출기를 만드는 편이 코드 무게가 가볍습니다.
둘째, SET search_path는 커넥션 세션 레벨 설정입니다. 커넥션 풀에서 꺼낸 커넥션에 SET search_path를 걸고 반환하면, 다음에 다른 테넌트가 그 커넥션을 재사용할 때 이전 값이 남아 크로스 테넌트 데이터 노출로 이어질 수 있습니다. 격리 방식은 크게 두 가지입니다.
- 트랜잭션 범위로
SET LOCAL사용: 커밋/롤백 시점에 자동으로 원복됩니다. 단,SET은 파라미터 바인딩을 지원하지 않으므로 스키마 이름은 안전한 식별자인지 검증한 뒤 인터폴레이션합니다. - 스키마 정규화된 쿼리: 애플리케이션이 항상
"tenant_a"."users"형태로 쿼리를 만들면 세션 상태에 의존하지 않아 가장 안전합니다.
아래는 트랜잭션 방식의 개념적 예시입니다.
use axum::{
extract::{Extension, Request, State},
http::{header, StatusCode},
middleware::Next,
response::{IntoResponse, Response},
Json,
};
use sqlx::PgPool;
use uuid::Uuid;
#[derive(Clone)]
struct TenantContext {
tenant_id: Uuid,
schema: String,
}
async fn tenant_middleware(
mut req: Request,
next: Next,
) -> Result<Response, StatusCode> {
let api_key = req
.headers()
.get("x-api-key")
.and_then(|v| v.to_str().ok())
.ok_or(StatusCode::UNAUTHORIZED)?;
let tenant = resolve_tenant(api_key)
.await
.map_err(|_| StatusCode::UNAUTHORIZED)?;
req.extensions_mut().insert(TenantContext {
tenant_id: tenant.id,
schema: tenant.db_schema,
});
Ok(next.run(req).await)
}
fn is_safe_ident(s: &str) -> bool {
!s.is_empty()
&& s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
}
async fn data_handler(
Extension(tenant): Extension<TenantContext>,
State(pool): State<PgPool>,
) -> Result<impl IntoResponse, StatusCode> {
if !is_safe_ident(&tenant.schema) {
return Err(StatusCode::BAD_REQUEST);
}
let mut tx = pool.begin().await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let stmt = format!("SET LOCAL search_path TO {}", tenant.schema);
sqlx::query(&stmt)
.execute(&mut *tx)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let rows: Vec<Row> = sqlx::query_as("SELECT id, name FROM widgets")
.fetch_all(&mut *tx)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
tx.commit().await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(Json(rows))
}SET LOCAL은 트랜잭션이 끝나는 순간 원복되므로, 커넥션이 풀에 반환된 뒤 다른 테넌트가 이어받아도 이전 스키마가 남지 않습니다. 스키마 이름 검증은 SQL 인젝션 방어를 위한 최소 요건입니다.
흔한 함정과 트레이드오프
Extension<T> 런타임 위험
앞서 언급했지만 다시 강조합니다. 미들웨어 스코프 밖의 라우트에서 Extension<User>를 추출하면 컴파일은 통과하지만 런타임 500이 납니다. 방어 방법 두 가지.
Option<Extension<User>>로 받아 명시적으로 처리한다.FromRequestParts로 감싸AuthUser처럼 도메인 추출기를 만들고, 내부에서 실패 시 401/403 등 의도한 응답 코드를 돌려준다.
이 함정 때문에 이 글의 큰 주장인 "런타임 에러의 컴파일 타임 승격"은 일부 예외를 남긴다는 점을 인정해야 합니다. Extension<T>는 편리한 만큼 규율이 필요한 도구입니다.
미들웨어 순서 실수
| 잘못된 순서 | 문제 |
|---|---|
| 인증을 트레이싱보다 바깥에 배치 | 인증 실패 응답이 스팬에 기록되지 않음 |
| 레이트리밋을 트레이싱보다 바깥에 배치 | 429 응답에 트레이스 ID 없음 |
| 인증을 레이트리밋보다 바깥에 배치 | 무효 토큰 요청은 레이트리밋을 우회 |
| 레이트리밋을 인증보다 바깥에 배치(권장) | 무효 토큰이라도 레이트리밋 카운터 소모 |
컴파일 시간
대규모 미들웨어 체인은 제네릭 모노모피화로 컴파일 시간이 늘어날 수 있습니다. 개발 중에는 cargo check와 증분 빌드를 최대한 활용하고, 필요하면 tower::util::BoxLayer로 타입을 지워 컴파일 단위를 분리할 수 있습니다.
생태계 성숙도와 성능
Express나 Go의 chi에 비해 고수준 플러그인 선택지가 적습니다. ORM, 인증 라이브러리 등 직접 구현해야 할 컴포넌트가 늘어날 수 있습니다.
성능 비교는 이 글에서 구체 수치를 옮겨 적지 않겠습니다. 스택 간 처리량은 하드웨어, 워크로드, 커넥션 모델, 튜닝 상태에 따라 크게 달라지고, 신뢰할 만한 비교는 TechEmpower Framework Benchmarks 같은 공개 벤치마크나 여러분의 프로덕션 트래픽에 근접한 자체 측정에서 나옵니다. 마케팅성 단일 출처 수치는 의사결정 근거로 쓰기 어렵습니다.
| 항목 | Node.js Express | Go chi | Rust axum |
|---|---|---|---|
| 미들웨어 타입 안전성 | 런타임 | 부분적 | 컴파일+런타임 혼합 |
| 처리량 특성 | 이벤트 루프 단일 스레드 | 경량 goroutine 병렬 | 멀티스레드 async 런타임 |
| 미들웨어 생태계 | 성숙 | 성숙 | 성장 중 |
| 학습 곡선 | 낮음 | 중간 | 높음(소유권·라이프타임) |
| 컴파일 오버헤드 | 없음 | 낮음 | 중간~높음 |
마무리: 언제 이 조합을 쓰고, 언제 미룰 것인가
axum + tower 조합의 핵심은 역할 분리입니다. State<T>는 앱 전체에서 공유하는 불변 상태, Extension<T>는 미들웨어가 요청 스코프로 주입하는 동적 데이터, TypedHeader는 표준 헤더의 타입 안전한 파싱. 이 셋을 혼용하지 않고 역할에 맞게 쓰면 런타임 에러의 상당 부분을 컴파일 타임으로 끌어올릴 수 있습니다.
하지만 완전한 컴파일 타임 보장은 아닙니다. Extension<T>는 여전히 미들웨어 부착을 잊으면 런타임에 터지고, search_path 같은 세션 상태 격리처럼 타입 시스템이 잡아주지 못하는 영역이 존재합니다. 이런 지점에는 FromRequestParts 래퍼, 트랜잭션 스코프, 스키마 이름 화이트리스트 같은 별도의 규율이 필요합니다.
언제 axum을 진지하게 검토할 만한가: 처리량과 꼬리 지연이 매출/UX에 직접 영향을 주는 서비스, 장시간 안정성이 중요한 백그라운드 워커, 이미 팀에 Rust 지식이 있는 경우.
언제 지금은 미루는 게 나은가: 팀 학습 곡선을 흡수할 여유가 없을 때, 필요한 도메인 라이브러리가 아직 성숙하지 않은 영역(특정 SaaS SDK 등), 기능 변경 속도가 성능보다 훨씬 중요한 초기 제품 단계.
접근은 점진적으로 하는 편이 안전합니다. 성능 병목이 뚜렷한 서비스 하나를 골라 axum으로 옮기고, 위에서 다룬 미들웨어 순서·컨텍스트 전파 패턴을 팀 내 관용구로 굳힌 뒤 다음 서비스로 확장하는 흐름이 현실적입니다.
참고 자료