Axum 레이어를 이용한 관측 가능성과 보안을 위한 모듈식 웹 서비스 구축
James Reed
Infrastructure Engineer · Leapcell

소개
빠르게 발전하는 웹 서비스 개발 환경에서 견고하고 확장 가능하며 유지보수 가능한 애플리케이션을 구축하는 것은 매우 중요합니다. 관측 가능성(로깅 및 추적)과 보안(인증)은 단순한 사후 고려 사항이 아니라 모든 프로덕션급 시스템의 근본적인 기둥입니다. 서비스가 복잡해질수록 이러한 교차 관심사를 효과적이고 비침습적으로 통합하는 것은 상당한 도전 과제가 됩니다. 바로 여기서 미들웨어가 빛을 발하며, 핵심 비즈니스 로직과 별도로 이러한 관심사를 캡슐화하는 강력한 패턴을 제공합니다. Rust 생태계에서 인기 있는 웹 프레임워크인 Axum은 Tower 프로젝트에서 상당 부분 영감을 받은 '레이어' 시스템을 통해 미들웨어를 구현하는 우아하고 유연한 메커니즘을 제공합니다. Axum 레이어를 이해하고 활용하면 개발자는 로깅, 인증, 추적과 같은 필수 기능을 놀랍도록 쉽게 재사용할 수 있도록 주입하여 매우 모듈화되고 관측 가능한 서비스를 구축할 수 있습니다. 이 글에서는 Axum 레이어의 내부를 살펴보고 이러한 중요한 측면에 대한 사용자 정의 미들웨어를 구축하는 과정을 안내하며, 이를 통해 Rust 웹 애플리케이션의 구조와 유지관리성을 획기적으로 개선할 수 있는 방법을 설명합니다.
Axum 레이어 이해하기
구현에 들어가기 전에 Axum 레이어와 관련된 몇 가지 핵심 용어를 명확히 하겠습니다.
서비스(Service): Tower 및 Axum의 맥락에서 Service
는 요청을 받아 응답 또는 오류의 미래를 반환하는 비동기 함수를 정의하는 트레잇입니다. 요청 처리를 위한 기본 빌딩 블록입니다. Axum 핸들러는 효과적으로 서비스입니다.
레이어(Layer): Layer
는 Service
를 받아 래핑하고 새 Service
를 반환하는 트레잇입니다. 이 새 서비스는 기본 서비스를 호출하기 전, 후 또는 심지어 대신하여 작업을 수행할 수 있습니다. 레이어는 구성 가능하므로 여러 레이어를 쌓아 처리 단계의 파이프라인을 만들 수 있습니다.
미들웨어(Middleware): Layer
는 특정 Tower 트레잇이지만, '미들웨어'는 이를 구현하는 더 일반적인 개념입니다. 미들웨어는 요청과 응답을 가로채 로그 기록, 인증, 캐싱 등과 같은 보조 작업을 수행합니다.
Tower: Tower는 강력한 클라이언트 및 서버 애플리케이션 구축을 위한 모듈식 구성 요소 라이브러리입니다. Axum은 Tower의 Service
및 Layer
트레잇을 광범위하게 활용하여 기능을 확장하는 강력하고 관용적인 방법을 제공합니다.
Axum 레이어의 원리
Axum 레이어의 힘은 구성 가능성에 있습니다. 각 레이어는 래핑하는 서비스에 기능을 추가하는 장식자 역할을 합니다. 요청이 들어오면 적용된 순서대로 각 레이어를 통과한 다음 가장 안쪽 서비스(핸들러)에 도달하고, 마지막으로 응답이 역순으로 레이어를 통해 다시 나옵니다. 이 '양파 껍질' 모델은 관심사를 명확하게 분리할 수 있습니다.
사용자 정의 레이어는 일반적으로 두 가지 주요 구성 요소를 정의합니다.
- 레이어 구조체: 이 구조체는
tower::Layer
트레잇을 구현합니다.layer
메서드는 내부 서비스를 받아 이를 래핑하는 새Service
를 반환합니다. - 서비스 구조체: 이 구조체는
tower::Service
트레잇을 구현합니다. 내부 서비스에 대한 참조를 보유하고 내부 서비스를 호출하기 전후에 실행되는 로직을 캡슐화합니다.
이를 로깅, 인증 및 추적에 대한 실제 예제로 설명하겠습니다.
사용자 정의 로깅 레이어
로깅 레이어는 운영 환경에서 애플리케이션의 작동 방식을 이해하고, 오류를 추적하며, 요청 흐름을 모니터링하는 데 중요합니다.
use axum::{ body::{Body, BoxBody}, http::{ Request, Response, StatusCode }, middleware::Next, response::IntoResponse, routing::get, Router, }; use std::{ task::{{ Context, Poll }}, time::Instant, }; use tower::{ Layer, Service }; use tracing::{info, instrument}; // 1. 로깅 레이어 정의 #[derive(Debug, Clone)] struct LogLayer; impl<S> Layer<S> for LogLayer { type Service = LogService<S>; fn layer(&self, inner: S) -> Self::Service { LogService { inner } } } // 2. 로깅 서비스 정의 #[derive(Debug, Clone)] struct LogService<S> { inner: S, } impl<S, B> Service<Request<B>> for LogService<S> where S: Service<Request<B>, Response = Response<BoxBody>> + Send + 'static, S::Future: Send + 'static, B: Send + 'static, { type Response = S::Response; type Error = S::Error; type Future = futures::future::BoxFuture<'static, Result<Self::Response, Self::Error>>; fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> { self.inner.poll_ready(cx) } fn call(&mut self, request: Request<B>) -> Self::Future { let path = request.uri().path().to_owned(); let method = request.method().to_string(); info!(%method, %path, "Incoming request"); let start = Instant::now(); let future = self.inner.call(request); Box::pin(async move { let response = future.await?; let latency = start.elapsed(); let status = response.status(); info!(%method, %path, %status, "Request finished in {}ms", latency.as_millis()); Ok(response) }) } } // 레이어를 생성하는 헬퍼 함수 (선택 사항이지만 편리함) fn log_layer() -> LogLayer { LogLayer }
이 예시에서:
LogLayer
는 상태를 보유하지 않으며Layer
트레잇을 구현합니다.layer
메서드는LogService
인스턴스를 생성하여 내부 서비스를 래핑합니다.LogService<S>
는inner
서비스를 보유합니다.call
메서드는 실제 로깅 로직이 있는 곳입니다. 내부inner.call()
을 호출하기 전에 로그를 기록한 다음 응답이 수신된 후 요청 메서드, 경로, 상태 및 지연 시간을 포함하여 다시 로그를 기록합니다.- Rust에서 강력히 권장되는 구조화된 로깅을 위해
tracing
을 사용합니다.
사용자 정의 인증 레이어
인증 레이어는 승인되지 않은 접근을 방지하기 위해 허가된 요청만 핵심 비즈니스 로직에 도달하도록 보장합니다.
use axum::http::HeaderValue; use std::collections::HashMap; // 시연을 위한 간단한 인메모리 사용자 저장소 lazy_static::lazy_static! { static ref USERS: HashMap<&'static str, &'static str> = { let mut m = HashMap::new(); m.insert("admin", "password123"); m.insert("user", "mysecret"); m }; } // 인증 실패를 위한 오류 유형 정의 #[derive(Debug)] enum AuthError { InvalidCredentials, MissingCredentials, } impl IntoResponse for AuthError { fn into_response(self) -> Response<BoxBody> { let (status, msg) = match self { AuthError::InvalidCredentials => (StatusCode::UNAUTHORIZED, "Invalid credentials"), AuthError::MissingCredentials => (StatusCode::UNAUTHORIZED, "Missing Authorization header"), }; (status, msg).into_response() } } // 1. 인증 레이어 정의 #[derive(Debug, Clone)] struct AuthLayer; impl<S> Layer<S> for AuthLayer { type Service = AuthService<S>; fn layer(&self, inner: S) -> Self::Service { AuthService { inner } } } // 2. 인증 서비스 정의 #[derive(Debug, Clone)] struct AuthService<S> { inner: S, } impl<S, B> Service<Request<B>> for AuthService<S> where S: Service<Request<B>, Response = Response<BoxBody>, Error = AuthError> + Send + 'static, // 내부 서비스는 AuthError를 반환할 수 있습니다. S::Future: Send + 'static, B: Send + 'static, { type Response = S::Response; type Error = AuthError; // 이 서비스도 AuthError를 반환할 수 있습니다. type Future = futures::future::BoxFuture<'static, Result<Self::Response, Self::Error>>; fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> { self.inner.poll_ready(cx) } fn call(&mut self, mut request: Request<B>) -> Self::Future { let auth_header = request.headers().get(axum::http::header::AUTHORIZATION); let authenticated = match auth_header { Some(header_value) => { let header_str = header_value.to_str().unwrap_or_default(); if header_str.starts_with("Basic ") { let encoded_credentials = header_str["Basic ".len()..].trim(); let decoded_bytes = base64::decode(encoded_credentials).unwrap_or_default(); let decoded_str = String::from_utf8(decoded_bytes).unwrap_or_default(); let parts: Vec<&str> = decoded_str.split(':').collect(); if parts.len() == 2 { let (username, password) = (parts[0], parts[1]); USERS.get(username) == Some(&password) } else { false } } else { false } } None => false, }; if !authenticated { return Box::pin(async { Err(AuthError::InvalidCredentials) }); } // 인증된 경우 사용자 ID를 추출하여 요청 확장 기능에 삽입할 수 있습니다. // request.extensions_mut().insert(UserId("some-user-id".to_string())); let future = self.inner.call(request); Box::pin(async move { future.await // 내부 서비스로 전달 }) } } // 레이어를 생성하는 헬퍼 함수 fn auth_layer() -> AuthLayer { AuthLayer }
인증의 주요 요점:
- 맞춤형
AuthError
를 정의하고IntoResponse
를 구현하므로 Axum에서 인증 실패를 우아하게 처리할 수 있습니다. AuthService
는Authorization
헤더를 추출하고, Basic 인증 자격 증명을 파싱하려고 시도하며,USERS
맵과 확인합니다.- 인증에 실패하면 즉시
Err(AuthError::InvalidCredentials)
를 반환합니다. 그렇지 않으면 요청을 내부 서비스로 전달합니다. - 중요:
AuthService
와inner
서비스 제약 모두에 대해Error = AuthError
를 정의하는 것을 확인합니다. 이는 내부 서비스도AuthError
로 실패하면 올바르게 전파된다는 것을 의미합니다.
사용자 정의 추적 레이어
추적은 단일 서비스 내에서, 그리고 서로 다른 서비스 간에 요청의 흐름을 시각화하는 데 도움이 되며, 분산 시스템 및 성능 분석 디버깅에 매우 유용합니다. Axum은 이미 tracing
과 잘 통합되어 있지만, 사용자 정의 레이어는 추적을 풍부하게 하거나 특정 컨텍스트를 추가할 수 있습니다. 종종 tower_http::trace::TraceLayer
와 같은 전용 추적 레이어를 사용합니다. 그러나 교육 목적으로 몇 가지를 설명하기 위해 단순화된 버전을 만들어 보겠습니다.
use axum_extra::extract::Extension; use uuid::Uuid; // 추적을 위해 요청에 삽입할 데이터 #[derive(Clone)] struct RequestId(String); // 1. 추적 레이어 정의 #[derive(Debug, Clone)] struct TraceLayer; impl<S> Layer<S> for TraceLayer { type Service = TraceService<S>; fn layer(&self, inner: S) -> Self::Service { TraceService { inner } } } // 2. 추적 서비스 정의 #[derive(Debug, Clone)] struct TraceService<S> { inner: S, } impl<S, B> Service<Request<B>> for TraceService<S> where S: Service<Request<B>, Response = Response<BoxBody>> + Send + 'static, S::Future: Send + 'static, B: Send + 'static, { type Response = S::Response; type Error = S::Error; type Future = futures::future::BoxFuture<'static, Result<Self::Response, Self::Error>>; fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> { self.inner.poll_ready(cx) } fn call(&mut self, mut request: Request<B>) -> Self::Future { // 고유 요청 ID 생성 let request_id = Uuid::new_v4().to_string(); info!(request_id = %request_id, "Assigning request ID"); // 핸들러가 액세스할 수 있도록 요청 확장 기능에 요청 ID 삽입 request.extensions_mut().insert(RequestId(request_id.clone())); // 수신 서비스에 대한 추적 ID 전파를 위해 헤더로도 추가 request.headers_mut().insert("X-Request-ID", HeaderValue::from_str(&request_id).unwrap()); // 요청에 대한 추적 스팬 생성 let span = tracing::info_span!("request", request_id = %request_id, method = %request.method(), path = %request.uri().path()); let _guard = span.enter(); // 요청 기간 동안 스팬 진입 let future = self.inner.call(request); Box::pin(async move { let response = future.await?; info!(request_id = %request_id, status = %response.status(), "Request processed"); Ok(response) }) } } // 레이어를 생성하는 헬퍼 함수 fn trace_layer() -> TraceLayer { TraceLayer }
추적의 경우:
- 고유한
request_id
를 생성하기 위해uuid
를 사용합니다. - 이 ID는
request.extensions_mut()
에 삽입되어 후속 레이어 및Extension<RequestId>
를 사용하는 Axum 핸들러에서 액세스할 수 있게 합니다. tracing::info_span!
이 생성되어 이 요청 컨텍스트 내의 모든 로그가 자동으로request_id
를 포함하도록 합니다.X-Request-ID
헤더가 추가되어 서비스 경계를 넘나드는 추적 ID 전파에 유용합니다.
Axum 라우터에 레이어 구성하기
이제 이러한 레이어를 조립하고 Axum 애플리케이션에 적용해 보겠습니다.
use axum::{ extract::State, middleware, response::Html, routing::{get, post}, Router, }; use std::{sync::Arc, time::Duration}; use tokio::net::TcpListener; use tower_http::{ trace::TraceLayer as TowerTraceLayer, ServiceBuilder }; // 더 나은 기능을 위해 tower-http의 추적 기능 사용 use tracing_subscriber::{{ layer::SubscriberExt, util::SubscriberInitExt }}; // 인증이 필요하고 요청 ID에 액세스할 수 있는 핸들러 #[instrument(skip(State))] async fn protected_handler( State(app_state): State<Arc<String>>, Extension(request_id): Extension<RequestId>, ) -> Html<String> { info!("Protected handler에 요청 ID {}로 액세스 중", request_id.0); Html(format!( "<h1>Protected handler에서 온 안녕하세요!</h1><p>App State: {}</p><p>Request ID: {}</p>", app_state, request_id.0 )) } // 인증이 필요하지 않은 핸들러 async fn public_handler() -> Html<String> { info!("Public handler에 액세스 중"); Html("<h1>Public handler에서 온 안녕하세요!</h1>".to_string()) } #[tokio::main] async fn main() { // 추적 초기화 tracing_subscriber::registry() .with( tracing_subscriber::EnvFilter::try_from_default_env() .unwrap_or_else(|_| "info,tower_http=debug".into()), ) .with(tracing_subscriber::fmt::layer()) .init(); info!("서버 시작 중..."); let app_state = Arc::new("My Awesome App".to_string()); // 사용자 정의 레이어 빌드 let custom_layers = ServiceBuilder::new() // 사용자 정의 로깅 레이어 (모든 것을 로깅하려면 외부여야 함) .layer(log_layer()) // 사용자 정의 추적 레이어 (요청을 계측할 다음 위치여야 함) .layer(trace_layer()); // 특정 라우트 또는 전체 라우터에 레이어 적용 let app = Router::new() .route("/public", get(public_handler)) .route("/protected", get(protected_handler)) .route_layer(middleware::from_fn(|req, next| async { // 특정 라우트에만 인증 적용 // 참고: 사용자 정의 인증 레이어는 Tower 레이어로 작동합니다. // 특정 라우트에 Axum 미들웨어 함수가 필요한 경우 `middleware::from_fn`을 사용합니다. // 이 예제에서는 명시적 AuthLayer를 직접 사용합니다. // 이것이 특정 라우트 세트에 Tower 레이어를 적용하는 방법입니다. // 더 관용적인 Axum 인증 방법은 사용자 정의 추출기 또는 중첩된 라우터에 레이어 적용을 포함할 수 있습니다. let auth_service = AuthService { inner: next }; // 이 예제를 위해 AuthService를 수동으로 생성 auth_service.oneshot(req).await })) .layer(TraceLayer::new()) // 포괄적인 추적을 위해 tower-http의 추적 레이어 사용 // .layer(TowerTraceLayer::new_for_http()) // tower-http의 더 강력한 추적 레이어 // .layer(custom_layers) // 사용자 정의 레이어를 여기에 적용 // 레이어는 정의의 역순으로 적용됩니다. // *마지막*으로 추가된 레이어가 *전체* 라우터/서비스를 래핑하여 가장 외부 로직이 됩니다. // 예: .layer(MyOuterLayer).layer(MyInnerLayer) -> 요청은 Outer, Inner, 그리고 핸들러를 통과합니다. .layer(auth_layer()) // 사용자 정의 인증 레이어 .layer(custom_layers) // 사용자 정의 로깅 및 추적을 먼저 적용 (가장 바깥쪽 camada) .with_state(app_state); let listener = TcpListener::bind("127.0.0.1:3000").await.unwrap(); info!("{}에서 수신 중", listener.local_addr().unwrap()); axum::serve(listener, app).await.unwrap(); }
이 예제를 실행하려면 Cargo.toml
에 다음 종속성이 필요합니다.
[dependencies] axum = { version = "0.7", features = ["macros"] } tokio = { version = "1.36", features = ["full"] } tower = { version = "0.4", features = ["full"] } tower-http = { version = "0.5", features = ["full"] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] } futures = "0.3" uuid = { version = "1.7", features = ["v4", "fast-rng"] } base64 = "0.21" lazy_static = "1.4" axum-extra = { version = "0.9", features = ["extract-intervals"] } # Extension용
main
함수에서:
- 로그 출력을 위해
tracing_subscriber
를 초기화합니다. ServiceBuilder::new().layer(log_layer()).layer(trace_layer())
는 사용자 정의 로깅 및 추적 서비스에서 결합된 레이어를 생성합니다.ServiceBuilder
체인의 위에서 아래로 레이어가 적용되므로log_layer
가trace_layer
외부가 됩니다.app.layer(auth_layer())
는 사용자 정의 인증 레이어를 전체 라우터에 적용합니다. 이는 모든 라우트가 인증을 거친다는 것을 의미합니다. 선택적 인증을 원하면 중첩된Router::with_no_routes()
인스턴스에 레이어를 적용할 수 있습니다.protected_handler
는 요청 확장 기능에서RequestId
를 추출하는 방법을 보여줍니다.- 서버는
127.0.0.1:3000
에서 수신 대기합니다.
테스트 방법:
- 공개 엔드포인트:
curl http://127.0.0.1:3000/public
(공개 HTML 반환해야 함). - 보호된 엔드포인트 (인증 없음):
curl http://127.0.0.1:3000/protected
(401 Unauthorized "Invalid credentials" 반환해야 함). - 보호된 엔드포인트 (인증됨):
curl -H "Authorization: Basic YWRtaW46cGFzc3dvcmQxMjM=" http://127.0.0.1:3000/protected
("admin"의 base64 인코딩된 YWRtaW46cGFzc3dvcmQxMjM=
로 보호된 HTML 반환해야 함).
콘솔에 요청 ID, 메서드, 경로, 상태 및 지연 시간을 보여주는 자세한 로그가 표시되어 사용자 정의 레이어의 효과를 입증합니다.
애플리케이션 시나리오
- 로깅: 모든 API 요청은 감사, 디버깅 및 모니터링을 위해 기록되어야 합니다.
- 인증/권한 부여: 사용자 역할 또는 권한에 따라 특정 엔드포인트 또는 API의 전체 세그먼트를 보호합니다.
- 추적: 분산 추적을 위해 요청 ID를 전파하여 마이크로서비스 간의 요청에 대한 엔드투엔드 가시성을 확보합니다.
- 속도 제한: 클라이언트가 특정 시간 내에 보낼 수 있는 요청 수를 제한하여 남용을 방지합니다.
- 요청/응답 변환: 헤더 수정, 본문 압축 또는 요청/응답에 일반 데이터 삽입.
- 지표: 모니터링 대시보드를 위해 요청 수, 오류율 및 지연 시간과 같은 지표를 수집하고 노출합니다.
- CORS: 교차 출처 리소스 공유 헤더 처리.
결론
강력한 Tower 에코시스템을 기반으로 구축된 Axum의 레이어 시스템은 Rust 웹 애플리케이션에서 사용자 정의 미들웨어를 구현하는 매우 유연하고 관용적인 방법을 제공합니다. Layer
및 Service
트레잇을 이해함으로써 개발자는 로깅, 인증 및 추적과 같은 교차 관심사를 모듈화하여 더 깨끗하고 더 유지 관리 가능하며 더 강력한 코드베이스를 구축할 수 있습니다. 이 접근 방식은 서비스의 관측 가능성과 보안을 향상시킬 뿐만 아니라 확장 가능하고 만족스러운 웹 애플리케이션을 구축하는 데 중요한 재사용성과 관심사 분리를 촉진합니다. Axum 레이어를 활용하면 자신 있게 매우 구성 가능하고 프로덕션 준비된 Rust 웹 서비스를 구축할 수 있습니다.