Axum의 Tower 기반 아키텍처, 라우팅, 핸들러, 추출자, 응답 타입, 미들웨어까지 Hello World에서 CRUD API까지 실습합니다.
Axum은 Tokio 팀이 개발하는 Rust 웹 프레임워크입니다. 매크로에 의존하지 않는 깔끔한 API, Tower 미들웨어 생태계와의 완벽한 호환, 타입 안전한 추출자 패턴이 특징입니다.
cargo new rust-api && cd rust-api[package]
name = "rust-api"
version = "0.1.0"
edition = "2024"
[dependencies]
axum = "0.8"
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tower-http = { version = "0.6", features = ["cors", "trace"] }
tracing = "0.1"
tracing-subscriber = "0.3"use axum::{Router, routing::get};
#[tokio::main]
async fn main() {
// 라우터 구성
let app = Router::new()
.route("/", get(hello));
// 서버 시작
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000")
.await
.unwrap();
println!("서버 시작: http://localhost:3000");
axum::serve(listener, app).await.unwrap();
}
// 핸들러: 문자열을 반환하면 자동으로 200 OK + text/plain
async fn hello() -> &'static str {
"Hello, Rust Backend!"
}이 몇 줄의 코드에 Axum의 핵심 개념이 모두 담겨 있습니다. Router가 경로를 정의하고, 핸들러(Handler)가 요청을 처리하며, 반환값이 자동으로 HTTP 응답으로 변환됩니다.
Axum의 핸들러는 비동기 함수입니다. 매개변수로 추출자(Extractor)를 받고, IntoResponse를 구현하는 값을 반환합니다.
use axum::{Json, http::StatusCode};
use serde_json::{json, Value};
// 매개변수 없는 핸들러
async fn health_check() -> StatusCode {
StatusCode::OK
}
// JSON 응답 반환
async fn api_info() -> Json<Value> {
Json(json!({
"name": "Rust API",
"version": "0.1.0"
}))
}
// 상태 코드 + JSON 조합
async fn not_found() -> (StatusCode, Json<Value>) {
(
StatusCode::NOT_FOUND,
Json(json!({ "error": "리소스를 찾을 수 없습니다" })),
)
}핸들러의 규칙은 간단합니다.
async fn이어야 합니다추출자는 HTTP 요청에서 데이터를 타입 안전하게 추출하는 메커니즘입니다. Axum의 가장 강력한 특징 중 하나입니다.
URL 경로 매개변수를 추출합니다.
use axum::extract::Path;
// /users/42 → id = 42
async fn get_user(Path(id): Path<u64>) -> String {
format!("사용자 ID: {}", id)
}
// /users/42/posts/7 → 여러 매개변수
async fn get_user_post(
Path((user_id, post_id)): Path<(u64, u64)>,
) -> String {
format!("사용자 {} 의 게시물 {}", user_id, post_id)
}URL 쿼리 파라미터를 구조체로 역직렬화합니다.
use axum::extract::Query;
use serde::Deserialize;
#[derive(Deserialize)]
struct Pagination {
page: Option<u32>,
per_page: Option<u32>,
}
// /users?page=2&per_page=20
async fn list_users(Query(params): Query<Pagination>) -> String {
let page = params.page.unwrap_or(1);
let per_page = params.per_page.unwrap_or(10);
format!("페이지 {}, {} 개씩", page, per_page)
}요청 본문을 JSON으로 역직렬화합니다.
use axum::Json;
use serde::{Deserialize, Serialize};
#[derive(Deserialize)]
struct CreateUser {
name: String,
email: String,
}
#[derive(Serialize)]
struct UserResponse {
id: u64,
name: String,
email: String,
}
async fn create_user(Json(payload): Json<CreateUser>) -> Json<UserResponse> {
// payload의 소유권이 이 핸들러로 이동
let response = UserResponse {
id: 1,
name: payload.name,
email: payload.email,
};
Json(response)
}애플리케이션 상태를 핸들러에 주입합니다.
use axum::extract::State;
use std::sync::Arc;
struct AppState {
db_url: String,
}
async fn get_status(State(state): State<Arc<AppState>>) -> String {
format!("DB 연결: {}", state.db_url)
}
// main에서 상태 등록
let state = Arc::new(AppState {
db_url: "postgresql://localhost/mydb".to_string(),
});
let app = Router::new()
.route("/status", get(get_status))
.with_state(state);State에는 Arc로 감싼 값을 사용하는 것이 관례입니다. 각 핸들러가 상태의 참조를 공유해야 하기 때문입니다. Arc는 참조 카운팅으로 소유권을 공유하는 스마트 포인터입니다.
use axum::{Router, routing::{get, post, put, delete}};
fn create_router(state: Arc<AppState>) -> Router {
Router::new()
// 기본 라우트
.route("/", get(root))
.route("/health", get(health_check))
// RESTful 리소스
.route("/users", get(list_users).post(create_user))
.route("/users/{id}", get(get_user).put(update_user).delete(delete_user))
// 중첩 라우터
.nest("/api/v1", api_v1_routes())
// 상태 주입
.with_state(state)
}
fn api_v1_routes() -> Router<Arc<AppState>> {
Router::new()
.route("/posts", get(list_posts).post(create_post))
.route("/posts/{id}", get(get_post))
}지금까지 배운 내용을 종합하여 인메모리 CRUD API를 구축합니다.
use axum::{
Json, Router,
extract::{Path, State},
http::StatusCode,
routing::{get, post, put, delete},
};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::RwLock;
// --- 모델 ---
#[derive(Debug, Clone, Serialize)]
struct User {
id: u64,
name: String,
email: String,
}
#[derive(Debug, Deserialize)]
struct CreateUserRequest {
name: String,
email: String,
}
#[derive(Debug, Deserialize)]
struct UpdateUserRequest {
name: Option<String>,
email: Option<String>,
}
// --- 상태 ---
type UserStore = Arc<RwLock<HashMap<u64, User>>>;
struct AppState {
users: UserStore,
next_id: Arc<tokio::sync::Mutex<u64>>,
}
// --- 핸들러 ---
async fn list_users(
State(state): State<Arc<AppState>>,
) -> Json<Vec<User>> {
let users = state.users.read().await;
let user_list: Vec<User> = users.values().cloned().collect();
Json(user_list)
}
async fn get_user(
State(state): State<Arc<AppState>>,
Path(id): Path<u64>,
) -> Result<Json<User>, StatusCode> {
let users = state.users.read().await;
users
.get(&id)
.cloned()
.map(Json)
.ok_or(StatusCode::NOT_FOUND)
}
async fn create_user(
State(state): State<Arc<AppState>>,
Json(payload): Json<CreateUserRequest>,
) -> (StatusCode, Json<User>) {
let mut next_id = state.next_id.lock().await;
let id = *next_id;
*next_id += 1;
let user = User {
id,
name: payload.name,
email: payload.email,
};
state.users.write().await.insert(id, user.clone());
(StatusCode::CREATED, Json(user))
}
async fn update_user(
State(state): State<Arc<AppState>>,
Path(id): Path<u64>,
Json(payload): Json<UpdateUserRequest>,
) -> Result<Json<User>, StatusCode> {
let mut users = state.users.write().await;
let user = users.get_mut(&id).ok_or(StatusCode::NOT_FOUND)?;
if let Some(name) = payload.name {
user.name = name;
}
if let Some(email) = payload.email {
user.email = email;
}
Ok(Json(user.clone()))
}
async fn delete_user(
State(state): State<Arc<AppState>>,
Path(id): Path<u64>,
) -> StatusCode {
let mut users = state.users.write().await;
if users.remove(&id).is_some() {
StatusCode::NO_CONTENT
} else {
StatusCode::NOT_FOUND
}
}
// --- 메인 ---
#[tokio::main]
async fn main() {
tracing_subscriber::init();
let state = Arc::new(AppState {
users: Arc::new(RwLock::new(HashMap::new())),
next_id: Arc::new(tokio::sync::Mutex::new(1)),
});
let app = Router::new()
.route("/users", get(list_users).post(create_user))
.route("/users/{id}", get(get_user).put(update_user).delete(delete_user))
.with_state(state);
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000")
.await
.unwrap();
println!("CRUD API 서버 시작: http://localhost:3000");
axum::serve(listener, app).await.unwrap();
}# 사용자 생성
curl -X POST http://localhost:3000/users \
-H "Content-Type: application/json" \
-d '{"name": "Alice", "email": "alice@example.com"}'
# 사용자 목록 조회
curl http://localhost:3000/users
# 특정 사용자 조회
curl http://localhost:3000/users/1
# 사용자 수정
curl -X PUT http://localhost:3000/users/1 \
-H "Content-Type: application/json" \
-d '{"name": "Alice Kim"}'
# 사용자 삭제
curl -X DELETE http://localhost:3000/users/1Axum은 Tower 미들웨어 생태계를 그대로 활용합니다.
use tower_http::cors::{CorsLayer, Any};
use tower_http::trace::TraceLayer;
let app = Router::new()
.route("/users", get(list_users).post(create_user))
.with_state(state)
// 로깅 미들웨어
.layer(TraceLayer::new_for_http())
// CORS 미들웨어
.layer(
CorsLayer::new()
.allow_origin(Any)
.allow_methods(Any)
.allow_headers(Any),
);미들웨어의 적용 순서에 주의하세요. layer()는 가장 마지막에 추가한 것이 가장 바깥쪽(먼저 실행)에 위치합니다. 위 예제에서 요청은 CORS → Trace → Handler 순서로 처리됩니다.
이 장에서는 Axum 웹 프레임워크의 기초를 다루었습니다.
다음 장에서는 Axum 고급 패턴을 다룹니다. 중첩 라우터, 인증/인가 미들웨어, 요청 검증, 웹소켓, 테스트 등 프로덕션 수준의 패턴을 익힙니다.
이 글이 도움이 되셨나요?
중첩 라우터, 인증/인가 미들웨어, 요청 검증, 웹소켓, SSE, API 테스트까지 프로덕션 수준의 Axum 패턴을 다룹니다.
Rust의 비동기 프로그래밍 기초부터 Future 트레이트, Tokio 런타임, spawn/select/join, 채널 기반 동시성 패턴까지 체계적으로 다룹니다.
SQLx의 컴파일 타임 쿼리 검증, 연결 풀, CRUD 구현, 마이그레이션, 트랜잭션까지 Rust 백엔드의 데이터베이스 연동 패턴을 다룹니다.