Axum 및 Tonic의 타워 추상화 계층 언패킹
Olivia Novak
Dev Intern · Leapcell

소개
빠르게 발전하는 네트워크 프로그래밍 환경에서 강력하고 확장 가능하며 유지보수 가능한 서비스를 구축하는 것은 매우 중요합니다. 성능과 안전성에 중점을 둔 Rust는 이러한 시스템 개발에 강력한 경쟁자가 되었습니다. 웹 애플리케이션을 위한 Axum과 gRPC 서비스를 위한 Tonic이라는 두 가지 주요 프레임워크는 Tower라는 강력한 기본 추상화를 활용합니다. Tower는 요청 라우팅, 오류 처리, 미들웨어 통합과 같은 일반적인 문제를 해결하는 네트워크 서비스 구축을 위한 모듈식 및 컴포저블 방식을 제공합니다. 이 글은 Tower의 핵심 구성 요소인 Service, Layer, BoxCloneService를 명확히 하고, 이것이 Axum과 Tonic의 백본을 어떻게 형성하여 우아하고 확장 가능한 서비스 아키텍처를 가능하게 하는지 설명하는 것을 목표로 합니다. 이러한 추상화를 이해하는 것은 단순한 학문적인 연습이 아닙니다. 이 프레임워크의 잠재력을 최대한 발휘하여 개발자가 고도로 맞춤화되고 효율적인 서비스를 만들 수 있도록 합니다.
Tower 코어 이해
Axum과 Tonic이 Tower를 어떻게 사용하는지 자세히 알아보기 전에, 기본 빌딩 블록에 대한 명확한 이해를 확립해 봅시다.
Service 트레잇
Tower의 핵심은 Service 트레잇입니다. 이 트레잇은 요청을 받아 응답 또는 오류로 해결되는 Future를 반환하는 비동기 함수를 나타냅니다. 들어오는 항목을 처리하고 나가는 항목을 생성하는 구성 요소에 대한 일반 인터페이스로 생각하십시오.
pub trait Service<Request>: Sized { type Response; type Error; type Future: Future<Output = Result<Self::Response, Self::Error>>; fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>>; fn call(&mut self, req: Request) -> Self::Future; }
Request: 이 서비스가 수락하는 입력 유형입니다.Response: 이 서비스가 생성하는 성공적인 출력 유형입니다.Error: 이 서비스가 반환할 수 있는 오류 유형입니다.Future:Response또는Error로 최종적으로 완료될 비동기 작업입니다.poll_ready: 이 메서드는 백프레셔에 중요합니다. 서비스를 통해 새로운 요청을 수락할 준비가 되었는지 신호할 수 있습니다.Poll::Pending을 반환하면 호출자는call을 호출하기 전에 기다려야 합니다.call: 서비스가Request를 처리하고 최종Response를 나타내는Future를 반환하는 핵심 로직입니다.
Axum의 맥락에서 Service는 종종 http::Request를 받아 http::Response를 반환하는 HTTP 핸들러를 나타냅니다. Tonic의 경우 gRPC 메서드를 처리하여 들어오는 gRPC 요청을 응답으로 변환합니다.
Layer 트레잇
Service는 단일 작업 단위를 정의하지만, Layer는 서비스를 구성하고 수정하는 메커니즘을 제공합니다. Layer는 본질적으로 서비스에 대한 고차 함수입니다. 즉, 내부 Service를 받아 크로스 커팅 관심사를 추가하거나 동작을 수정하는 새롭고 (아마도 래핑된) Service를 반환합니다.
pub trait Layer<S> { type Service: Service<S::Request, Response = S::Response, Error = S::Error>; fn layer(&self, inner: S) -> Self::Service; }
S: 이 레이어가 래핑할 내부 서비스의 유형입니다.Service: 이 레이어가 생성하는 새롭고 래핑된 서비스의 유형입니다.layer: 이 메서드는inner서비스를 받아 새 서비스를 반환합니다.
Layer는 미들웨어에 기본적입니다. 일반적인 예는 다음과 같습니다.
- 로깅 레이어: 들어오는 요청과 나가는 응답을 로깅합니다.
 - 속도 제한 레이어: 서비스가 처리할 수 있는 요청 수를 제한합니다.
 - 인증 레이어: 요청을 내부 서비스로 전달하기 전에 자격 증명을 확인합니다.
 - 메트릭스 레이어: 요청 지속 시간과 같은 성능 데이터를 수집합니다.
 
