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

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

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

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

프로젝트 시작하기

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()로 여러 라우터를 하나로 합칩니다. 공통 접두사가 있다면 .nest()를 씁니다.

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

/api/v1 아래에 api_routes()의 모든 경로가 마운트됩니다.

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

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

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())
}

axum-extra 크레이트를 추가해야 하는데, 원시 헤더가 필요하다면 axum::http::HeaderMap을 바로 써도 됩니다.

상태 관리

실제 서버에서는 데이터베이스 연결이나 설정값처럼 여러 핸들러가 공유하는 상태가 필요합니다. 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.0 CC BY

개발자를 위한 뉴스레터

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

Discord