Axum 완벽 가이드 — Rust 고성능 웹 서버·REST API

Axum 완벽 가이드 — Rust 고성능 웹 서버·REST API

이 글의 핵심

Axum은 Tokio·Tower·Hyper 위에서 동작하는 Rust 웹 프레임워크입니다. 라우팅·Extractor·미들웨어·상태·에러·WebSocket·REST API를 한 흐름으로 익힙니다.

이 글의 핵심

Axum은 Rust에서 HTTP 서비스를 만들 때 많이 선택되는 프레임워크입니다. Tokio 비동기 런타임, TowerService 레이어, Hyper HTTP 구현 위에 올라가 있으며, “타입으로 요청을 파싱한다”는 Extractor 모델이 특징입니다. 이 글에서는 라우팅·핸들러, Extractor와 미들웨어, 애플리케이션 상태, 에러 응답, WebSocket, 실전 REST API 구조까지 한 번에 정리합니다. 이미 Rust 비동기·Tokio를 다룬 적이 있다면, 그 지식이 Axum 학습에 직결됩니다.


1. Axum이란 무엇인가

1.1 스택 구성

Axum은 단독으로 모든 것을 재구현하지 않습니다. 요청은 Hyper를 통해 들어오고, 비동기 실행은 Tokio가 담당하며, 미들웨어·타임아웃·재시도 같은 가로 관심사는 Tower의 ServiceLayer로 조합합니다. 따라서 “Axum만 배운다”기보다 Tower 패턴async Rust를 함께 이해하는 것이 장기적으로 유리합니다.

1.2 설계 철학

  • 타입 안전한 라우팅: 경로 파라미터·쿼리·바디를 Extractor로 조합해 컴파일 타임에 검증 가능한 형태로 만듭니다.
  • 중첩 라우터: Routermerge·nest해 모듈 단위로 API를 쪼갤 수 있습니다.
  • 미들웨어와의 일관성: Tower 레이어를 그대로 쓸 수 있어 CORS, 트레이싱, 압축 등을 동일한 방식으로 붙입니다.

1.3 언제 Axum을 고를까

마이크로서비스 API, BFF(Backend for Frontend), 내부 관리 도구, WebSocket이 섞인 실시간 엔드포인트 등 HTTP·JSON·스트리밍 중심 서비스에 잘 맞습니다. 반면 템플릿 엔진·SSR이 전부인 전통적인 멀티 페이지 서버만 필요하면 다른 스택을 검토하는 편이 낫습니다.


2. 프로젝트 생성과 의존성

2.1 새 프로젝트

cargo new axum-demo --bin
cd axum-demo

2.2 Cargo.toml 예시

아래는 REST·CORS·로그·WebSocket을 한 번에 쓰기 위한 최소 구성 예입니다. 실제 프로덕션에서는 버전 핀과 cargo audit을 주기적으로 실행하세요.

[package]
name = "axum-demo"
version = "0.1.0"
edition = "2021"

[dependencies]
axum = { version = "0.7", features = ["macros", "ws"] }
tokio = { version = "1", features = ["full"] }
tower = "0.5"
tower-http = { version = "0.5", features = ["cors", "trace"] }
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"] }

features = ["macros"]#[derive(axum::routing::get)] 등 라우트 매크로를 쓸 때, ws는 WebSocket 핸들러에 필요합니다.


3. 최소 서버와 라우팅

3.1 Hello World

Router에 경로와 메서드를 붙이고, TcpListener로 바인딩한 뒤 axum::serve에 넘깁니다. tokio::main으로 엔트리를 비동기로 만드는 패턴이 표준입니다.

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에 넘긴 클로저는 async 블록이므로 핸들러는 Future를 반환합니다. 반환 타입은 IntoResponse를 구현하면 되며, 문자열 슬라이스는 자동으로 200 OK 텍스트 응답으로 변환됩니다.

3.2 여러 라우트와 HTTP 메서드

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

async fn list_users() -> &'static str {
    "user list"
}

async fn create_user() -> &'static str {
    "user created"
}