간단한 로깅 레이어로 설명해 보겠습니다.
use std::future::Future; use std::pin::Pin; use std::task::{Context, Poll}; use tower::{Service, Layer}; // 시연을 위한 더미 요청 및 응답 #[derive(Debug)] struct MyRequest(String); struct MyResponse(String); type MyError = std::io::Error; // 간단한 오류 유형 // 예제 서비스 struct MyService; impl Service<MyRequest> for MyService { type Response = MyResponse; type Error = MyError; type Future = Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>> + Send>>; fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> { Poll::Ready(Ok(())) } fn call(&mut self, req: MyRequest) -> Self::Future { println!(" (Inner Service) Processing request: {}", req.0); Box::pin(async move { Ok(MyResponse(format!("Response to {}", req.0))) }) } } // 우리의 로깅 미들웨어 유형 struct LogLayer; impl<S> Layer<S> for LogLayer where S: Service<MyRequest, Response = MyResponse, Error = MyError> + Send + 'static, S::Future: Send + 'static, { type Service = LogService<S>; fn layer(&self, inner: S) -> Self::Service { LogService { inner } } } // LogLayer에서 생성된 서비스 #[derive(Clone)] struct LogService<S> { inner: S, } impl<S> Service<MyRequest> for LogService<S> where S: Service<MyRequest, Response = MyResponse, Error = MyError> + 'static, S::Future: Send + 'static, { type Response = MyResponse; type Error = MyError; type Future = Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>> + Send>>; fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> { self.inner.poll_ready(cx) } fn call(&mut self, req: MyRequest) -> Self::Future { println!("(Log Layer) Incoming request: {:?}", req); let fut = self.inner.call(req); Box::pin(async move { let res = fut.await; println!("(Log Layer) Outgoing response: {:?}", res.as_ref().map(|r| r.0.clone())); res }) } } #[tokio::main] async fn main() { let my_service = MyService; let logged_service = LogLayer.layer(my_service); let res1 = logged_service.call(MyRequest("hello".to_string())).await.unwrap(); println!("Main received: {} ", res1.0); // 참고: `my_service`가 `logged_service.call`에 의해 소비되었으므로, `logged_service`는 여기서 다시 호출할 수 없습니다. // 이것이 `BoxCloneService`로 연결되는 부분입니다. }
이 예제는 LogLayer가 MyService를 래핑하여 LogService를 생성하는 방법을 보여주며, 내부 서비스의 실행 전후에 로깅을 추가합니다. LogService의 Clone을 주목하십시오. 이는 Layer::layer가 새 Service 인스턴스를 반환하고 실제 애플리케이션에서 동시 처리를 위해 종종 Clone 가능해야 하기 때문에 중요합니다.
BoxCloneService 유형
Service 트레잇 자체는 종종 객체 안전하지 않습니다. 즉, 유형을 지우기 위해 직접 Box<dyn Service<...>>를 사용할 수 없으므로 다형성과 동적 디스패치가 제한됩니다. 실제 서비스는 동시 처리 또는 다양한 데이터 구조에 저장하기 위해 복제해야 하는 경우가 많습니다. Tower는 이러한 문제를 해결하기 위해 BoxCloneService를 제공합니다.
BoxCloneService는 Send, Sync, Clone인 Service를 래핑하는 Box의 타입 별칭이며, Future도 Send 및 static입니다. 이를 통해 동적 디스패치와 서비스 복제가 가능하며, 이는 라우팅 및 병렬 실행에 필수적입니다.
// 단순화된 표현 pub type BoxCloneService<Request, Response, Error> = Box<dyn Service<Request, Response = Response, Error = Error, Future = Pin<Box<dyn Future<Output = Result<Response, Error>> + Send>>> + Send + Sync + Clone>;
주요 측면:
Box: 힙 할당 및 동적 디스패치를 가능하게 합니다.dyn Service<...> + Send + Sync + Clone: 기본 구체 서비스 유형이 동적으로 디스패치되고, 스레드 간에 안전하게 전송되고, 스레드 간에 공유되고, 복제될 수 있음을 의미합니다.Future = Pin<Box<dyn Future<...> + Send>>:call에서 반환된 Future도 동적으로 디스패치되고Send인지 확인합니다.
BoxCloneService를 언제 사용하겠습니까?
- 라우팅: 특정 기준에 따라 다른 서비스로 요청을 라우팅하려는 경우, 이러한 서비스는 서로 다른 구체 유형을 가질 수 있습니다. 
BoxCloneService를 사용하면 공통 컬렉션에 저장할 수 있습니다. - 미들웨어 체인: 각 레이어가 박싱된 서비스를 반환해야 하는 복잡한 미들웨어 체인을 구축합니다.
 - 프레임워크 내부: Axum과 Tonic은 핸들러 함수 및 gRPC 메서드 구현을 관리하기 위해 
