Axum으로 Rust REST API 만들기: Tower 서비스 스택, Extractor, 상태·에러 처리
이 글의 핵심
Axum 핸들러가 알아보기 힘든 컴파일 에러를 낼 때는 대개 Extractor의 순서나 타입 규칙이 원인입니다. Tower Service 추상화가 요청을 처리하는 방식, 나중에 등록한 Layer가 바깥에 붙는 적용 순서, anyhow·thiserror 연동을 이해하고, 블로킹 호출이나 타임아웃·바디 크기 제한 누락 같은 운영상의 실수를 피하는 방법까지 다룹니다.
이 글의 핵심
Axum은 Rust에서 HTTP 서비스를 만들 때 많이 쓰는 프레임워크로, Tokio 팀이 관리합니다. Tokio 비동기 런타임, Tower의 Service·Layer 추상화, Hyper HTTP 구현 위에 올라가 있으며, 함수 인자의 타입으로 요청을 파싱하는 Extractor 모델이 특징입니다. 이 글은 앞부분에서 Tower Service와 poll_ready, Extractor 타입 규칙, Layer 적용 순서, State와 FromRef, 운영 한도와 우아한 종료 같은 내부·운영 관점을 짚고, 뒤에서 라우팅부터 에러 처리, WebSocket, REST API 구조까지 코드로 정리합니다. Rust 비동기·Tokio를 먼저 알고 있으면 이해가 빠릅니다.
예제는 Axum 0.8 기준입니다. 0.7에서 0.8로 오면서 경로 파라미터 문법이 /:id에서 /{id}로 바뀌었고, 0.8에서 옛 문법을 쓰면 라우터 생성 시점에 패닉이 납니다. 인터넷의 예제를 가져올 때 가장 먼저 확인할 차이입니다.
Axum이란 무엇인가
스택 구성
Axum은 모든 것을 직접 구현하지 않습니다. 요청은 Hyper를 통해 들어오고, 비동기 실행은 Tokio가 담당하며, 타임아웃·압축·트레이싱 같은 공통 관심사는 Tower의 Service와 Layer로 조합합니다. 그래서 Axum을 배운다는 것은 Tower 패턴과 async Rust를 함께 익히는 것에 가깝고, 그 지식은 Hyper 클라이언트나 tonic(gRPC) 같은 같은 생태계의 다른 도구에도 그대로 쓰입니다.
라우터는 merge와 nest로 모듈 단위로 나눌 수 있고, 경로 파라미터·쿼리·바디는 Extractor 타입으로 선언하므로 핸들러 서명만 보고도 무엇을 받는지 알 수 있습니다.
언제 Axum을 고를까
마이크로서비스 API, BFF(Backend for Frontend), 내부 관리 도구, WebSocket이 섞인 실시간 엔드포인트처럼 HTTP·JSON·스트리밍 중심 서비스에 잘 맞습니다. 서버 템플릿 렌더링이 중심인 전통적인 웹사이트도 askama 같은 템플릿 크레이트와 함께 만들 수 있지만, 폼 처리나 세션 같은 기능을 하나씩 조립해야 하므로 그런 기능이 기본 제공되는 풀스택 프레임워크보다 손이 더 갑니다.
Axum·Tower 심화: 서비스 스택과 내부 동작
Tower Service 아키텍처
Tower의 핵심은 요청 하나를 받아 응답 하나를 비동기로 돌려주는 동작을 Service 트레이트로 표준화한 것입니다. 트레이트에는 두 메서드가 있습니다. call은 실제 요청을 처리하고 응답 Future를 돌려주며, Axum의 라우팅, Extractor 평가, 핸들러 실행이 모두 이 경로 안에서 일어납니다. poll_ready는 서비스가 새 요청을 받을 준비가 되었는지를 알리는 메서드로, 동시 처리 한도(ConcurrencyLimitLayer)나 연결 풀처럼 용량이 정해진 서비스가 여기서 백프레셔를 구현합니다.
Axum의 Router는 최종적으로 HTTP Request를 받아 Response를 반환하는 하나의 Service이고, Router::layer로 미들웨어를 붙이는 것은 그 서비스를 한 겹 감싼 새 Service를 만드는 일입니다. Axum은 요청이 들어올 때마다 라우트의 서비스를 복제한 뒤 poll_ready와 call을 차례로 호출합니다. 그래서 ConcurrencyLimitLayer를 붙이면 한도를 넘는 요청은 거절되지 않고 허가가 날 때까지 기다립니다. 과부하 시 바로 503으로 돌려보내고 싶다면 LoadShedLayer를 바깥에 함께 두어야 합니다. 운영 중 동시성 한도나 큐 적체를 볼 때 이 동작을 알고 있어야 대기 시간이 어디서 생기는지 설명할 수 있습니다.
Extractor의 타입 규칙
Axum의 핸들러는 임의의 async fn이 아니라 Handler 트레이트를 만족하는 함수여야 하고, 그 조건은 인자 타입들이 Extractor 트레이트를 구현하는 것입니다. Extractor에는 두 종류가 있습니다. 헤더, 경로, 쿼리, State처럼 바디를 건드리지 않는 것은 FromRequestParts를 구현하고, Json<T>, String, Bytes처럼 바디를 읽는 것은 FromRequest를 구현합니다. 바디는 스트림이라 한 번만 읽을 수 있으므로, FromRequest Extractor는 한 핸들러에 하나만 둘 수 있고 반드시 마지막 인자여야 합니다. Json을 Path보다 앞에 두면 컴파일되지 않습니다.
인자는 앞에서부터 차례로 추출되고, 하나라도 실패하면 그 Extractor가 정의한 거절(rejection) 응답이 바로 반환되어 핸들러는 실행되지 않습니다. 예를 들어 Path<u64> 파싱에 실패하면 400, JSON 형식이 틀리면 400이나 422, Content-Type이 application/json이 아니면 415가 나갑니다. 이 덕분에 프로토콜·파싱 오류와 비즈니스 오류를 자연스럽게 나눠 다룰 수 있습니다. 거절 응답의 형식을 바꾸고 싶다면 WithRejection이나 커스텀 Extractor로 감쌉니다.
존재하지 않는 경로는 404, 경로는 맞지만 메서드가 다르면 405가 나갑니다. nest로 하위 라우터를 붙일 때 접두 경로와 하위 경로의 슬래시가 겹치거나 빠지면 의도와 다른 매칭이 나오기 쉬우므로, 경로 문자열은 한 곳에서만 정의하는 편이 안전합니다.
미들웨어 Layer 패턴
Layer는 서비스를 받아 그 서비스를 감싼 새 서비스를 돌려주는 팩토리입니다. router.layer(a).layer(b)라고 쓰면 요청은 b → a → 라우터 순서로 통과하고, 응답은 그 반대 순서로 나옵니다. 나중에 등록한 레이어가 더 바깥이라는 점이 직관과 반대라 실수가 잦습니다. 위에서 아래로 읽는 순서대로 쌓고 싶다면 tower::ServiceBuilder로 묶어 한 번에 layer에 넘기면 되고, 이때는 먼저 적은 레이어가 바깥입니다.
일반적으로 요청 ID와 트레이싱처럼 모든 요청을 관측해야 하는 레이어를 가장 바깥에 두고, 인증과 레이트 리밋을 그 안쪽에 둡니다. 그래야 인증에 실패한 요청도 로그와 트레이스에 남습니다. 특정 라우트에만 레이어를 적용하려면 Router::route_layer를 쓰는데, 이 레이어는 경로가 매칭된 요청에만 적용되므로 인증 레이어를 붙여도 없는 경로는 401이 아니라 404를 받습니다.
간단한 미들웨어는 axum::middleware::from_fn으로 async 함수 하나로 만들 수 있습니다. 요청을 받아 필요한 처리를 하고 next.run(req).await로 다음 단계에 넘기며, 응답 후처리는 그 결과를 받은 뒤에 합니다.
상태 관리: Clone과 FromRef
Router::with_state에 넘긴 상태 S는 핸들러가 State<S>를 추출할 때마다 Clone으로 복제됩니다. 그래서 S 자체는 가볍게 유지하고, DB 풀, HTTP 클라이언트, 큰 설정처럼 무거운 것은 Arc로 감싸 복제가 포인터 복사가 되게 하는 것이 표준 패턴입니다.
하나의 AppState에서 일부만 꺼내 쓰고 싶다면 FromRef를 씁니다. 하위 타입에 FromRef<AppState>를 구현하면(또는 macros 기능의 #[derive(FromRef)]로 필드마다 자동 구현하면) 핸들러에서 State<SubState>로 받을 수 있습니다. 한 핸들러에서 서로 다른 하위 상태를 여러 개 추출하는 것도 가능합니다. 모듈이 필요한 부분만 받게 하면 의존 범위가 좁아지고 테스트에서 가짜 구현을 끼우기도 쉬워집니다.
merge나 nest로 합치는 라우터들은 같은 상태 타입을 써야 합니다. 상태 타입이 Router<S>의 타입 매개변수로 남아 있다가 with_state를 호출하는 순간 채워지기 때문입니다. 하위 라우터를 만드는 함수가 Router<AppState>를 반환하게 하고, 최상위에서 한 번만 with_state를 호출하는 구조가 가장 단순합니다.
전역 설정을 OnceLock이나 LazyLock(Rust 1.80+) 전역 변수로 두는 방법도 있지만, main에서 만들어 with_state로 넘기면 테스트마다 다른 상태로 라우터를 만들 수 있어 더 다루기 쉽습니다. Axum의 상태는 전역 싱글톤이 아니라 요청 처리 파이프라인에 주입되는 복제 가능한 핸들이라는 점이 DI 컨테이너 중심 프레임워크와 다릅니다.
운영 설정
실서비스에서는 프레임워크 설정, 리버스 프록시, 런타임 한도를 함께 봐야 합니다.
우아한 종료부터 보겠습니다. axum::serve(...).with_graceful_shutdown(signal)에 넘긴 Future가 완료되면 서버는 새 연결 수락을 멈추고 진행 중인 요청이 끝나기를 기다립니다. 이 대기에는 상한이 없으므로, 오래 걸리는 요청이나 열린 WebSocket이 있으면 종료가 끝나지 않을 수 있습니다. Kubernetes는 terminationGracePeriodSeconds(기본 30초)가 지나면 SIGKILL을 보내므로, 요청 타임아웃을 그보다 짧게 두거나 종료 신호 후 일정 시간이 지나면 강제로 끝내는 로직을 함께 둡니다.
바디 크기는 Axum이 기본으로 2MB까지만 받도록 제한합니다(DefaultBodyLimit). 파일 업로드처럼 큰 바디가 필요한 라우트만 DefaultBodyLimit::max로 늘리고, 리버스 프록시의 한도(Nginx client_max_body_size 등)와 값을 맞춥니다. 프록시 한도가 더 작으면 애플리케이션까지 오지도 못하고 413이 나갑니다.
타임아웃은 tower_http::timeout::TimeoutLayer를 쓰면 시간 초과 시 408 응답을 돌려줍니다. tower::timeout::TimeoutLayer는 응답 대신 에러를 내므로, Axum 라우터에 바로 붙이면 에러 타입이 맞지 않아 컴파일되지 않고 HandleErrorLayer로 에러를 응답으로 바꿔 줘야 합니다. 이름이 같아 자주 헷갈리는 부분입니다. 서버 타임아웃은 DB나 외부 API 호출의 타임아웃보다 약간 길게 잡아야, 하위 호출이 실패했을 때 적절한 에러 응답을 만들 시간이 남습니다.
관측은 tower_http::trace::TraceLayer와 tracing 구조화 로그, 필요하면 OpenTelemetry 연동으로 구성하고, 요청 ID는 SetRequestIdLayer와 PropagateRequestIdLayer로 붙여 응답 헤더와 로그에서 같은 요청을 추적할 수 있게 합니다.
아래는 바디 한도와 SIGTERM·Ctrl+C 기반 우아한 종료를 함께 보여 주는 최소 예입니다.
use axum::{extract::DefaultBodyLimit, routing::get, Router};
use tokio::signal;
async fn shutdown_signal() {
let ctrl_c = async {
signal::ctrl_c().await.expect("Ctrl+C 핸들러 설치 실패");
};
#[cfg(unix)]
let terminate = async {
signal::unix::signal(signal::unix::SignalKind::terminate())
.expect("SIGTERM 핸들러 설치 실패")
.recv()
.await;
};
#[cfg(not(unix))]
let terminate = std::future::pending::<()>();
tokio::select! {
_ = ctrl_c => {},
_ = terminate => {},
}
}
#[tokio::main]
async fn main() {
let app = Router::new()
.route("/health", get(|| async { "ok" }))
.layer(DefaultBodyLimit::max(1024 * 1024)); // 1MiB
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
axum::serve(listener, app)
.with_graceful_shutdown(shutdown_signal())
.await
.unwrap();
}
컨테이너 안에서 실행할 때는 127.0.0.1이 아니라 0.0.0.0에 바인딩해야 외부에서 접근할 수 있습니다. 동시성 한도나 타임아웃 값은 트레이드오프가 있는 설정입니다. 한도를 너무 낮추면 정상 트래픽까지 대기하거나 거절되고, 타임아웃만 늘리면 장애 시 자원이 오래 묶입니다. 부하 테스트로 값을 정하는 것이 안전합니다.
프로젝트 생성과 의존성
cargo new axum-demo
cd axum-demo
REST, CORS, 로그, WebSocket을 함께 쓰는 최소 구성은 다음과 같습니다. 정확한 버전은 Cargo.lock으로 고정하고 cargo audit으로 취약점을 주기적으로 확인합니다.
[package]
name = "axum-demo"
version = "0.1.0"
edition = "2021"
[dependencies]
axum = { version = "0.8", features = ["macros", "ws"] }
tokio = { version = "1", features = ["full"] }
tower = "0.5"
tower-http = { version = "0.6", features = ["cors", "trace", "timeout", "request-id"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
uuid = { version = "1", features = ["v4", "serde"] }
macros 기능은 #[debug_handler]와 #[derive(FromRef)] 같은 매크로를 켜고, ws는 WebSocket 지원을 켭니다. Axum 0.8은 tower-http 0.6과 짝을 이루므로, 두 크레이트의 버전이 어긋나면 레이어 타입이 맞지 않는 컴파일 에러가 납니다.
최소 서버와 라우팅
use axum::{routing::get, Router};
#[tokio::main]
async fn main() {
let app = Router::new().route("/", get(|| async { "Hello, Axum" }));
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
axum::serve(listener, app).await.unwrap();
}
get에 넘긴 클로저는 Future를 반환하는 핸들러입니다. 반환 타입이 IntoResponse를 구현하면 되며, 문자열은 200 OK와 text/plain 응답으로 변환됩니다.
같은 경로에 여러 메서드를 붙일 때는 메서드 라우터를 체이닝합니다.
use axum::{routing::get, Router};
async fn list_users() -> &'static str {
"user list"
}
async fn create_user() -> &'static str {
"user created"
}
fn routes() -> Router {
Router::new().route("/users", get(list_users).post(create_user))
}
핸들러와 Extractor
Path와 Json
use axum::{
extract::Path,
routing::{get, post},
Json, Router,
};
use serde::{Deserialize, Serialize};
#[derive(Deserialize)]
struct CreateUser {
name: String,
}
#[derive(Serialize)]
struct User {
id: u64,
name: String,
}
async fn get_user(Path(user_id): Path<u64>) -> Json<User> {
Json(User { id: user_id, name: "demo".into() })
}
async fn create_user(Json(payload): Json<CreateUser>) -> Json<User> {
Json(User { id: 1, name: payload.name })
}
fn user_routes() -> Router {
Router::new()
.route("/users/{id}", get(get_user))
.route("/users", post(create_user))
}
Path<u64>는 경로 세그먼트를 u64로 파싱하고, 실패하면 400을 반환합니다. 파라미터가 여러 개면 Path<(u64, String)>처럼 튜플로 받거나 Deserialize 구조체로 받습니다. Json<T>는 Content-Type: application/json 확인과 역직렬화를 함께 하며, 둘 중 하나가 실패하면 클라이언트 오류로 처리됩니다.
여러 Extractor를 함께 쓸 때는 Path, Query, HeaderMap, State처럼 바디를 읽지 않는 것들을 앞에 두고, Json 같은 바디 Extractor는 하나만 마지막에 둡니다.
async fn update_user(
State(state): State<AppState>,
Path(id): Path<u64>,
headers: HeaderMap,
Json(payload): Json<UpdateUser>, // 바디 Extractor는 마지막
) -> StatusCode {
// ...
StatusCode::NO_CONTENT
}
규칙을 어기면 “the trait bound … is not satisfied”처럼 원인이 잘 드러나지 않는 에러가 나옵니다. 이때 핸들러에 #[axum::debug_handler]를 붙이면 어떤 인자가 문제인지 구체적으로 알려 줍니다.
미들웨어
tower-http 레이어
CORS, 요청 로깅, 타임아웃, 압축 같은 일반적인 미들웨어는 대부분 tower-http에 있습니다.
use std::time::Duration;
use axum::{http::{HeaderValue, Method}, routing::get, Router};
use tower_http::{cors::CorsLayer, timeout::TimeoutLayer, trace::TraceLayer};
fn app() -> Router {
Router::new()
.route("/health", get(|| async { "ok" }))
.layer(TimeoutLayer::new(Duration::from_secs(10)))
.layer(
CorsLayer::new()
.allow_origin("https://app.example.com".parse::<HeaderValue>().unwrap())
.allow_methods([Method::GET, Method::POST]),
)
.layer(TraceLayer::new_for_http()) // 마지막에 붙였으므로 가장 바깥
}
개발 중에는 allow_origin(Any)가 편하지만, 프로덕션에서는 허용할 오리진을 명시해야 합니다. 쿠키 인증을 쓰려면 allow_credentials(true)가 필요한데, 이 경우 브라우저 규칙상 오리진에 와일드카드를 쓸 수 없고 tower-http도 그런 조합을 허용하지 않습니다.
커스텀 미들웨어
from_fn으로 async 함수를 레이어로 만들 수 있습니다. 아래는 요청 ID를 요청 헤더에 넣고 응답 헤더에도 돌려주는 예입니다.
use axum::{extract::Request, http::HeaderValue, middleware::{self, Next}, response::Response};
async fn request_id(mut req: Request, next: Next) -> Response {
let id = HeaderValue::from_str(&uuid::Uuid::new_v4().to_string()).unwrap();
req.headers_mut().insert("x-request-id", id.clone());
let mut res = next.run(req).await;
res.headers_mut().insert("x-request-id", id);
res
}
// Router::new().route(...).layer(middleware::from_fn(request_id))
실제 서비스라면 같은 일을 하는 SetRequestIdLayer·PropagateRequestIdLayer를 쓰는 편이 낫고, 직접 작성한 미들웨어는 인증처럼 서비스 고유의 로직에 씁니다. 미들웨어에서 상태가 필요하면 middleware::from_fn_with_state를 씁니다.
상태 관리
use std::sync::Arc;
use axum::{extract::State, routing::get, Router};
#[derive(Clone)]
struct AppState {
db: sqlx::PgPool, // 내부적으로 Arc를 쓰므로 그대로 둠
config: Arc<Config>, // 큰 설정은 Arc로 공유
}
async fn handler(State(state): State<AppState>) -> String {
format!("env = {}", state.config.env)
}
fn app(state: AppState) -> Router {
Router::new().route("/", get(handler)).with_state(state)
}
모듈별 라우터는 상태를 채우지 않은 Router<AppState>를 반환하게 하고, 최상위에서 합친 뒤 한 번만 with_state를 호출합니다.
fn users_router() -> Router<AppState> {
Router::new().route("/users", get(list_users))
}
fn app(state: AppState) -> Router {
Router::new()
.nest("/api", users_router())
.with_state(state)
}
에러 처리
Result와 IntoResponse
핸들러가 Result<T, E>를 반환하려면 T와 E 모두 IntoResponse를 구현해야 합니다. 프로젝트 전역 에러 타입을 정의하고 IntoResponse를 구현하는 패턴이 가장 흔합니다.
use axum::{http::StatusCode, response::{IntoResponse, Response}, Json};
use serde_json::json;
pub enum ApiError {
NotFound,
BadRequest(String),
Internal(anyhow::Error),
}
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
let (status, message) = match self {
ApiError::NotFound => (StatusCode::NOT_FOUND, "not found".to_string()),
ApiError::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg),
ApiError::Internal(err) => {
tracing::error!(error = ?err, "internal error"); // 상세는 로그에만
(StatusCode::INTERNAL_SERVER_ERROR, "internal server error".to_string())
}
};
(status, Json(json!({ "error": message }))).into_response()
}
}
impl<E: Into<anyhow::Error>> From<E> for ApiError {
fn from(err: E) -> Self {
ApiError::Internal(err.into())
}
}
pub type ApiResult<T> = Result<T, ApiError>;
From 구현 덕분에 핸들러 안에서 DB 에러 같은 것에 ?를 쓰면 자동으로 500 응답으로 바뀝니다. 도메인 에러는 thiserror로 정의하고 경계에서 ApiError의 구체적인 변형으로 매핑합니다. anyhow::Error의 메시지를 그대로 클라이언트에 보내면 SQL 문이나 내부 경로 같은 정보가 새어 나갈 수 있으므로, 위처럼 500 응답에는 일반화된 메시지만 쓰고 상세는 로그에 남깁니다.
Router::fallback으로 매칭되지 않는 경로의 응답을 JSON 404로 통일하거나, SPA를 서빙할 때 index.html로 넘기도록 지정할 수 있습니다.
WebSocket
WebSocket 연결은 HTTP 업그레이드 요청으로 시작합니다. WebSocketUpgrade Extractor로 업그레이드 요청을 받고, on_upgrade에 연결이 성립한 뒤 실행할 함수를 넘깁니다.
use axum::{
extract::ws::{Message, WebSocket, WebSocketUpgrade},
response::IntoResponse,
routing::get,
Router,
};
async fn ws_handler(ws: WebSocketUpgrade) -> impl IntoResponse {
ws.max_message_size(64 * 1024).on_upgrade(handle_socket)
}
async fn handle_socket(mut socket: WebSocket) {
while let Some(Ok(msg)) = socket.recv().await {
match msg {
Message::Text(t) => {
if socket.send(Message::Text(t)).await.is_err() {
break; // 클라이언트가 끊김
}
}
Message::Close(_) => break,
_ => {}
}
}
}
fn ws_routes() -> Router {
Router::new().route("/ws", get(ws_handler))
}
인증은 업그레이드 전에 처리하는 것이 좋습니다. ws_handler에 쿠키나 헤더 Extractor를 추가해 검증하고, 실패하면 업그레이드하지 않고 401을 돌려줍니다. 브라우저의 WebSocket API는 임의 헤더를 보낼 수 없으므로 쿠키나 쿼리 토큰을 쓰게 되는데, 쿼리 토큰은 접근 로그에 남는다는 점을 감안해야 합니다. 연결이 오래 유지되므로 로드밸런서의 idle timeout보다 짧은 주기로 ping을 보내고, 앞서 말했듯 열린 WebSocket은 우아한 종료를 지연시키므로 종료 신호를 받으면 연결을 닫는 처리가 필요합니다.
REST API 구조
작은 프로젝트에서는 핸들러 안에 로직을 직접 넣어도 되지만, 테스트와 변경이 잦아지면 역할을 나누는 편이 낫습니다. 라우터는 URL·메서드·미들웨어만 정의하고, 핸들러는 Extractor로 입력을 받아 상태 코드와 DTO 변환을 담당하며, 서비스 계층이 비즈니스 로직과 트랜잭션 경계를, 저장소 계층이 DB와 외부 API 접근을 맡는 식입니다.
CRUD 예제
use axum::{
extract::{Path, State},
http::StatusCode,
routing::get,
Json, Router,
};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::{
atomic::{AtomicU64, Ordering},
Arc, RwLock,
};
#[derive(Clone, Default)]
struct AppState {
items: Arc<RwLock<HashMap<u64, Item>>>,
next_id: Arc<AtomicU64>,
}
#[derive(Clone, Serialize)]
struct Item {
id: u64,
name: String,
}
#[derive(Deserialize)]
struct CreateItem {
name: String,
}
async fn list_items(State(state): State<AppState>) -> Json<Vec<Item>> {
let map = state.items.read().unwrap();
Json(map.values().cloned().collect())
}
async fn get_item(
State(state): State<AppState>,
Path(id): Path<u64>,
) -> Result<Json<Item>, StatusCode> {
let map = state.items.read().unwrap();
map.get(&id).cloned().map(Json).ok_or(StatusCode::NOT_FOUND)
}
async fn create_item(
State(state): State<AppState>,
Json(payload): Json<CreateItem>,
) -> (StatusCode, Json<Item>) {
let id = state.next_id.fetch_add(1, Ordering::Relaxed);
let item = Item { id, name: payload.name };
state.items.write().unwrap().insert(id, item.clone());
(StatusCode::CREATED, Json(item))
}
async fn delete_item(State(state): State<AppState>, Path(id): Path<u64>) -> StatusCode {
match state.items.write().unwrap().remove(&id) {
Some(_) => StatusCode::NO_CONTENT,
None => StatusCode::NOT_FOUND,
}
}
fn api_router(state: AppState) -> Router {
Router::new()
.route("/items", get(list_items).post(create_item))
.route("/items/{id}", get(get_item).delete(delete_item))
.with_state(state)
}
ID 발급은 락 없이 AtomicU64::fetch_add로 충분합니다. 여기서 std::sync::RwLock을 쓴 것은 잠금을 잡은 구간에 .await가 없기 때문입니다. 짧은 동기 구간만 보호한다면 표준 라이브러리 락이 더 가볍고, Tokio 문서도 이 경우 표준 락을 권합니다. 잠금을 잡은 채 .await해야 할 때만 tokio::sync::RwLock이나 Mutex가 필요합니다. 표준 락의 가드를 잡은 채 .await하면 핸들러 Future가 Send가 아니게 되어 컴파일 에러가 나므로, 실수는 대부분 컴파일 단계에서 잡힙니다. 실제 서비스라면 이 자리에 sqlx 같은 비동기 DB 클라이언트가 들어갑니다.
입력 검증은 serde의 타입 제약, validator 크레이트, 또는 검증을 수행하는 커스텀 Extractor로 처리합니다. 경계에서 한 번 검증해 검증된 도메인 타입으로 바꿔 두면, 이후 코드는 잘못된 값을 다시 의심하지 않아도 됩니다.
운영에서 주의할 점
Tokio 워커에서 동기 파일 I/O나 긴 CPU 작업을 실행하면 그 워커 스레드에 배정된 다른 요청이 모두 멈춥니다. 비밀번호 해시(argon2, bcrypt)처럼 의도적으로 느린 연산이 대표적인 예입니다. 이런 작업은 tokio::task::spawn_blocking으로 블로킹 전용 스레드 풀에 보내고, CPU 작업이 많다면 rayon 같은 별도 풀을 씁니다.
헬스 체크는 프로세스가 살아 있는지를 보는 /health(liveness)와 트래픽을 받을 준비가 되었는지를 보는 /ready(readiness)를 나누는 것이 좋습니다. readiness에서 DB 연결을 확인하되, liveness에서까지 DB를 확인하면 DB 장애 때 모든 파드가 재시작되는 연쇄 장애가 생길 수 있습니다. 에러 응답에 요청 ID를 포함하면 사용자 문의와 로그를 연결하기 쉬워집니다.
트러블슈팅
| 증상 | 자주 있는 원인 | 점검 |
|---|---|---|
the trait bound ... is not satisfied | 바디 Extractor가 마지막이 아니거나 둘 이상, State<T>의 T가 with_state 타입과 다름 | #[axum::debug_handler]를 붙여 문제 인자 확인 |
| 라우터 생성 시 패닉 | 0.8에서 /:id 같은 0.7 경로 문법 사용 | /{id}로 변경 |
| 404만 반복 | nest 접두 경로와 하위 경로의 슬래시 불일치 | 최종 경로를 출력해 확인 |
| 415 응답 | 클라이언트가 Content-Type: application/json을 보내지 않음 | 요청 헤더 확인 |
| 413 응답 | 기본 바디 한도(2MB) 또는 프록시 한도 초과 | DefaultBodyLimit과 프록시 설정 확인 |
| WebSocket 즉시 끊김 | 프록시가 업그레이드 헤더를 전달하지 않음 | Upgrade·Connection 헤더 전달 설정 확인 |
TimeoutLayer를 붙였더니 컴파일 에러 | tower의 TimeoutLayer는 에러를 반환 | tower_http::timeout::TimeoutLayer를 쓰거나 HandleErrorLayer로 감쌈 |