#[tokio::main]
async fn main() {
    let app = Router::new()
        .route("/users", get(list_users).post(create_user));

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

같은 경로에 GETPOST를 붙이려면 한 줄에 get(...).post(...)처럼 체이닝하면 됩니다. REST API에서는 리소스 단위로 묶는 방식이 읽기 쉽습니다.


4. 핸들러와 Extractor

4.1 Extractor란

Axum에서 Extractor는 “요청에서 값을 꺼내 타입으로 만드는 것”입니다. 함수 인자의 순서와 타입이 곧 파싱 규칙입니다. 예를 들어 Path는 경로 변수, Query는 쿼리 스트링, Json은 JSON 바디를 담당합니다.

4.2 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는 경로 세그먼트 이름(:id)과 타입이 맞아야 합니다. u64 파싱에 실패하면 400 Bad Request로 떨어지므로, 커스텀 타입·검증이 필요하면 serde 파이프라인이나 별도 Extractor를 고려합니다. JsonContent-Type: application/json과 바디 파싱을 담당하며, 역직렬화 실패 시 역시 클라이언트 오류로 처리됩니다.

4.3 여러 Extractor 조합

Extractor는 튜플로 묶을 수 있습니다. 예: Path 다음에 Query, 그 다음 HeaderMap 등. 소비형 Extractor(예: 바디를 한 번 읽는 타입)는 한 핸들러에 하나라는 제약이 있으므로, 바디를 두 번 읽는 설계는 피해야 합니다.


5. 미들웨어(Middleware)

5.1 Tower Layer

Axum은 Tower의 LayerRouter::layer로 감쌉니다. CORS·요청 로깅·타임아웃 등은 대부분 tower-http에서 제공합니다.

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

fn app() -> Router {
    Router::new()
        .route("/health", axum::routing::get(|| async { "ok" }))
        .layer(
            CorsLayer::new()
                .allow_origin(Any)
                .allow_methods(Any)
                .allow_headers(Any),
        )
        .layer(TraceLayer::new_for_http())
}

프로덕션에서는 allow_origin(Any) 대신 명시적 오리진 목록을 쓰는 것이 안전합니다. TraceLayertracing과 연결해 요청 메서드·경로·상태 코드를 로그로 남기기 좋습니다.

5.2 커스텀 미들웨어

axum::middleware::from_fn으로 비동기 함수를 레이어로 올릴 수 있습니다. 인증 헤더 검사, 요청 ID 주입, 레이트 리밋 등에 사용합니다.

use axum::{
    body::Body,
    extract::Request,
    middleware::Next,
    response::Response,
};

async fn inject_request_id(mut req: Request, next: Next) -> Response {
    let id = uuid::Uuid::new_v4().to_string();
    req.headers_mut().insert("x-request-id", id.parse().unwrap());
    next.run(req).await
}

Next는 다음 레이어/핸들러로 요청을 넘깁니다. 응답 후처리가 필요하면 next.run 이후에 헤더를 붙이면 됩니다.


6. 상태 관리(State)

6.1 AppState 패턴

애플리케이션 전역에서 DB 풀·설정·클라이언트를 공유하려면 구조체에 Clone을 구현하고 Router::with_state로 주입합니다. 비싼 자원은 Arc로 감쌉니다.

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

#[derive(Clone)]
struct AppState {
    db: Arc<String>, // 실제로는 sqlx::PgPool 등
}

async fn handler(State(state): State<AppState>) -> String {
    format!("db = {}", state.db)
}

fn app(state: AppState) -> Router {
    Router::new()
        .route("/", get(handler))
        .with_state(state)
}

State Extractor는 핸들러 서명에 정확히 한 번 타입이 일치해야 합니다. 서브 라우터마다 다른 상태 타입을 쓰려면 merge 시 제네릭이 복잡해지므로, 보통은 하나의 AppState에 모듈별 필드를 두는 방식이 단순합니다.

6.2 서브 라우트와 상태

nest/api 아래에 모듈을 붙일 때도 동일한 State를 공유할 수 있습니다. API 버전이 나뉘면 Router를 파일 단위로 쪼개 merge하는 구조가 유지보수에 유리합니다.


7. 에러 처리

7.1 Result와 IntoResponse

핸들러가 Result<impl IntoResponse, E>를 반환할 수 있으려면 E: IntoResponse여야 합니다. 프로젝트 전역 에러 타입을 정의하고 IntoResponse를 구현하는 패턴이 가장 흔합니다.

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

pub struct ApiError {
    status: StatusCode,
    message: String,
}

impl IntoResponse for ApiError {
    fn into_response(self) -> Response {
        let body = Json(json!({
            "error": self.message,
        }));
        (self.status, body).into_response()
    }
}

pub type ApiResult<T> = Result<T, ApiError>;

이렇게 하면 핸들러에서 ?로 연쇄할 수 있고, HTTP 상태와 JSON 형식을 일관되게 유지할 수 있습니다.

7.2 anyhow·thiserror와의 연동

도메인 에러는 thiserror로, 경계에서는 ApiError로 매핑합니다. anyhow::Error를 그대로 클라이언트에 노출하면 내부 스택 정보가 새어 나갈 수 있으므로, 500 응답에는 일반화된 메시지를 쓰고 상세는 로그에만 남기는 것이 안전합니다.

7.3 폴백 핸들러

Router::fallback으로 404 JSON을 통일하거나, SPA 정적 파일 서빙 시 index.html로 넘기는 식으로 동작을 고정할 수 있습니다.


8. WebSocket

8.1 업그레이드 흐름

Axum의 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.on_upgrade(handle_socket)
}

