Axum으로 REST API 서버 만들기: 라우팅, 추출자, 상태 관리

Axum으로 REST API 서버 만들기: 라우팅, 추출자, 상태 관리

Rust로 HTTP 서버를 짜다 보면 프레임워크 선택에서 한 번씩 막힙니다. actix-web은 성능이 검증됐지만 액터 모델이 낯설고, warp는 함수형 스타일이 깔끔한데 타입 에러가 미로처럼 느껴질 때가 있습니다.

Axum은 Tokio 팀이 만든 웹 프레임워크입니다. “Tower 위의 얇은 라우팅 레이어”를 표방하는데, 이게 생각보다 실용적입니다. 타임아웃이나 인증 미들웨어를 Tower로 짜두면 Axum에 그대로 끼울 수 있고, 핸들러 함수 시그니처에 타입만 선언해두면 Axum이 요청에서 필요한 데이터를 알아서 꺼내줍니다. 이 글에서는 실제 REST API 서버를 만들면서 라우팅, 추출자, 상태 관리, 에러 처리, 미들웨어 연결까지 순서대로 짚겠습니다.

Axum은 어떤 프레임워크일까

본격적으로 코드를 쓰기 전에, Axum이 어디에 위치한 도구인지 짚고 가면 이후 내용이 훨씬 잘 들어옵니다.

Rust의 HTTP 생태계는 여러 층으로 나뉩니다. 가장 아래에는 실제 TCP 연결을 받고 HTTP 메시지를 파싱하는 hyper가 있고, 그 위에 “요청을 받아 응답을 돌려주는 비동기 함수”라는 공통 추상화를 제공하는 Tower가 있습니다. Axum은 이 둘 위에 올라타서, 우리가 신경 쓰고 싶은 부분인 “어떤 경로로 들어온 요청을 어떤 함수가 처리할까”만 다루도록 설계됐습니다. 그래서 스스로를 얇은 레이어라고 부르는 것입니다.

Axum의 가장 큰 특징은 매크로가 거의 없다는 점입니다. actix-web처럼 #[get("/users")] 같은 속성 매크로로 라우팅을 선언하는 대신, Axum은 평범한 함수와 평범한 타입만으로 모든 것을 표현합니다. 그래서 IDE의 자동 완성이나 타입 추론이 잘 동작하고, 에러 메시지도 비교적 추적하기 쉽습니다.

Axum으로 서버를 짤 때 반복해서 만나게 될 개념은 다섯 가지로 정리할 수 있습니다. 이 다섯 가지가 어떻게 맞물리는지만 머릿속에 그려두면, 뒤에 나오는 코드는 대부분 이 개념들의 조합입니다.

  • 라우터(Router): 어떤 HTTP 메서드와 경로로 들어온 요청을 어떤 핸들러로 보낼지 정하는 표입니다. Router::new().route("/users", get(...))처럼 경로와 핸들러를 연결합니다.
  • 핸들러(handler): 요청 하나를 실제로 처리하는 평범한 비동기 함수입니다. 별도의 트레이트를 구현할 필요 없이 async fn이면 됩니다.
  • 추출자(extractor): 요청에서 경로 변수, 쿼리스트링, 헤더, JSON 바디 같은 데이터를 꺼내 핸들러의 인자로 넘겨주는 장치입니다. 핸들러 시그니처에 타입을 선언하는 것만으로 동작합니다.
  • 응답(IntoResponse): 핸들러가 돌려준 값을 HTTP 응답으로 변환하는 규칙입니다. String, Json<T>, StatusCode, 또는 이들의 튜플 등 다양한 타입이 곧바로 응답이 됩니다.
  • 미들웨어(middleware): 인증이나 로깅처럼 여러 핸들러에 공통으로 적용할 로직입니다. 앞서 말한 Tower의 추상화를 그대로 활용해 라우터에 끼웁니다.

이 글의 나머지는 결국 이 다섯 가지 개념을 하나씩 풀어 보는 과정입니다.

프로젝트 시작하기

Cargo.toml에 필요한 의존성을 추가합니다.

Cargo.toml
[dependencies]
axum = "0.8"
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tower-http = { version = "0.6", features = ["trace", "cors"] }
tracing = "0.1"
tracing-subscriber = "0.3"

Axum은 Tokio 비동기 런타임 위에서 동작합니다. 그리고 JSON 직렬화는 serde로 처리하는데, Axum이 내부적으로 serde_json을 사용하므로 함께 추가해줍니다. 핸들러에서 자주 마주치게 될 StatusCode, HeaderMap, Uri 같은 타입은 http 크레이트에서 가져옵니다.