BoxCloneService를 내부적으로 광범위하게 사용하여 API를 더욱 유연하게 만듭니다. 
로깅 예제를 다시 살펴보면, MyService를 레이어링 후 여러 번 호출해야 하는 경우:
use std::future::Future; use std::pin::Pin; use std::task::{Context, Poll}; use tower::{Service, Layer}; use tower::ServiceBuilder; // 더 간단한 레이어링을 위해 // ... (이전과 동일한 MyRequest, MyResponse, MyError, MyService, LogLayer, LogService) ... #[tokio::main] async fn main() { let my_service = MyService; // ServiceBuilder를 사용하여 복제 및 레이어 구성을 더 쉽게 만듭니다. let layered_service = ServiceBuilder::new() .layer(LogLayer) // 로깅 레이어 추가 .service(my_service); // 기본 서비스 // 이제 여러 번 호출할 수 있습니다. ServiceBuilder는 `Clone`을 보장하기 때문입니다. // (필요한 경우 복제 가능한 서비스들을 소비하고 다시 생성하여) let res1 = layered_service.call(MyRequest("hello".to_string())).await.unwrap(); println!("Main received: {} ", res1.0); let res2 = layered_service.call(MyRequest("world".to_string())).await.unwrap(); println!("Main received: {} ", res2.0); // 유형 소거를 위해 박싱하려면 (예: 라우터에서) let boxed_service = ServiceBuilder::new() .boxed_clone() // 서비스를 BoxCloneService로 박싱합니다. .service(MyService); // 기본 서비스 let res3 = boxed_service.call(MyRequest("boxed".to_string())).await.unwrap(); println!("Main received: {} ", res3.0); }
ServiceBuilder::boxed_clone() 메서드가 핵심입니다. 이 메서드는 구체적인 서비스(이전 레이어 이후)를 받아 BoxCloneService로 박싱하여 다형적으로 사용하고 필요한 대로 복제할 수 있도록 합니다. 이는 각 경로가 잠재적으로 다른 내부 서비스 유형으로 요청을 처리할 수 있지만 모두 라우터에 의해 동일하게 취급되어야 하는 Axum의 라우팅에 중요합니다.
Axum과 Tonic이 Tower를 활용하는 방법
Axum: Tower 기반 웹 프레임워크
Axum의 핵심 철학은 Tower 위에서 직접 구축하여 복잡성을 최소화하고 유연성을 극대화하는 것입니다.
- 서비스로서의 핸들러: Axum에서 라우트 핸들러는 본질적으로 
Service입니다.axum::Router::get("/", handler_fn)을 정의할 때handler_fn은Service인스턴스로 변환됩니다. Axum의 추출기(extractor)와 응답기(responder) (Json,Path,Html등)는Service트레잇 또는 관련 유형의 타입에서 작동하거나 해당 타입을 생성하는 로직을 구현합니다. - 레이어로서의 미들웨어: Axum의 미들웨어 함수(
axum::Router::layer,axum::Router::fallback_service)는Layer를 기대합니다. 이를 통해 로깅, 인증, 압축 등을 위해 Tower 호환 미들웨어를 쉽게 연결할 수 있습니다. BoxCloneService를 사용한 라우팅: Axum의Router는 내부적으로 서비스 컬렉션(라우트 핸들러 및 관련 미들웨어)을 관리합니다. 이러한 다양한 서비스를 다형적으로 저장하기 위해BoxCloneService또는 이와 유사한 박싱된 구성을 사용하여 들어오는 요청을 올바른 핸들러와 일치시킨 후 해당 핸들러를call할 수 있습니다.
// Tower 개념을 암묵적으로 보여주는 Axum 예제 use axum::{ routing::{get}, response::IntoResponse, Router, }; use tower_http::trace::TraceLayer; // 일반적인 Tower 레이어 async fn hello_world() -> impl IntoResponse { "Hello, Axum!" } #[tokio::main] async fn main() { // hello_world는 암묵적으로 서비스로 변환됩니다. let app = Router::new() .route("/", get(hello_world)) // TraceLayer는 Tower Layer입니다. .layer(TraceLayer::new_for_http()); let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap(); axum::serve(listener, app).await.unwrap(); }
여기서 TraceLayer는 Layer 역할을 하며 hello_world()에서 생성된 서비스를 래핑하여 요청 추적을 추가합니다. Router 자체는 경로에 따라 올바른 내부 서비스로 요청을 라우팅하는 Service입니다.
Tonic: Rust용 gRPC 프레임워크
Rust용 gRPC 프레임워크인 Tonic도 Tower에 크게 의존합니다.
- 서비스로서의 gRPC 메서드: Tonic 서비스에서 구현한 각 gRPC 메서드는 본질적으로 
Service인스턴스입니다. Tonic은 Rust 함수를 Tower 호환 서비스로 변환하는 매크로 및 코드 생성을 제공하여 gRPC 요청/응답 의미를 처리합니다. - gRPC용 미들웨어: Axum과 마찬가지로 Tonic의 
tonic::transport::Server는 gRPC 서비스에Layer를 적용하는 메서드를 제공합니다. 이는 인증, 인가 또는 gRPC 호출에 대한 사용자 정의 메트릭 수집을 위한 Interceptor를 구현하는 데 매우 유용합니다. - 서비스 스택: Tonic은 gRPC 메서드 구현에서 시작하여 프로토콜 처리(HTTP/2 등)를 위한 레이어를 적용하고 사용자 정의 미들웨어를 적용한 후, HTTP/2 프레임을 소비하는 단일 
Service를 노출하는 Tower 서비스 스택을 구축합니다. 
// Tower 레이어를 보여주는 Tonic 예제 use tonic::{transport::Server, Request, Response, Status}; use hello_world::greeter_server::{Greeter, GreeterServer}; use hello_world::{HelloReply, HelloRequest}; use tower_http::trace::TraceLayer; pub mod hello_world { tonic::include_proto!("helloworld"); } #[derive(Debug, Default)] pub struct MyGreeter; #[tonic::async_trait] impl Greeter for MyGreeter { async fn say_hello( &self, request: Request<HelloRequest>, ) -> Result<Response<HelloReply>, Status> { println!("Got a request from {:?}", request.remote_addr()); let reply = hello_world::HelloReply { message: format!("Hello {}!", request.into_inner().name), }; Ok(Response::new(reply)) } } #[tokio::main] async fn main() -> Result<(), Box<dyn std::error::Error>> { let addr = "[::1]:50051".parse()?; let greeter = MyGreeter::default(); println!("GreeterServer listening on {}", addr); Server::builder() // gRPC 서비스에 TraceLayer 적용 .layer(TraceLayer::new_for_grpc()) .add_service(GreeterServer::new(greeter)) .serve(addr) .await?; Ok(()) }
이 Tonic 예제에서 TraceLayer::new_for_grpc()는 gRPC Service 자체를 래핑하는 Layer이며, Server::builder().layer(...) 구문은 Tower Layer의 적용을 직접 반영합니다.
결론
Tower의 핵심 Service, Layer, BoxCloneService 구성 요소를 포함하는 Tower 추상화 계층은 Rust에서 네트워크 애플리케이션을 구축하기 위한 믿을 수 없을 정도로 강력하고 유연한 기반을 제공합니다. 이러한 개념을 이해함으로써 개발자는 Axum 및 Tonic과 같은 프레임워크를 효과적으로 사용할 수 있을 뿐만 아니라 사용자 정의 미들웨어로 확장하고 다양한 서비스 구성 요소를 원활하게 통합할 수 있습니다. Tower는 Rust 철학인 구성 가능성과 타입 안전성을 구현하여 고성능의 강력하고 유지보수 가능한 네트워크 서비스를 구축할 수 있도록 합니다. 이는 본질적으로 강력한 분산 시스템 구축이라는 복잡한 작업을 단순화합니다.

