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 백엔드의 데이터베이스 연동 패턴을 다룹니다.