첫 번째 서버

가장 단순한 형태로 서버를 띄워보겠습니다.

src/main.rs
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("0.0.0.0:3000").await.unwrap();
    axum::serve(listener, app).await.unwrap();
}

Router::new()로 라우터를 만들고, .route()로 경로와 핸들러를 연결합니다. 핸들러는 평범한 비동기 함수입니다. 클로저로 인라인으로 작성할 수도 있고, 별도의 함수로 분리해도 됩니다.

서버를 실행하면 http://localhost:3000에서 응답이 옵니다.

실행
cargo run

라우팅

실제 서버에서는 여러 경로와 HTTP 메서드를 다뤄야 합니다. Axum 라우터는 메서드별로 핸들러를 따로 지정하는 방식을 씁니다.

use axum::{
    routing::{get, post, put, delete},
    Router,
};

let app = Router::new()
    .route("/users", get(list_users).post(create_user))
    .route("/users/:id", get(get_user).put(update_user).delete(delete_user));

같은 경로에 여러 메서드를 체이닝할 수 있어서 RESTful 설계가 한눈에 들어옵니다.

라우터 규모가 커지면 모듈별로 라우터를 나눠서 합칠 수 있습니다.

async fn main() {
    let app = Router::new()
        .merge(user_routes())
        .merge(post_routes());

    // ...
}

fn user_routes() -> Router {
    Router::new()
        .route("/users", get(list_users).post(create_user))
        .route("/users/:id", get(get_user))
}

두 메서드의 차이는 “최종 경로 앞에 접두사가 붙느냐”에 있습니다. .merge()는 각 라우터의 경로를 그대로 둔 채 하나로 합칩니다. 위에서 user_routes()/users를 정의했다면, 합친 뒤에도 경로는 그대로 /users입니다.

반면 .nest()는 라우터를 통째로 특정 접두사 아래에 끼워 넣습니다.

let app = Router::new()
    .nest("/api/v1", user_routes());

이렇게 하면 user_routes()가 정의한 /users/users/:id 앞에 /api/v1이 덧붙어 최종 경로가 다음처럼 바뀝니다.

최종 경로
GET  /api/v1/users
GET  /api/v1/users/:id

여기서 핵심은 user_routes() 안의 코드는 전혀 바뀌지 않는다는 점입니다. 자식 라우터는 여전히 /users만 알고 있고, 그 앞에 접두사를 붙일지 말지는 합치는 쪽이 .merge()를 쓰느냐 .nest()를 쓰느냐로 결정합니다. 덕분에 자식 라우터는 자신이 어디에 마운트될지 신경 쓰지 않아도 되어 그대로 재사용할 수 있습니다.

실무에서는 보통 둘을 함께 씁니다. 모듈별 라우터를 .merge()로 모은 뒤, 그 묶음 전체를 .nest()로 한 번에 마운트하는 식입니다.

fn api_routes() -> Router {
    Router::new()
        .merge(user_routes()) // /users, /users/:id
        .merge(post_routes()) // /posts
}

let app = Router::new()
    .nest("/api/v1", api_routes()); // → /api/v1/users, /api/v1/posts ...

추출자: 핸들러로 데이터 가져오기

Axum의 가장 독특한 기능이 추출자(extractor)입니다. 핸들러 함수의 인자 타입을 선언하면 Axum이 알아서 요청에서 해당 데이터를 꺼내 넣어줍니다.

본격적으로 살펴보기 전에 코드를 읽는 요령을 하나 짚고 가겠습니다. 앞으로 Path(user_id): Path<u64>Json(payload): Json<T>처럼 콜론 왼쪽에 추출자(변수) 형태가 반복해서 나오는데, 이 왼쪽은 타입이 아니라 패턴입니다. 대부분의 추출자는 pub struct Path<T>(pub T);처럼 값을 하나 감싼 튜플 구조체라서, 함수 인자 자리에 Path(user_id)라고 적으면 Rust가 그 자리에서 곧바로 내부 값(.0)을 꺼내 user_id에 바인딩합니다. 덕분에 핸들러 본문에서는 .0 없이 변수를 바로 쓸 수 있습니다. 만약 분해하지 않고 path: Path<u64>로 받으면 본문에서 path.0으로 직접 꺼내야 합니다. 이어지는 Query, Json, State, TypedHeader가 모두 이 패턴을 따르니, 한 번 익혀두면 나머지 코드가 한결 잘 읽힙니다.

Path 추출자