async fn handle_socket(mut socket: WebSocket) {
    while let Some(Ok(msg)) = socket.recv().await {
        if let Message::Text(t) = msg {
            let _ = socket.send(Message::Text(t)).await;
        }
    }
}

fn ws_routes() -> Router {
    Router::new().route("/ws", get(ws_handler))
}

실제 서비스에서는 핑/퐁 타임아웃, 메시지 크기 제한, 인증(쿼리 토큰 또는 쿠키)을 함께 설계해야 합니다. 장시간 연결은 로드밸런서의 idle timeout과도 맞춰야 합니다.


9. 실전 REST API 구조

9.1 레이어드 구성

아래는 “한 파일에 모든 것”이 아니라, 라우터 / 핸들러 / 서비스(도메인) / 저장소를 나누는 방향을 가정한 스케치입니다. 작은 프로젝트에서는 핸들러 안에 직접 비즈니스 로직을 넣어도 되지만, 테스트와 변경이 잦아지면 서비스 레이어로 분리하는 편이 낫습니다.

  • 라우터: URL·메서드·미들웨어만 담당
  • 핸들러: Extractor 조합, StatusCode 결정, DTO 매핑
  • 서비스: 순수 로직·트랜잭션 경계
  • 저장소: DB·외부 API

9.2 CRUD 스케치

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

#[derive(Clone, Default)]
struct AppState {
    items: Arc<RwLock<HashMap<u64, Item>>>,
    next_id: Arc<RwLock<u64>>,
}

#[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 mut id_lock = state.next_id.write().unwrap();
    let id = *id_lock;
    *id_lock += 1;
    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)
}

위 예에서 RwLock데모용입니다. 실제로는 tokio::sync::RwLock과 비동기 DB, 또는 채널 기반 단일 writer 같은 모델을 검토하세요. 동시성 모델은 도메인 요구와 측정 결과에 따라 달라집니다.

9.3 검증과 경계

입력 검증은 serde 제약, validator 크레이트, 혹은 커스텀 Extractor로 처리합니다. 경계에서 한 번만 검증하고 내부 도메인 타입으로 변환하면, 잘못된 상태가 깊숙이 스며드는 것을 막을 수 있습니다.


10. 운영·성능·실수 방지

10.1 블로킹 호출

Tokio 워커에서 동기 파일 I/O나 CPU 바운드 작업을 길게 실행하면 다른 요청이 기아 상태에 빠질 수 있습니다. 반드시 블로킹이면 tokio::task::spawn_blocking이나 별도 스레드 풀을 사용하세요.

10.2 연결 타임아웃·바디 크기

리버스 프록시(Nginx, Cloudflare)와 애플리케이션 양쪽에서 타임아웃·최대 바디 크기를 맞춥니다. Axum·Hyper 단독 설정 외에도 인프라 레벨 제한이 실제 방어선이 됩니다.

10.3 관측 가능성

tracing과 구조화 로그, OpenTelemetry 연동, /health·/ready 엔드포인트는 운영 필수에 가깝습니다. 에러 응답에 요청 ID를 붙이면 장애 분석이 빨라집니다.


11. 트러블슈팅

증상자주 있는 원인점검
the trait bound ... is not satisfiedExtractor 순서·타입 불일치핸들러 인자 타입과 with_state 제네릭 확인
404만 반복nest 경로·슬래시 중복"/api""/api/" 혼동 여부
WebSocket 즉시 끊김프록시가 업그레이드 미지원HTTP/1.1 업그레이드·헤더 전달 확인
바디 파싱 실패Content-Type·JSON 형식클라이언트가 application/json 보내는지 확인

12. 정리

Axum은 Tokio·Tower 생태계와 자연스럽게 맞물리는 Rust 웹 프레임워크입니다. 라우터로 URL을 나누고, Extractor로 요청을 타입으로 바꾸고, State로 자원을 공유하고, IntoResponse로 에러를 HTTP로 되돌리는 흐름만 익혀도 실무 API의 뼈대는 갖출 수 있습니다. 그 위에 미들웨어로 횡단 관심사, WebSocket으로 실시간 채널, 계층형 모듈 구조로 REST를 얹으면 프로덕션에 가까운 형태가 됩니다. 배포 전에는 cargo clippy, 부하 테스트, 그리고 보안 헤더·TLS 종단 여부까지 인프라 체크리스트를 함께 돌리는 것을 권장합니다.


배포 참고: 변경 후 git addgit commitgit push를 마친 뒤 npm run deploy로 Cloudflare Pages에 반영할 수 있습니다. 동일 제목·슬롯의 포스트가 이미 있으면 파일을 덮어쓰지 말고 이름을 조정하세요.