Rust의 Axum으로 REST API 작성하기

1. 개요

Axum은 Tokio 팀의 웹 프레임워크입니다. hyper와 tower 위에 구축되며, Rust의 타입 시스템을 활용하여 handler를 컴파일 시점에 검사합니다.

이 글에서는 주문(Order)을 관리하는 REST API를 작성하면서 모든 CRUD 작업을 수행합니다. 또한 handler 간의 state 공유, JSON 형식의 구조화된 에러 반환, 포트를 열지 않고 router를 테스트하는 방법을 다룹니다.

이 글의 코드는 Axum 0.8과 Rust edition 2021 이상을 사용합니다.

2. 프로젝트 설정

새 프로젝트를 생성합니다:

cargo new order-api
cd order-api

Cargo.toml에 의존성을 추가합니다:

[dependencies]
axum = "0.8"
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"

[dev-dependencies]
tower = { version = "0.5", features = ["util"] }
http-body-util = "0.1"

각 crate의 역할:

  • axum: router, extractor, response 타입.
  • tokio: async runtime. Axum은 반드시 Tokio 위에서 실행됩니다.
  • serde와 serde_json: struct를 JSON으로 변환하고 그 반대로.
  • tower와 http-body-util: 테스트할 때만 사용. 이들은 request를 router로 직접 전송하고 response 본문을 읽는 것을 도와줍니다.

3. 첫 번째 Axum 애플리케이션

서비스의 health 확인 endpoint부터 시작합니다:

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