URL 경로 파라미터를 꺼낼 때 씁니다.

use axum::{extract::Path, response::Json};
use serde_json::{json, Value};

async fn get_user(Path(user_id): Path<u64>) -> Json<Value> {
    Json(json!({ "id": user_id, "name": "Alice" }))
}

경로를 /users/:id로 등록해두면 :id에 해당하는 값이 user_id에 들어옵니다. 파라미터가 여러 개라면 튜플을 씁니다.

async fn get_comment(
    Path((post_id, comment_id)): Path<(u64, u64)>,
) -> String {
    format!("post {post_id}, comment {comment_id}")
}

Query 추출자

URL 쿼리스트링을 파싱할 때 씁니다.

use axum::extract::Query;
use serde::Deserialize;

#[derive(Deserialize)]
struct Pagination {
    page: Option<u32>,
    per_page: Option<u32>,
}

async fn list_users(Query(params): Query<Pagination>) -> String {
    let page = params.page.unwrap_or(1);
    let per_page = params.per_page.unwrap_or(20);
    format!("page={page}, per_page={per_page}")
}

?page=2&per_page=10 같은 쿼리스트링이 자동으로 Pagination 구조체로 역직렬화됩니다. 파싱에 실패하면 Axum이 400 응답을 자동으로 돌려줍니다.

Json 추출자

요청 바디를 JSON으로 파싱할 때 씁니다.

use axum::Json;
use serde::{Deserialize, Serialize};

#[derive(Deserialize)]
struct CreateUserRequest {
    name: String,
    email: String,
}

#[derive(Serialize)]
struct UserResponse {
    id: u64,
    name: String,
    email: String,
}

async fn create_user(Json(payload): Json<CreateUserRequest>) -> Json<UserResponse> {
    Json(UserResponse {
        id: 1,
        name: payload.name,
        email: payload.email,
    })
}

Json<T> 추출자는 요청의 Content-Type: application/json 헤더를 확인하고 바디를 T로 역직렬화합니다. 핸들러가 Json<T>를 반환하면 응답에 자동으로 Content-Type: application/json이 붙습니다.

Header 추출자

요청 헤더를 꺼낼 때는 TypedHeader를 씁니다.

use axum_extra::TypedHeader;
use headers::Authorization;
use headers::authorization::Bearer;

async fn protected(
    TypedHeader(auth): TypedHeader<Authorization<Bearer>>,
) -> String {
    format!("token: {}", auth.token())
}

TypedHeaderaxum-extra 크레이트를 추가해야 쓸 수 있습니다. Authorization이나 ContentType처럼 표준으로 정의된 헤더는 이렇게 타입으로 받는 편이 안전합니다. 형식이 잘못된 헤더는 핸들러에 도달하기 전에 Axum이 걸러내고, auth.token()처럼 파싱된 값에 바로 접근할 수 있기 때문입니다.

반대로 X-Request-IdX-Api-Key 같은 커스텀 헤더를 직접 읽고 싶을 때는 axum::http::HeaderMap을 핸들러 인자로 받습니다. 핸들러 시그니처에 HeaderMap 타입만 선언해두면 Axum이 요청의 모든 헤더를 담아 넘겨줍니다.

use axum::http::HeaderMap;

async fn handler(headers: HeaderMap) -> String {
    // get()은 Option<&HeaderValue>를 반환하므로 없을 수도 있는 값으로 다룬다
    let request_id = headers
        .get("x-request-id")
        // HeaderValue는 바이트 기반이라 to_str()로 UTF-8 문자열로 변환한다
        .and_then(|value| value.to_str().ok())
        .unwrap_or("unknown");

    format!("request id: {request_id}")
}

여기서 주의할 점이 두 가지 있습니다. 우선 헤더 이름은 대소문자를 구분하지 않으므로 get("x-request-id")X-Request-Id로 들어온 헤더도 그대로 찾아냅니다. 그리고 HeaderValue는 임의의 바이트를 담을 수 있어서 문자열이라는 보장이 없습니다. 그래서 to_str()로 UTF-8 변환을 한 번 거쳐야 하고, 이 과정에서 실패할 수 있다는 점을 Option으로 다뤄줍니다.

상태 관리

실제 서버에서는 데이터베이스 연결이나 설정값처럼 여러 핸들러가 공유하는 상태가 필요합니다. Axum은 State 추출자로 이 문제를 해결합니다.

use axum::{extract::State, routing::get, Router};
use std::sync::Arc;

struct AppState {
    db_url: String,
    // 실제로는 DB 커넥션 풀, HTTP 클라이언트 등
}

