- Authors

- Name
- Youngju Kim
- @fjvbn20031
- 들어가며 — 왜 Rust로 웹 API인가
- Axum 첫 걸음 — 핸들러와 라우팅
- 추출기 — 요청에서 원하는 것 뽑아내기
- 공유 상태 — 데이터베이스 풀과 설정 나누기
- tower 미들웨어 — 공통 관심사 얹기
- serde로 JSON 다루기 — 직렬화와 역직렬화
- sqlx로 데이터베이스 연동 — 컴파일 타임에 검증되는 쿼리
- 에러 처리 — Result를 응답으로
- Actix Web과의 비교 — 무엇을 고를까
- 마치며
- 참고 자료
들어가며 — 왜 Rust로 웹 API인가
한때 웹 백엔드는 Rust와 거리가 멀어 보였습니다. 소유권과 수명 같은 개념이 요청-응답 코드의 진입 장벽처럼 느껴졌기 때문입니다. 하지만 이제 상황이 달라졌습니다. 앞선 글에서 본 async/await와 Tokio 위에 성숙한 웹 프레임워크들이 올라오면서, Rust로 웹 API를 만드는 경험이 놀랄 만큼 깔끔해졌습니다.
Rust 웹 백엔드의 매력은 분명합니다. 컴파일 타임에 잡히는 방대한 버그, 가비지 컬렉터 없이도 나오는 예측 가능한 저지연, 그리고 타입 시스템이 강제하는 견고함입니다. JSON 필드 이름 하나 오타 내면 컴파일이 거부하고, Option 이 널 처리를 강제합니다.
이 글은 현재 가장 인기 있는 프레임워크 Axum을 중심으로, 라우팅부터 데이터베이스 연동, 에러 처리까지 실전 흐름을 짚습니다. 마지막에는 또 다른 대표 주자인 Actix Web과 비교합니다. Axum은 Tokio 팀이 만들었고 tower 생태계와 매끄럽게 맞물린다는 점에서 좋은 출발점입니다.
Axum 첫 걸음 — 핸들러와 라우팅
Axum에서 가장 기본이 되는 두 개념은 **핸들러(handler)**와 **라우터(router)**입니다. 핸들러는 요청을 받아 응답을 돌려주는 async 함수이고, 라우터는 어떤 경로와 메서드가 어떤 핸들러로 가는지를 정의합니다.
use axum::{routing::get, Router};
#[tokio::main]
async fn main() {
// 라우터: 경로와 핸들러를 연결
let app = Router::new()
.route("/", get(root))
.route("/health", get(health));
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000")
.await
.unwrap();
axum::serve(listener, app).await.unwrap();
}
// 핸들러: 그냥 async 함수다
async fn root() -> &'static str {
"Hello, Axum!"
}
async fn health() -> &'static str {
"OK"
}
핸들러가 그냥 async 함수라는 점이 Axum의 큰 장점입니다. 매크로로 감싸거나 특별한 트레이트를 구현할 필요 없이, 인자로 원하는 것을 받고 응답 가능한 값을 반환하면 됩니다. 반환 타입이 &'static str 인데도 자동으로 HTTP 응답이 되는 것은, Axum이 IntoResponse 라는 트레이트로 다양한 타입을 응답으로 변환해 주기 때문입니다. 문자열, 상태 코드, JSON, 튜플 등 많은 것이 그대로 응답이 됩니다.
추출기 — 요청에서 원하는 것 뽑아내기
Axum의 진짜 우아함은 **추출기(extractor)**에 있습니다. 핸들러의 인자에 특정 타입을 적으면, Axum이 요청에서 그 부분을 알아서 뽑아 넘겨줍니다. 경로 파라미터, 쿼리 문자열, JSON 본문, 헤더 등 대부분의 요청 요소에 추출기가 있습니다.
use axum::{
extract::{Path, Query, Json},
routing::{get, post},
Router,
};
use serde::Deserialize;
#[derive(Deserialize)]
struct Pagination {
page: u32,
per_page: u32,
}
#[derive(Deserialize)]
struct CreateUser {
name: String,
email: String,
}
// 경로 파라미터 추출: /users/42 -> id = 42
async fn get_user(Path(id): Path<u64>) -> String {
format!("user {id}")
}
// 쿼리 문자열 추출: /users?page=2&per_page=20
async fn list_users(Query(p): Query<Pagination>) -> String {
format!("page {} ({} per page)", p.page, p.per_page)
}
// JSON 본문 추출
async fn create_user(Json(payload): Json<CreateUser>) -> String {
format!("created {} <{}>", payload.name, payload.email)
}
fn app() -> Router {
Router::new()
.route("/users/{id}", get(get_user))
.route("/users", get(list_users).post(create_user))
}
추출기의 순서에도 규칙이 있습니다. 요청 본문을 소비하는 추출기(예: Json)는 본문을 통째로 읽어 버리므로 하나만, 그리고 인자 목록의 마지막에 와야 합니다. 헤더나 경로처럼 본문을 소비하지 않는 추출기는 그 앞에 여럿 올 수 있습니다. 추출에 실패하면(예: JSON 파싱 실패) Axum이 자동으로 적절한 4xx 응답을 돌려주므로, 핸들러 본문은 성공 경로에만 집중할 수 있습니다.
공유 상태 — 데이터베이스 풀과 설정 나누기
실제 애플리케이션의 핸들러는 데이터베이스 연결 풀이나 설정 값 같은 공유 자원에 접근해야 합니다. Axum은 이를 **상태(state)**로 다루며, State 추출기로 핸들러에 주입합니다.
use axum::{extract::State, routing::get, Router};
use std::sync::Arc;
// 애플리케이션 전역에서 공유할 상태
#[derive(Clone)]
struct AppState {
// 실제로는 여기에 sqlx 풀, 설정 등이 들어간다
app_name: Arc<String>,
}
async fn handler(State(state): State<AppState>) -> String {
format!("running: {}", state.app_name)
}
#[tokio::main]
async fn main() {
let state = AppState {
app_name: Arc::new("my-service".to_string()),
};
let app = Router::new()
.route("/", get(handler))
.with_state(state); // 라우터에 상태를 붙인다
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000")
.await
.unwrap();
axum::serve(listener, app).await.unwrap();
}
AppState 에 Clone 을 요구하는 이유는, 요청마다 상태의 값 복제가 필요하기 때문입니다. 그래서 데이터베이스 풀처럼 실제 자원은 Arc 로 감싸 값싸게 복제되도록 하는 것이 관례입니다. 앞선 스마트 포인터 글에서 본 Arc 가 여기서 자연스럽게 쓰입니다. 풀 자체는 하나만 존재하고, 각 요청은 그 Arc 의 클론을 공유합니다.
tower 미들웨어 — 공통 관심사 얹기
로깅, 인증, 타임아웃, 압축, CORS처럼 여러 라우트에 공통으로 적용할 로직은 미들웨어로 다룹니다. Axum은 별도 미들웨어 시스템을 새로 만들지 않고, Rust 생태계의 표준 서비스 추상화인 tower를 그대로 씁니다. 덕분에 tower 와 tower-http 가 제공하는 방대한 미들웨어를 바로 얹을 수 있습니다.
use axum::{routing::get, Router};
use tower_http::trace::TraceLayer;
use tower_http::timeout::TimeoutLayer;
use std::time::Duration;
fn app() -> Router {
Router::new()
.route("/", get(|| async { "Hello" }))
// 미들웨어는 layer로 얹는다
.layer(TraceLayer::new_for_http()) // 요청 로깅/추적
.layer(TimeoutLayer::new(Duration::from_secs(10))) // 요청 타임아웃
}
미들웨어를 layer 로 얹으면 그 아래 모든 라우트에 적용됩니다. 여러 layer를 쌓으면 바깥에서 안쪽으로 요청이 통과하고, 응답은 반대로 빠져나옵니다. tower를 공유하는 이 설계 덕분에, gRPC 프레임워크 tonic 등 tower 기반의 다른 도구들과 미들웨어를 재사용할 수 있다는 점이 Axum의 큰 강점입니다.
serde로 JSON 다루기 — 직렬화와 역직렬화
웹 API의 핵심은 JSON을 주고받는 일입니다. Rust에서 이것은 사실상 serde 크레이트로 처리합니다. 구조체에 Serialize/Deserialize 를 파생시키면, 그 타입과 JSON 사이의 변환을 컴파일러가 생성해 줍니다.
use axum::Json;
use serde::{Deserialize, Serialize};
// 요청 본문을 받을 타입
#[derive(Deserialize)]
struct CreateTodo {
title: String,
#[serde(default)] // 없으면 false
done: bool,
}
// 응답으로 내보낼 타입
#[derive(Serialize)]
struct Todo {
id: u64,
title: String,
done: bool,
}
async fn create_todo(Json(input): Json<CreateTodo>) -> Json<Todo> {
let todo = Todo {
id: 1,
title: input.title,
done: input.done,
};
Json(todo) // Json으로 감싸면 자동으로 JSON 응답이 된다
}
Json 추출기로 요청 본문을 타입으로 받고, 반환 시 Json 으로 감싸면 응답이 JSON으로 나갑니다. serde의 속성으로 세부를 제어할 수도 있습니다. #[serde(rename_all = "camelCase")] 로 필드 이름 규칙을 바꾸거나, #[serde(default)] 로 누락된 필드에 기본값을 주거나, Option<T> 필드로 있어도 되고 없어도 되는 값을 표현합니다. 이 모든 것이 타입에 선언적으로 붙으므로, JSON 계약이 코드에 그대로 드러납니다.
sqlx로 데이터베이스 연동 — 컴파일 타임에 검증되는 쿼리
데이터를 저장하고 조회하려면 데이터베이스가 필요합니다. Rust 비동기 생태계에서 널리 쓰이는 것이 sqlx입니다. sqlx의 독특한 점은, ORM이 아니라 날 SQL을 쓰되 그 쿼리를 컴파일 타임에 실제 데이터베이스 스키마와 대조해 검증한다는 것입니다. 오타 난 컬럼명이나 타입 불일치가 런타임이 아니라 컴파일 때 잡힙니다.
use sqlx::postgres::PgPoolOptions;
use sqlx::PgPool;
#[derive(sqlx::FromRow)]
struct User {
id: i64,
name: String,
email: String,
}
async fn setup_pool() -> PgPool {
PgPoolOptions::new()
.max_connections(5)
.connect("postgres://localhost/mydb")
.await
.unwrap()
}
async fn find_user(pool: &PgPool, id: i64) -> Result<Option<User>, sqlx::Error> {
// query_as로 결과를 구조체에 매핑한다
let user = sqlx::query_as::<_, User>(
"SELECT id, name, email FROM users WHERE id = $1",
)
.bind(id)
.fetch_optional(pool)
.await?;
Ok(user)
}
sqlx는 연결 풀을 제공하며, 이 풀을 앞서 본 State 로 애플리케이션에 공유합니다. 파라미터는 달러 기호에 번호를 붙인 자리표시자(첫 번째 파라미터, 두 번째 파라미터 식)와 bind 로 넘겨 SQL 인젝션을 원천 차단합니다. 결과 행은 FromRow 를 파생시킨 구조체로 매핑됩니다. fetch_optional 은 결과가 없을 수 있음을 Option 으로 표현하고, 여러 행이면 fetch_all, 정확히 한 행을 기대하면 fetch_one 을 씁니다. 더 나아가 query! 매크로를 쓰면 컴파일 타임에 실제 DB에 접속해 쿼리 자체를 검증하는 강력한 모드도 있습니다.
에러 처리 — Result를 응답으로
지금까지의 예제는 대부분 성공 경로만 다뤘지만, 실제 API는 실패를 우아하게 다뤄야 합니다. Rust의 관용구는 핸들러가 Result 를 반환하고, 에러 타입이 스스로 HTTP 응답으로 변환되게 하는 것입니다. IntoResponse 를 에러 타입에 구현하면 됩니다.
use axum::{
extract::{Path, State},
http::StatusCode,
response::{IntoResponse, Response},
Json,
};
use serde_json::json;
// 애플리케이션 전용 에러 타입
enum AppError {
NotFound,
Database(sqlx::Error),
}
// 에러를 HTTP 응답으로 변환하는 방법을 정의
impl IntoResponse for AppError {
fn into_response(self) -> Response {
let (status, message) = match self {
AppError::NotFound => (StatusCode::NOT_FOUND, "not found"),
AppError::Database(_) => {
(StatusCode::INTERNAL_SERVER_ERROR, "internal error")
}
};
(status, Json(json!({ "error": message }))).into_response()
}
}
// sqlx 에러를 AppError로 자동 변환 (? 연산자용)
impl From<sqlx::Error> for AppError {
fn from(e: sqlx::Error) -> Self {
AppError::Database(e)
}
}
async fn get_user(
State(pool): State<sqlx::PgPool>,
Path(id): Path<i64>,
) -> Result<Json<String>, AppError> {
let user = find_user(&pool, id).await?; // sqlx::Error가 ?로 AppError가 된다
match user {
Some(_) => Ok(Json(format!("user {id}"))),
None => Err(AppError::NotFound), // 404로 변환된다
}
}
이 패턴의 아름다움은 ? 연산자와의 조합에 있습니다. From<sqlx::Error> 를 구현해 두면, 데이터베이스 호출 뒤에 ? 만 붙여도 실패가 자동으로 AppError 로 바뀌고, 그것이 다시 적절한 HTTP 상태 코드로 변환됩니다. 핸들러 본문은 성공 흐름을 자연스럽게 쓰고, 에러는 조용히 위로 전파되어 일관된 형태의 응답이 됩니다. 실무에서는 이 부분을 thiserror 같은 크레이트로 더 간결하게 다듬습니다.
Actix Web과의 비교 — 무엇을 고를까
Axum 외에 또 하나의 대표 프레임워크가 Actix Web입니다. 오래 전부터 성숙했고, 각종 벤치마크에서 최상위권 성능으로 이름을 알려 왔습니다. 두 프레임워크는 실제로 상당히 닮았습니다. 둘 다 async/await 위에서 돌고, 추출기 스타일의 핸들러를 쓰며, serde로 JSON을 다룹니다.
핵심적인 차이는 다음과 같습니다.
- 미들웨어 생태계: Axum은 tower를 그대로 채택해, tower/tower-http의 미들웨어와 tonic 같은 다른 tower 기반 도구를 공유합니다. Actix는 자체 미들웨어 시스템을 가집니다.
- 런타임: Axum은 Tokio에 직결됩니다. Actix는 자체 액터 런타임(actix-rt, 내부적으로 Tokio 기반) 위에서 동작해 온 역사가 있습니다.
- 설계 철학: Actix는 이름대로 액터 모델에서 출발했고, 지금도 상태 관리에 그 색채가 남아 있습니다. Axum은 함수와 tower 서비스라는 더 얇은 추상화를 지향합니다.
- 팀과 통합: Axum은 Tokio 팀이 관리하므로 Tokio·hyper·tower와의 통합이 매끄럽습니다.
간단한 비교를 위해 Actix의 핸들러도 한 번 보겠습니다. 모양이 Axum과 크게 다르지 않다는 걸 알 수 있습니다.
use actix_web::{get, web, App, HttpServer, Responder};
#[get("/users/{id}")]
async fn get_user(path: web::Path<u64>) -> impl Responder {
let id = path.into_inner();
format!("user {id}")
}
#[actix_web::main]
async fn main() -> std::io::Result<()> {
HttpServer::new(|| App::new().service(get_user))
.bind(("0.0.0.0", 3000))?
.run()
.await
}
선택 기준을 요약하면 이렇습니다. tower 생태계와의 통합, Tokio와의 밀접함, 얇고 예측 가능한 추상화를 중시한다면 Axum이 요즘의 자연스러운 기본값입니다. 오래 검증된 성숙함과 풍부한 기능, 그리고 최고 수준의 벤치마크 성능을 원한다면 Actix Web도 여전히 훌륭한 선택입니다. 다만 둘의 실질적 성능 차이는 대부분의 애플리케이션에서 무시할 만하므로, 생태계 적합성과 팀의 취향으로 고르는 편이 현실적입니다.
마치며
Rust로 웹 API를 만드는 일은 이제 특별한 고행이 아니라, 오히려 즐거운 경험에 가깝습니다. 이 글에서 짚은 흐름을 정리하면 다음과 같습니다.
- Axum의 핸들러는 그냥
async함수이고, 추출기로 요청에서 원하는 것을 선언적으로 뽑습니다. - 데이터베이스 풀 같은 공유 자원은
Arc로 감싸 **상태(State)**로 주입합니다. - 로깅·타임아웃 같은 공통 관심사는 tower 미들웨어로 얹습니다.
- serde로 JSON을, sqlx로 컴파일 타임에 검증되는 SQL을 다룹니다.
- 에러는
IntoResponse를 구현한 타입 +?연산자로 우아하게 응답으로 변환합니다. - Actix Web은 성숙하고 강력한 대안이며, 선택은 성능보다 생태계 적합성으로 갈리는 경우가 많습니다.
타입 시스템이 계약을 강제하고, 컴파일러가 방대한 버그를 미리 잡아 주는 이 환경에서, 여러분의 API는 처음부터 견고합니다. Rust가 주는 안전성과 성능은 시스템 프로그래밍만의 이야기가 아니라, 웹 백엔드에서도 그대로 유효합니다.
참고 자료
- Axum 공식 문서 (docs.rs): https://docs.rs/axum/latest/axum/
- Axum 예제 모음 (GitHub): https://github.com/tokio-rs/axum/tree/main/examples
- tower / tower-http: https://docs.rs/tower-http/latest/tower_http/
- serde 공식 사이트: https://serde.rs/
- sqlx (GitHub): https://github.com/launchbadge/sqlx
- Actix Web 공식 사이트: https://actix.rs/