#[tokio::main]
async fn main() {
    let app = Router::new().route("/health", get(|| async { "OK" }));

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

이 코드는 세 가지 일을 합니다:

  • #[tokio::main] 속성은 main 함수를 Tokio runtime 위에서 실행되는 async 함수로 변환합니다.
  • Router::new().route(...) 경로 /health를 GET 메서드와 연결합니다. handler는 &'static str을 반환하는 async 클로저입니다. Axum이 자동으로 이 문자열을 200 OK 응답으로 변환하며 Content-Type: text/plain을 설정합니다.
  • axum::serve는 TcpListener를 받아 request 처리를 시작합니다.

실행해봅시다:

cargo run
curl -i http://localhost:3000/health
HTTP/1.1 200 OK
content-type: text/plain; charset=utf-8
content-length: 2

OK

4. Model과 shared state

4.1. Model 정의

use serde::{Deserialize, Serialize};

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct Order {
    pub id: u64,
    pub customer: String,
    pub amount: u64,
}

#[derive(Debug, Deserialize)]
pub struct CreateOrder {
    pub customer: String,
    pub amount: u64,
}

CreateOrder를 Order에서 분리한 것은 의도적입니다. 클라이언트가 자신의 id를 전송할 수 없는데, 이는 서버가 생성하기 때문입니다. 같은 struct를 사용하면 클라이언트가 임의로 id를 전송하는 경우를 처리해야 합니다.

4.2. 공유 state

이 글에서는 데이터를 메모리에 저장합니다. 실제로는 보통 sqlx::PgPool 같은 connection pool을 사용합니다.

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

#[derive(Clone, Default)]
pub struct AppState {
    orders: Arc<RwLock<HashMap<u64, Order>>>,
    next_id: Arc<AtomicU64>,
}

Axum은 각 request마다 state를 clone합니다. 따라서 AppState는 Clone을 구현해야 하고, clone이 저렴해야 합니다. 데이터를 Arc로 감싸면 두 가지 요구 사항을 만족합니다. clone은 참조 카운트만 증가시키고, 모든 handler는 같은 데이터를 가리킵니다.

RwLock은 여러 thread이 동시에 읽을 수 있지만, 한 번에 하나의 thread만 쓸 수 있습니다. AtomicU64는 lock 없이 id를 생성하는 데 사용됩니다.

주의: 여기서는 std::sync::RwLock을 사용합니다. lock을 획득한 후 해제할 때까지 .await이 없기 때문입니다. lock을 획득한 상태에서 .await을 해야 한다면 tokio::sync::RwLock으로 전환하세요. 그렇지 않으면 future가 Send가 아니 되어 handler가 컴파일되지 않습니다.

5. IntoResponse를 이용한 에러 처리

handler를 작성하기 전에 공통 에러 타입을 정의합시다. IntoResponse를 구현하는 모든 타입은 handler에서 반환될 수 있으며, Result에 포함되어 있어도 됩니다.

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

#[derive(Debug)]
pub enum AppError {
    NotFound(u64),
    Validation(String),
}

impl IntoResponse for AppError {
    fn into_response(self) -> Response {
        let (status, message) = match self {
            AppError::NotFound(id) => (StatusCode::NOT_FOUND, format!("Order {id} not found")),
            AppError::Validation(msg) => (StatusCode::UNPROCESSABLE_ENTITY, msg),
        };
        (status, Json(json!({ "error": message }))).into_response()
    }
}

튜플 (StatusCode, Json<_>)도 IntoResponse를 구현합니다. 덕분에 상태 코드와 본문을 동시에 설정할 수 있습니다.

이제부터 handler는 Err(AppError::NotFound(id))를 반환하기만 하면 됩니다. 클라이언트는 다음과 같은 응답을 받습니다:

HTTP/1.1 404 Not Found
content-type: application/json

{"error":"Order 42 not found"}

6. CRUD handler 작성

6.1. 주문 생성

use axum::extract::State;
use std::sync::atomic::Ordering;

async fn create_order(
    State(state): State<AppState>,
    Json(input): Json<CreateOrder>,
) -> Result<(StatusCode, Json<Order>), AppError> {
    if input.customer.trim().is_empty() {
        return Err(AppError::Validation("customer must not be empty".into()));
    }
    let id = state.next_id.fetch_add(1, Ordering::Relaxed) + 1;
    let order = Order { id, customer: input.customer, amount: input.amount };
    state.orders.write().unwrap().insert(id, order.clone());
    Ok((StatusCode::CREATED, Json(order)))
}

handler의 각 매개변수는 extractor입니다:

  • State(state)는 router에 연결된 AppState를 가져옵니다.
  • Json(input)은 본문을 읽고 CreateOrder로 deserialize합니다. 본문의 형식이 잘못되면 함수가 실행되기 전에 Axum이 자동으로 에러를 반환합니다.

fetch_add는 이전 값을 반환하므로, id가 1부터 시작하려면 1을 더해야 합니다. handler는 201 Created 상태와 함께 생성된 주문을 반환합니다.

주의: Json처럼 본문을 읽는 extractor는 마지막 매개변수여야 합니다. 본문은 한 번만 읽을 수 있습니다. Json을 State 앞에 놓으면 “the trait Handler<_, _> is not implemented” 같은 컴파일 에러가 발생하며, 원인을 모르면 이 메시지는 매우 혼란스러울 수 있습니다.

6.2. id로 하나의 주문 조회

use axum::extract::Path;

async fn get_order(
    State(state): State<AppState>,
    Path(id): Path<u64>,
) -> Result<Json<Order>, AppError> {
    state
        .orders
        .read()
        .unwrap()
        .get(&id)
        .cloned()
        .map(Json)
        .ok_or(AppError::NotFound(id))
}

Path<u64>는 URL의 {id} 부분을 u64로 파싱합니다. 클라이언트가 /orders/abc를 호출하면, 추가 코드 없이 Axum이 400 Bad Request를 반환합니다. 주문을 찾지 못했을 때는 ok_or가 None을 NotFound 에러로 변환합니다.

6.3. 페이지네이션이 있는 목록 조회

use axum::extract::Query;

#[derive(Debug, Deserialize)]
pub struct Pagination {
    #[serde(default)]
    offset: usize,
    #[serde(default = "default_limit")]
    limit: usize,
}

fn default_limit() -> usize {
    20
}

async fn list_orders(
    State(state): State<AppState>,
    Query(page): Query<Pagination>,
) -> Json<Vec<Order>> {
    let orders = state.orders.read().unwrap();
    let mut result: Vec<Order> = orders.values().cloned().collect();
    result.sort_by_key(|o| o.id);
    let result = result.into_iter().skip(page.offset).take(page.limit).collect();
    Json(result)
}

Query<Pagination>은 ?offset=10&limit=5 같은 쿼리 문자열을 읽습니다. #[serde(default)] 덕분에 클라이언트가 두 매개변수를 생략해도 유효한 결과를 받습니다.

코드는 id순으로 정렬해야 합니다. HashMap은 순서를 보장하지 않기 때문입니다. 이 단계를 건너뛰면 페이지네이션 결과가 호출마다 달라집니다.

6.4. 주문 삭제

async fn delete_order(
    State(state): State<AppState>,
    Path(id): Path<u64>,
) -> Result<StatusCode, AppError> {
    match state.orders.write().unwrap().remove(&id) {
        Some(_) => Ok(StatusCode::NO_CONTENT),
        None => Err(AppError::NotFound(id)),
    }
}

StatusCode 자체도 IntoResponse를 구현합니다. 성공적인 삭제에는 204 No Content를 반환하는 것이 관례입니다.

7. Router 구성

pub fn app(state: AppState) -> Router {
    Router::new()
        .route("/health", get(|| async { "OK" }))
        .route("/orders", get(list_orders).post(create_order))
        .route("/orders/{id}", get(get_order).delete(delete_order))
        .with_state(state)
}

#[tokio::main]
async fn main() {
    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
    axum::serve(listener, app(AppState::default())).await.unwrap();
}

이 router는 다음과 같이 구성됩니다:

  • 하나의 경로에 여러 메서드를 연결할 수 있습니다: get(...).post(...).
  • with_state는 router에 state를 연결합니다. 이 단계 후 router의 타입은 Router<()>이 되고 request를 처리할 준비가 됩니다.
  • app 함수를 따로 분리하여 테스트가 포트를 열지 않고 router를 만들 수 있게 합니다.

주문을 생성하고 조회해봅시다:

curl -i -X POST http://localhost:3000/orders \
  -H "Content-Type: application/json" \
  -d '{"customer":"Nguyen Van An","amount":150000}'

curl http://localhost:3000/orders/1
HTTP/1.1 201 Created
content-type: application/json

{"id":1,"customer":"Nguyen Van An","amount":150000}

{"id":1,"customer":"Nguyen Van An","amount":150000}

주의: Axum 0.8부터는 경로 매개변수가 {id} 문법을 사용합니다. 이전 버전의 /:id 코드를 복사하면 router 생성 시 애플리케이션이 panic 합니다. wildcard 문법도 *rest에서 {*rest}로 변경되었습니다.

8. Router 테스트

Router는 tower의 Service trait을 구현합니다. 따라서 oneshot을 사용해 실제 서버를 실행하지 않고도 request를 직접 router로 보낼 수 있습니다. main.rs 끝에 테스트 모듈을 추가합시다:

#[cfg(test)]
mod tests {
    use super::*;
    use axum::body::Body;
    use axum::http::Request;
    use http_body_util::BodyExt;
    use tower::ServiceExt;

    fn post_order(json: &str) -> Request<Body> {
        Request::builder()
            .method("POST")
            .uri("/orders")
            .header("content-type", "application/json")
            .body(Body::from(json.to_owned()))
            .unwrap()
    }

    #[tokio::test]
    async fn create_then_get_order() {
        let app = app(AppState::default());

        let response = app
            .clone()
            .oneshot(post_order(r#"{"customer":"An","amount":150000}"#))
            .await
            .unwrap();
        assert_eq!(response.status(), StatusCode::CREATED);

        let response = app
            .oneshot(Request::get("/orders/1").body(Body::empty()).unwrap())
            .await
            .unwrap();
        assert_eq!(response.status(), StatusCode::OK);

        let bytes = response.into_body().collect().await.unwrap().to_bytes();
        let order: Order = serde_json::from_slice(&bytes).unwrap();
        assert_eq!(order, Order { id: 1, customer: "An".into(), amount: 150000 });
    }

    #[tokio::test]
    async fn empty_customer_is_rejected() {
        let response = app(AppState::default())
            .oneshot(post_order(r#"{"customer":"  ","amount":1}"#))
            .await
            .unwrap();
        assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
    }

    #[tokio::test]
    async fn unknown_order_returns_404() {
        let response = app(AppState::default())
            .oneshot(Request::get("/orders/42").body(Body::empty()).unwrap())
            .await
            .unwrap();
        assert_eq!(response.status(), StatusCode::NOT_FOUND);
    }
}

이 테스트의 세 가지 주의점:

  • oneshot은 router를 소비합니다. 같은 state에 여러 request를 보내려면 각 호출 전에 router를 clone() 해야 합니다. state는 여전히 Arc에 있어서 공유됩니다.
  • BodyExt::collect는 전체 본문을 Bytes로 모은 후 serde_json이 다시 Order로 파싱합니다.
  • 각 테스트는 새로운 AppState를 만들어서 서로 독립적이며 병렬로 실행할 수 있습니다.

테스트를 실행합시다:

cargo test
running 3 tests
test tests::unknown_order_returns_404 ... ok
test tests::empty_customer_is_rejected ... ok
test tests::create_then_get_order ... ok

test result: ok. 3 passed; 0 failed

9. 일반적인 에러들

Content-Type 헤더 누락. Json extractor는 application/json 헤더를 요구합니다. 이 헤더가 없으면 본문이 완전히 유효해도 클라이언트는 415 Unsupported Media Type을 받습니다. 이것은 curl로 테스트할 때 가장 흔한 에러입니다.

Json 에러 코드 구분. JSON 구문이 잘못되면 400을 받습니다. JSON 구문은 맞지만 필드가 없거나 타입이 잘못되면 422를 받습니다. 기본적으로 이러한 응답들의 본문은 일반 텍스트이지 JSON이 아닙니다. API가 통일된 에러 형식이 필요하면 Json을 별도 extractor로 감싸고 JsonRejection을 AppError로 변환하세요.

프로덕션에서 lock에 unwrap() 사용. thread가 lock을 잡은 상태에서 panic하면 lock이 “poisoned” 되고, 이후 모든 unwrap() 호출도 panic 합니다. 실제 코드에서는 이 경우를 처리하거나 실제 데이터베이스를 사용하세요.

거대한 AppState에 모든 것을 집어넣기. 애플리케이션이 성장하면 FromRef를 사용해서 handler가 필요한 state의 일부만 가져오도록 할 수 있습니다. 예를 들어, 전체 AppState 대신 State<PgPool>을 사용할 수 있습니다.

Axum을 언제 사용하지 말아야 할까요? ORM, 템플릿, admin, migration이 기본으로 포함된 “배터리 포함” 프레임워크가 필요하면 Axum은 많은 것들을 직접 조립해야 합니다. 팀이 async Rust에 익숙하지 않으면 handler의 trait bound 에러 메시지도 초기 단계에서 큰 장애물이 될 것입니다.

10. 결론

Axum 0.8로 완전한 REST API CRUD를 구축했습니다. 주요 개념은:

  • Router: 경로와 메서드를 handler와 연결합니다.
  • Extractor: State, Path, Query, Json이 request에서 타입 안전하게 데이터를 추출합니다.
  • 공유 state: 공유 데이터는 Arc로 감싸집니다.
  • 에러 처리: IntoResponse를 구현해서 구조화된 JSON 에러를 반환합니다.
  • 테스트: tower::ServiceExt::oneshot을 사용해 request를 router로 직접 보냅니다.

합리적인 다음 단계는 HashMap을 PostgreSQL에 연결하는 sqlx로 바꾸는 것입니다. 또한 tower-http의 middleware를 추가해야 합니다. request를 로깅하는 TraceLayer나 CORS를 설정하는 CorsLayer 같은 것들입니다. 대부분의 handler는 변경이 필요 없습니다. HTTP 로직이 이미 저장소 계층과 분리되어 있기 때문입니다.

rustaxumrest-apitokiobackend