async fn get_config(State(state): State<Arc<AppState>>) -> String {
    state.db_url.clone()
}

#[tokio::main]
async fn main() {
    let state = Arc::new(AppState {
        db_url: "postgres://localhost/mydb".to_string(),
    });

    let app = Router::new()
        .route("/config", get(get_config))
        .with_state(state);

    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
    axum::serve(listener, app).await.unwrap();
}

Router::with_state()로 상태를 등록하고, 핸들러에서 State(state): State<Arc<AppState>>로 꺼냅니다. Arc로 감싸는 이유는 여러 요청이 동시에 상태에 접근하기 때문입니다. 가변 상태가 필요하면 Arc<Mutex<T>> 또는 Arc<RwLock<T>>를 씁니다.

use std::sync::{Arc, RwLock};
use std::collections::HashMap;

type SharedStore = Arc<RwLock<HashMap<u64, String>>>;

async fn read_item(
    State(store): State<SharedStore>,
    Path(id): Path<u64>,
) -> String {
    let map = store.read().unwrap();
    map.get(&id).cloned().unwrap_or_else(|| "not found".to_string())
}

에러 처리

핸들러가 에러를 반환해야 할 때는 Result<T, E> 타입을 쓰는데, EIntoResponse를 구현해야 합니다. 커스텀 에러 타입을 만들어두면 편합니다.

use axum::{
    http::StatusCode,
    response::{IntoResponse, Response},
    Json,
};
use serde_json::json;

enum AppError {
    NotFound(String),
    Unauthorized,
    Internal(String),
}

impl IntoResponse for AppError {
    fn into_response(self) -> Response {
        let (status, message) = match self {
            AppError::NotFound(msg) => (StatusCode::NOT_FOUND, msg),
            AppError::Unauthorized => (
                StatusCode::UNAUTHORIZED,
                "인증이 필요합니다".to_string(),
            ),
            AppError::Internal(msg) => (StatusCode::INTERNAL_SERVER_ERROR, msg),
        };

        (status, Json(json!({ "error": message }))).into_response()
    }
}

이제 핸들러에서 자연스럽게 ? 연산자를 쓸 수 있습니다.

async fn get_user(Path(id): Path<u64>) -> Result<Json<UserResponse>, AppError> {
    let user = find_user(id)
        .await
        .map_err(|e| AppError::Internal(e.to_string()))?
        .ok_or_else(|| AppError::NotFound(format!("user {id} not found")))?;

    Ok(Json(user))
}

에러를 AppError로 변환해서 돌려주면 IntoResponse 구현이 적절한 HTTP 상태코드와 JSON 바디로 만들어줍니다.

실무에서는 thiserror 크레이트로 에러 타입을 정의하고 From 구현으로 외부 에러를 자동 변환하는 패턴을 자주 씁니다.

응답 타입

Axum 핸들러는 IntoResponse를 구현한 타입이면 무엇이든 반환할 수 있습니다. String, &str, Json<T>, StatusCode, 튜플 조합까지 기본 구현이 이미 마련되어 있습니다.

상태코드와 바디를 함께 반환하려면 튜플을 씁니다.

use axum::http::StatusCode;

async fn create_item(Json(payload): Json<CreateRequest>) -> (StatusCode, Json<ItemResponse>) {
    let item = /* 생성 로직 */;
    (StatusCode::CREATED, Json(item))
}

헤더도 함께 지정하고 싶다면 (StatusCode, HeaderMap, body) 튜플이나 Response 빌더를 씁니다.

use axum::{
    http::{header, StatusCode},
    response::Response,
    body::Body,
};

async fn redirect(Path(slug): Path<String>) -> Response {
    Response::builder()
        .status(StatusCode::MOVED_PERMANENTLY)
        .header(header::LOCATION, format!("/new/{slug}"))
        .body(Body::empty())
        .unwrap()
}

미들웨어

Axum은 Tower 위에 만들어졌기 때문에 Tower 미들웨어를 그대로 끼울 수 있습니다. Tower 미들웨어 실전에서 다룬 ServiceBuilder를 여기서 바로 쓸 수 있죠.

tower-http의 미들웨어를 추가하는 예시입니다.

use axum::Router;
use tower_http::{
    cors::{Any, CorsLayer},
    trace::TraceLayer,
};

let app = Router::new()
    .route("/", get(handler))
    .layer(TraceLayer::new_for_http())
    .layer(
        CorsLayer::new()
            .allow_origin(Any)
            .allow_methods(Any)
            .allow_headers(Any),
    );

