Rust Tower 미들웨어 실전: 타임아웃, 재시도, 레이트 리미트
Tower 입문에서 Service/Layer/ServiceBuilder를 둘러봤다면, 이번에는 Tower가 기본으로 제공하는 미들웨어를 실무 시나리오에 맞춰 끼워보겠습니다. 외부 API를 호출하는 서비스나 가용성 보장이 필요한 게이트웨이를 만든다고 가정하면 거의 다 이 안에서 해결됩니다.
이 글에서 다루는 미들웨어는 모두 tower 크레이트의 기본 기능에 들어 있어서, Cargo.toml에 다음만 추가하면 시작할 수 있습니다.
[dependencies]
tower = { version = "0.5", features = ["full"] }
tokio = { version = "1", features = ["full"] }
타임아웃: 응답 시간 제한
가장 자주 쓰이는 미들웨어가 타임아웃입니다. “이 호출은 N초 안에 끝나야 한다”는 약속을 강제하죠.
use tower::{ServiceBuilder, ServiceExt, BoxError, service_fn};
use std::time::Duration;
#[tokio::main]
async fn main() -> Result<(), BoxError> {
let service = ServiceBuilder::new()
.timeout(Duration::from_millis(50))
.service_fn(|_req: String| async move {
tokio::time::sleep(Duration::from_secs(10)).await;
Ok::<_, BoxError>("never reached".to_string())
});
match service.oneshot("hi".into()).await {
Ok(_) => println!("성공"),
Err(e) => println!("타임아웃: {e}"),
}
Ok(())
}
내부 서비스가 50ms 안에 응답하지 못하면 타임아웃 에러로 빠집니다. 외부 의존성이 무한정 매달리는 사고를 막아주는 가장 단순한 안전 장치인데요. 보통 다른 모든 미들웨어를 감싸는 가장 바깥쪽에 둡니다.
이 미들웨어는 서비스를 통과하는 모든 요청에 같은 제한을 거는 방식입니다. 서비스 전체가 아니라 특정 비동기 작업 하나에만 시간 제한을 걸고 싶다면 tokio::time::timeout 함수가 더 알맞습니다.
동시 요청 제한: concurrency_limit
특정 자원(데이터베이스 커넥션 풀, 외부 API 쿼터 등)이 감당할 수 있는 동시 요청 수가 정해져 있을 때 사용합니다.
let service = ServiceBuilder::new()
.concurrency_limit(10)
.service_fn(|req: u64| async move {
tokio::time::sleep(Duration::from_millis(100)).await;
Ok::<_, BoxError>(req * 2)
});
이 서비스는 동시에 최대 10개의 요청만 처리합니다. 11번째 요청이 들어오면 앞선 요청이 끝날 때까지 poll_ready가 Pending을 돌려주죠. 호출자 입장에서는 자연스럽게 백프레셔로 느껴집니다.
rate_limit이 시간당 처리량을 제한한다면, concurrency_limit은 동시 진행 중인 요청 수를 제한한다는 차이가 있습니다. 둘은 자주 함께 쓰입니다.
레이트 리미트: rate_limit
처리량 자체에 상한을 걸고 싶을 때 사용합니다. “초당 5개” 같은 정책을 깔끔하게 표현할 수 있죠.
let service = ServiceBuilder::new()
.rate_limit(5, Duration::from_secs(1))
.service_fn(|req: String| async move {
Ok::<_, BoxError>(format!("ok: {req}"))
});
내부 서비스가 아무리 빠르더라도 초당 5개를 넘기지 않습니다. 외부 API의 쿼터를 지켜야 할 때, 또는 다운스트림 의존성을 보호하고 싶을 때 자주 등장합니다.
다만 이 미들웨어는 분산 환경에서는 한계가 있습니다. 인스턴스마다 카운터가 따로라서 노드가 5대면 실효 한도는 25/sec가 되거든요. 분산 레이트 리밋이 필요하면 보통 Redis 같은 외부 저장소를 쓰는 다른 도구를 끼웁니다.
재시도: 일시적 실패 자동 복구
네트워크 호출은 일시적으로 실패할 수 있습니다. 한두 번 다시 시도하면 성공할 가능성이 높을 때 RetryLayer를 씁니다. 다만 사용 방법이 다른 미들웨어보다 살짝 복잡한데, 재시도 정책을 직접 정의해야 하기 때문입니다.
use tower::retry::{Policy, RetryLayer};
use tower::{ServiceBuilder, ServiceExt, BoxError, service_fn};
use std::future;
#[derive(Clone)]
struct Attempts(usize);
impl<Req: Clone, Res, E> Policy<Req, Res, E> for Attempts {
type Future = future::Ready<()>;
fn retry(&mut self, _req: &mut Req, result: &mut Result<Res, E>)
-> Option<Self::Future>
{
match result {
Ok(_) => None,
Err(_) if self.0 > 0 => {
self.0 -= 1;
Some(future::ready(()))
}
Err(_) => None,
}
}
fn clone_request(&mut self, req: &Req) -> Option<Req> {
Some(req.clone())
}
}
Policy는 두 가지를 결정합니다. retry는 “이 결과를 보고 재시도할지” 판단하고, 재시도한다면 다음 시도까지 기다릴 future를 돌려줍니다. clone_request는 “재시도용 요청 사본을 만들 수 있는지” 답합니다. 멱등성이 없는 요청(예: POST)이라면 None을 돌려줘서 재시도 자체를 막을 수도 있죠.
이렇게 정의한 정책을 레이어로 감싸 사용합니다.
let service = ServiceBuilder::new()
.layer(RetryLayer::new(Attempts(3)))
.service_fn(|req: String| async move {
// 실제로는 외부 호출
Err::<String, BoxError>("일시 장애".into())
});
실무에서는 보통 지연을 점진적으로 늘리는 지수 백오프 정책을 쓰는데, tokio::time::sleep을 future에 넣으면 됩니다. 일정 시간 안에 일정 횟수만 시도하도록 제한하는 정책도 자주 보이고요.
버퍼: 단일 소유 서비스를 공유하기
Service는 &mut self로 호출하기 때문에, 그 자체로는 여러 작업이 동시에 호출하기 어렵습니다. buffer 미들웨어는 백그라운드에 워커 태스크를 띄워두고, 호출자들은 채널을 통해 요청을 보내는 구조로 이 문제를 풀어줍니다.
let service = ServiceBuilder::new()
.buffer(100)
.service_fn(|req: String| async move {
Ok::<_, BoxError>(format!("ok: {req}"))
});
// service.clone()해서 여러 태스크에 나눠줘도 안전
buffer(100)은 워커 앞에 100개짜리 큐를 둔다는 뜻입니다. 큐가 가득 차면 호출자는 백프레셔를 받고요. 데이터베이스 커넥션처럼 단일 소유 자원을 여러 핸들러에서 공유해야 할 때 자주 등장합니다.
자주 쓰는 조합
이 미들웨어들을 조합한 실무 빈출 패턴 두 가지를 짚어보면, 우선 외부 API 클라이언트는 보통 다음 같은 모양입니다.
let api_client = ServiceBuilder::new()
.timeout(Duration::from_secs(10))
.layer(RetryLayer::new(my_policy))
.rate_limit(50, Duration::from_secs(1))
.concurrency_limit(20)
.service(http_client);
바깥부터 안쪽으로: 전체 호출은 10초 안에 끝나야 하고(타임아웃), 실패 시 재시도하고, 초당 50회/동시 20회 제한 안에서 호출을 분산시킵니다.
게이트웨이의 인바운드 라우터는 살짝 다른데요.
let gateway = ServiceBuilder::new()
.timeout(Duration::from_secs(30))
.concurrency_limit(1000)
.buffer(2000)
.service(router);
총 1000개를 동시 처리하되 추가로 2000개까지 큐에 받아두고, 30초 안에 응답을 만들지 못하면 타임아웃으로 끊습니다. 폭주 트래픽을 흡수하면서도 무제한 누적은 막는 패턴이죠.
프레임워크에 연결하기
여기서 만든 미들웨어 조합은 Tower 위에 빌드된 프레임워크라면 코드 수정 없이 그대로 씁니다. Axum에서는 Router::layer에 ServiceBuilder를 넘기면 됩니다.
use axum::{routing::get, Router};
use tower::ServiceBuilder;
use std::time::Duration;
let app = Router::new()
.route("/", get(handler))
.layer(
ServiceBuilder::new()
.timeout(Duration::from_secs(10))
.concurrency_limit(100)
);
Tonic도 같은 방식입니다. HTTP REST와 gRPC 서버가 동일한 타임아웃과 동시성 제한 코드를 공유할 수 있는 이유가 바로 이것입니다.
use tonic::transport::Server;
use tower::ServiceBuilder;
use std::time::Duration;
let layer = ServiceBuilder::new()
.timeout(Duration::from_secs(30))
.concurrency_limit(50)
.into_inner();
Server::builder()
.layer(layer)
.add_service(my_service)
.serve(addr)
.await?;
HTTP 특화 미들웨어는 tower-http 크레이트에 별도로 있습니다. 요청 트레이싱, gzip 압축, CORS 같은 것들인데, Tower 본체와 분리되어 있지만 같은 Service 트레이트 위에서 동작하기 때문에 Axum, Hyper 어디서든 그대로 끼울 수 있습니다.
use tower_http::{
compression::CompressionLayer,
cors::CorsLayer,
trace::TraceLayer,
};
let app = Router::new()
.route("/", get(handler))
.layer(
ServiceBuilder::new()
.layer(TraceLayer::new_for_http())
.layer(CompressionLayer::new())
.layer(CorsLayer::permissive())
);
Axum으로 실제 REST API 서버를 만드는 예제는 Axum으로 REST API 서버 만들기에 라우팅, 추출자, 상태 관리와 함께 정리되어 있습니다.
마치며
Tower 미들웨어는 빌딩 블록이 단순해서 한 번 익혀두면 조합으로 다양한 정책을 표현할 수 있습니다. 타임아웃은 “외부 의존성에 매달리지 않기”, concurrency_limit은 “자원 보호”, rate_limit은 “처리량 상한”, retry는 “일시 장애 흡수”, buffer는 “단일 자원 공유”라는 단일 의도만 기억해두면 됩니다.
자기만의 미들웨어를 만들고 싶으시다면 Tower Layer 직접 만들기에서 이어서 보시면 좋습니다.
더 자세한 내용은 Tower 공식 문서의 미들웨어 가이드를 참고하세요.
This work is licensed under
CC BY 4.0