.layer()에 미들웨어 레이어를 넣으면 라우터 전체에 적용됩니다. 특정 라우트에만 적용하고 싶을 때는 .route_layer()를 씁니다.

use tower::ServiceBuilder;
use std::time::Duration;

let app = Router::new()
    .route("/public", get(public_handler))
    .route(
        "/private",
        get(private_handler).route_layer(
            ServiceBuilder::new()
                .layer(auth_middleware)
        ),
    );

/private 경로에만 인증 미들웨어가 적용되고 /public은 그대로입니다.

인증 미들웨어 같은 걸 직접 만들 때는 Tower Layer 직접 만들기에서 다루는 ServiceLayer 트레이트를 구현하면 됩니다. 작성한 미들웨어는 Axum뿐만 아니라 Hyper, Tonic 어디서든 그대로 쓸 수 있습니다.

실전: 간단한 메모 API

지금까지 나온 개념들을 조합해서 메모를 CRUD 하는 API를 만들어보겠습니다.

use axum::{
    extract::{Path, State},
    http::StatusCode,
    response::{IntoResponse, Response},
    routing::{get, post, delete},
    Json, Router,
};
use serde::{Deserialize, Serialize};
use std::{
    collections::HashMap,
    sync::{Arc, RwLock},
};

#[derive(Clone, Serialize)]
struct Memo {
    id: u64,
    content: String,
}

#[derive(Deserialize)]
struct CreateMemoRequest {
    content: String,
}

type Store = Arc<RwLock<HashMap<u64, Memo>>>;

enum ApiError {
    NotFound,
}

impl IntoResponse for ApiError {
    fn into_response(self) -> Response {
        match self {
            ApiError::NotFound => (StatusCode::NOT_FOUND, "not found").into_response(),
        }
    }
}

async fn list_memos(State(store): State<Store>) -> Json<Vec<Memo>> {
    let map = store.read().unwrap();
    Json(map.values().cloned().collect())
}

async fn create_memo(
    State(store): State<Store>,
    Json(payload): Json<CreateMemoRequest>,
) -> (StatusCode, Json<Memo>) {
    let mut map = store.write().unwrap();
    let id = map.len() as u64 + 1;
    let memo = Memo { id, content: payload.content };
    map.insert(id, memo.clone());
    (StatusCode::CREATED, Json(memo))
}

async fn delete_memo(
    State(store): State<Store>,
    Path(id): Path<u64>,
) -> Result<StatusCode, ApiError> {
    let mut map = store.write().unwrap();
    map.remove(&id).ok_or(ApiError::NotFound)?;
    Ok(StatusCode::NO_CONTENT)
}

#[tokio::main]
async fn main() {
    let store: Store = Arc::new(RwLock::new(HashMap::new()));

    let app = Router::new()
        .route("/memos", get(list_memos).post(create_memo))
        .route("/memos/:id", delete(delete_memo))
        .with_state(store);

    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
    println!("listening on http://0.0.0.0:3000");
    axum::serve(listener, app).await.unwrap();
}

Arc<RwLock<HashMap>>>으로 인메모리 저장소를 만들고, 각 핸들러에서 State로 꺼내 씁니다. 실제 서비스라면 저장소 자리에 데이터베이스 커넥션 풀(sqlx, sea-orm 등)이 들어갑니다. 구조 자체는 동일합니다. 메모 본문에 풀텍스트 검색을 붙이고 싶다면 tantivy로 시작하는 Rust 풀텍스트 검색에서 다룬 인덱스를 같은 State로 공유해 핸들러 안에서 바로 검색 API를 노출할 수 있어요.

마치며

Axum을 쓰다 보면 프레임워크가 뒤에서 별다른 마법을 부리지 않는다는 걸 느끼게 됩니다. 타입 시스템으로 추출자를 선언하고, Tower로 미들웨어를 합성하고, IntoResponse로 응답을 정의하는 것 전부 Rust가 원래 갖고 있는 기능들입니다. 덕분에 서버가 커져도 핸들러 하나가 뭘 하는지 함수 시그니처만 봐도 파악됩니다.

타임아웃, 재시도, 동시 요청 제한 같은 빌트인 미들웨어를 Axum 서버에 붙이는 방법은 Tower 미들웨어 실전에 예제가 있습니다.

더 자세한 내용은 Axum 공식 문서를 참고하세요.

This work is licensed under CC BY 4.0CCBY

개발자를 위한 뉴스레터

달레가 정리한 AI 개발 트렌드와 직접 만든 콘텐츠를 전해드립니다.

Discord