TechFeedTechFeed
Programming Languages

Axum 실전 튜토리얼 — Rust 비동기 웹 서버, JWT 인증, PostgreSQL 연동, Docker 배포

Axum 0.8 기반으로 Rust 비동기 API 서버를 처음부터 구축하는 완전 가이드. 타입 안전한 라우팅, sqlx PostgreSQL 연동, JWT 커스텀 Extractor, 멀티스테이지 Docker 빌드까지 실제 코드로 다룬다.

한 줄 요약: Axum은 Tokio 팀이 만든 Rust 비동기 웹 프레임워크로, 타입 안전한 라우팅과 Tower 미들웨어 생태계를 기반으로 프로덕션 수준의 API 서버를 빠르게 구성할 수 있다. 이 가이드는 프로젝트 셋업부터 PostgreSQL 연동, JWT 인증, Docker 배포까지 실제 코드로 전 과정을 다룬다.

이 글이 필요한 사람
  • Rust 기초는 알지만 웹 서버 구현 경험이 없는 개발자
  • Actix-web 대신 더 인체공학적인 Rust 프레임워크를 찾는 팀
  • Go나 Python FastAPI 서버를 Rust로 전환 검토 중인 백엔드 개발자
  • 타입 안전한 API 서버 아키텍처를 처음부터 설계하고 싶은 개발자

※ 2026년 4월 기준, Axum 0.8 / sqlx 0.8 / Rust 1.78+ 기준으로 작성

Axum이란? Rust 웹 프레임워크 생태계 현황

Axum은 Tokio 팀이 2021년 오픈소스로 공개한 Rust 비동기 웹 프레임워크다. 2026년 현재 GitHub 스타 20만 개를 넘으며 Rust 웹 프레임워크의 사실상 표준 위치를 차지하고 있다.

Rust 웹 개발을 시작하면 반드시 마주치는 세 가지 선택지를 정리하면 다음과 같다.

  • Actix-web — TechEmpower 벤치마크에서 꾸준히 상위권. 액터 모델 기반이라 고성능을 낼 수 있지만, 오래된 설계와 독자적인 액터 패러다임 때문에 온보딩 비용이 높다.
  • Axum — Tokio 공식 지원. Tower 서비스 추상화 위에서 동작하며, 매크로 없이 타입 시스템만으로 라우팅과 미들웨어를 처리한다. Tokio 생태계와 완벽하게 통합된다.
  • Warp — Filter 조합 방식의 독창적 API. 소규모 프로젝트에서는 간결하지만, 복잡한 라우팅이 더해질수록 컴파일 에러 메시지가 이해하기 어려워진다.

2026년 기준으로 새 Rust 백엔드를 시작한다면 Axum이 기본 선택지다. Tower 미들웨어 생태계를 공유하기 때문에 CORS, 레이트 리미팅, 압축, 트레이싱 등 대부분의 공통 기능을 tower-http 한 크레이트로 해결할 수 있다.

Axum 웹 프레임워크 아키텍처 — Tokio와 Tower 스택
Axum은 Tokio 비동기 런타임과 Tower 서비스 추상화 위에서 동작한다

프로젝트 초기 설정 — Cargo.toml과 핵심 의존성

새 Axum 프로젝트를 시작하기 전에 Rust toolchain이 설치되어 있는지 확인한다. rustup update stable로 최신 안정 버전을 맞춘 다음 cargo new axum-api로 프로젝트를 생성한다.

이 튜토리얼에서 사용하는 핵심 크레이트와 역할을 정리했다.

  • axum 0.8 — 라우팅, 핸들러, extractor(요청 데이터 추출)
  • tokio — 비동기 런타임. full feature를 활성화한다.
  • sqlx 0.8 — PostgreSQL 비동기 쿼리. 컴파일 타임 SQL 검증 지원.
  • serde + serde_json — JSON 직렬화/역직렬화.
  • jsonwebtoken — JWT 토큰 생성과 검증.
  • tower-http — CORS, 로깅, 요청 추적 미들웨어.
  • thiserror — 커스텀 에러 타입 선언 매크로.
  • dotenvy.env 파일에서 환경변수 로드.
  • tracing + tracing-subscriber — 구조화 로그와 요청 추적.
Cargo.toml
[package] name = "axum-api" version = "0.1.0" edition = "2021" [dependencies] axum = "0.8" tokio = { version = "1", features = ["full"] } sqlx = { version = "0.8", features = ["postgres", "runtime-tokio", "uuid", "chrono"] } serde = { version = "1", features = ["derive"] } serde_json = "1" jsonwebtoken = "9" tower-http = { version = "0.6", features = ["cors", "trace", "catch-panic"] } thiserror = "2" dotenvy = "0.15" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } uuid = { version = "1", features = ["v4"] } bcrypt = "0.17" chrono = { version = "0.4", features = ["serde"] }

라우팅과 핸들러 — 타입 안전한 URL 처리

Axum의 가장 큰 장점은 매크로 없이 순수 Rust 타입으로 라우팅을 정의한다는 점이다. Router를 중첩하고 HTTP 메서드별로 핸들러를 연결하는 방식이 직관적이며, 컴파일 타임에 불일치를 잡아낸다.

기본 서버 구조는 세 파트로 나뉜다. 첫째는 공유 상태(AppState) 정의, 둘째는 라우터 조립, 셋째는 미들웨어 레이어 추가다. AppState에 DB 커넥션 풀을 넣어 모든 핸들러에 공유하는 패턴이 Axum 공식 권장 방식이다.

모듈 분리는 라우터를 반환하는 함수 단위로 한다. users::router(), posts::router() 같이 도메인별로 라우터를 나누고 Router::nest()로 합치면 경로 접두사도 함께 적용된다.

src/main.rs — 서버 진입점
use axum::{Router, routing::get}; use sqlx::PgPool; use sqlx::postgres::PgPoolOptions; use std::sync::Arc; use tower_http::{cors::CorsLayer, trace::TraceLayer, catch_panic::CatchPanicLayer}; mod users; mod auth; mod error; #[derive(Clone)] pub struct AppState { pub db: PgPool, } #[tokio::main] async fn main() { tracing_subscriber::fmt() .with_env_filter(std::env::var("RUST_LOG").unwrap_or_else(|_| "info".into())) .init(); dotenvy::dotenv().ok(); let database_url = std::env::var("DATABASE_URL") .expect("DATABASE_URL must be set"); let db = PgPoolOptions::new() .max_connections(20) .connect(&database_url) .await .expect("Failed to connect to database"); sqlx::migrate!("./migrations").run(&db).await.expect("Migration failed"); let state = Arc::new(AppState { db }); let app = Router::new() .route("/health", get(health_check)) .nest("/api/v1", api_router()) .layer(CorsLayer::permissive()) .layer(TraceLayer::new_for_http()) .layer(CatchPanicLayer::new()) .with_state(state); let listener = tokio::net::TcpListener::bind("0.0.0.0:8080").await.unwrap(); tracing::info!("Server listening on 0.0.0.0:8080"); axum::serve(listener, app) .with_graceful_shutdown(shutdown_signal()) .await .unwrap(); } async fn health_check() -> &'static str { "OK" } async fn shutdown_signal() { tokio::signal::ctrl_c().await.expect("Failed to listen for ctrl+c"); tracing::info!("Shutdown signal received"); } fn api_router() -> Router<Arc<AppState>> { Router::new() .merge(users::router()) .merge(auth::router()) }
Axum 0.8 State extractor 사용법
핸들러에서 공유 상태를 받으려면 State(state): State<Arc<AppState>>를 파라미터로 선언한다. 0.6 이전 버전의 Extension 패턴과 다르니, 구버전 예제를 따라 쓰지 않도록 주의한다.
Axum 라우팅 모듈 구조 다이어그램
Router::nest()로 도메인별 라우터를 조합하면 경로 접두사가 자동 적용된다

PostgreSQL 연동 — sqlx 비동기 쿼리와 컴파일 타임 검증

sqlx는 Rust에서 가장 많이 쓰이는 비동기 SQL 크레이트다. 핵심 기능은 컴파일 타임 쿼리 검증으로, 쿼리 문자열의 오타나 타입 불일치를 빌드 단계에서 잡아낸다. 이 기능을 활성화하려면 빌드 환경에 DB가 필요하거나, sqlx prepare 명령으로 생성한 캐시 파일(.sqlx/)을 커밋해야 한다.

마이그레이션은 migrations/ 디렉토리에 0001_create_users.sql 형식의 파일을 두고, 앱 시작 시 sqlx::migrate!()로 자동 적용한다. 스키마 변경 히스토리가 코드베이스에 함께 관리되는 구조다.

쿼리 결과를 Rust 구조체로 받으려면 #[derive(FromRow)]를 붙이고 sqlx::query_as()를 사용한다. 필드 이름이 DB 컬럼명과 일치해야 하며, #[sqlx(rename = "column_name")]으로 매핑을 조정할 수 있다.

src/users/mod.rs — CRUD 핸들러
use axum::{ extract::{Path, State}, http::StatusCode, Json, Router, routing::{get, post}, }; use serde::{Deserialize, Serialize}; use sqlx::FromRow; use std::sync::Arc; use uuid::Uuid; use crate::{AppState, error::AppError}; #[derive(Debug, Serialize, FromRow)] pub struct User { pub id: Uuid, pub email: String, pub name: String, } #[derive(Debug, Deserialize)] pub struct CreateUser { pub email: String, pub name: String, pub password: String, } pub fn router() -> Router<Arc<AppState>> { Router::new() .route("/users", get(list_users).post(create_user)) .route("/users/:id", get(get_user)) } async fn list_users( State(state): State<Arc<AppState>>, ) -> Result<Json<Vec<User>>, AppError> { let users = sqlx::query_as::<_, User>( "SELECT id, email, name FROM users ORDER BY created_at DESC" ) .fetch_all(&state.db) .await?; Ok(Json(users)) } async fn get_user( State(state): State<Arc<AppState>>, Path(id): Path<Uuid>, ) -> Result<Json<User>, AppError> { sqlx::query_as::<_, User>( "SELECT id, email, name FROM users WHERE id = $1" ) .bind(id) .fetch_optional(&state.db) .await? .map(Json) .ok_or(AppError::NotFound) } async fn create_user( State(state): State<Arc<AppState>>, Json(payload): Json<CreateUser>, ) -> Result<(StatusCode, Json<User>), AppError> { let hashed = bcrypt::hash(&payload.password, bcrypt::DEFAULT_COST) .map_err(|_| AppError::Validation("Password hashing failed".into()))?; let user = sqlx::query_as::<_, User>( "INSERT INTO users (id, email, name, password_hash) VALUES ($1, $2, $3, $4) RETURNING id, email, name" ) .bind(Uuid::new_v4()) .bind(&payload.email) .bind(&payload.name) .bind(&hashed) .fetch_one(&state.db) .await?; Ok((StatusCode::CREATED, Json(user))) }
DATABASE_URL과 sqlx offline 모드
로컬 개발 시 프로젝트 루트에 .env 파일을 만들고 DATABASE_URL=postgres://user:pass@localhost/dbname을 설정한다. CI에서 DB 없이 빌드하려면 cargo sqlx prepare.sqlx/ 캐시를 생성하고 커밋한 뒤 SQLX_OFFLINE=true로 빌드한다.

JWT 인증 — 커스텀 Extractor로 보호 라우트 구현

Axum에서 인증을 구현하는 가장 Axum다운 방법은 커스텀 extractor를 만드는 것이다. FromRequestParts 트레이트를 구현하면 핸들러 파라미터에 AuthUser 타입을 선언하는 것만으로 JWT 검증이 자동 실행된다.

이 방식의 장점은 타입 시스템이 인증 여부를 강제한다는 점이다. AuthUser가 파라미터에 있는 핸들러는 인증 없이 호출이 불가능하다. 선택적 인증이 필요하면 Option<AuthUser>로 선언하면 된다.

로그인 핸들러는 이메일과 패스워드를 받아 bcrypt로 검증 후 JWT를 발급한다. 토큰에는 사용자 ID(sub)와 만료 시각(exp)을 클레임으로 담는다.

src/auth/extractor.rs — JWT Extractor
use axum::{ async_trait, extract::FromRequestParts, http::{request::Parts, StatusCode}, }; use jsonwebtoken::{decode, DecodingKey, Validation, Algorithm, encode, EncodingKey, Header}; use serde::{Deserialize, Serialize}; use std::time::{SystemTime, UNIX_EPOCH}; #[derive(Debug, Serialize, Deserialize, Clone)] pub struct Claims { pub sub: String, pub exp: usize, } pub struct AuthUser(pub Claims); impl AuthUser { pub fn user_id(&self) -> &str { &self.0.sub } } #[async_trait] impl<S> FromRequestParts<S> for AuthUser where S: Send + Sync, { type Rejection = (StatusCode, &'static str); async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> { let token = parts .headers .get("Authorization") .and_then(|v| v.to_str().ok()) .and_then(|v| v.strip_prefix("Bearer ")) .ok_or((StatusCode::UNAUTHORIZED, "Missing or invalid Authorization header"))?; let secret = std::env::var("JWT_SECRET").unwrap_or_default(); let key = DecodingKey::from_secret(secret.as_bytes()); let claims = decode::<Claims>(token, &key, &Validation::new(Algorithm::HS256)) .map_err(|_| (StatusCode::UNAUTHORIZED, "Invalid or expired token"))? .claims; Ok(AuthUser(claims)) } } pub fn create_token(user_id: &str) -> Result<String, jsonwebtoken::errors::Error> { let exp = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap() .as_secs() as usize + 86400 * 7; // 7일 let claims = Claims { sub: user_id.to_string(), exp }; let secret = std::env::var("JWT_SECRET").unwrap_or_default(); encode(&Header::default(), &claims, &EncodingKey::from_secret(secret.as_bytes())) }
JWT 인증 흐름 — 로그인 토큰 발급과 보호 라우트 접근
커스텀 Extractor 패턴으로 인증 로직을 핸들러와 완전히 분리한다

에러 처리 — IntoResponse로 API 에러를 JSON으로 반환

Axum 핸들러에서 에러를 반환하려면 IntoResponse 트레이트를 구현한 타입을 리턴하면 된다. thiserror로 도메인 에러 열거형을 정의하고, 각 variant에 맞는 HTTP 상태코드와 JSON 바디를 반환하는 패턴이 실무 표준이다.

핸들러 반환 타입을 Result<Json<T>, AppError>로 통일하면 ? 연산자로 에러를 자연스럽게 전파할 수 있다. sqlx 에러는 #[from] 어트리뷰트로 자동 변환된다.

프로덕션에서는 AppError::Database의 내부 메시지를 클라이언트에 그대로 노출하지 않는다. 아래 예시처럼 variant에 따라 외부 메시지를 별도로 정의하는 방식을 권장한다.

src/error.rs — 통합 에러 타입
use axum::{ http::StatusCode, response::{IntoResponse, Response}, Json, }; use serde_json::json; use thiserror::Error; #[derive(Error, Debug)] pub enum AppError { #[error("Database error: {0}")] Database(#[from] sqlx::Error), #[error("Not found")] NotFound, #[error("Unauthorized")] Unauthorized, #[error("Validation failed: {0}")] Validation(String), #[error("Conflict: {0}")] Conflict(String), } impl IntoResponse for AppError { fn into_response(self) -> Response { let (status, message) = match &self { AppError::Database(e) => { // PostgreSQL unique violation if let Some(db_err) = e.as_database_error() { if db_err.code().as_deref() == Some("23505") { return (StatusCode::CONFLICT, Json(json!({ "error": "Already exists" }))).into_response(); } } tracing::error!("Database error: {:?}", e); (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error") } AppError::NotFound => (StatusCode::NOT_FOUND, "Not found"), AppError::Unauthorized => (StatusCode::UNAUTHORIZED, "Unauthorized"), AppError::Validation(msg) => (StatusCode::UNPROCESSABLE_ENTITY, msg.as_str()), AppError::Conflict(msg) => (StatusCode::CONFLICT, msg.as_str()), }; (status, Json(json!({ "error": message }))).into_response() } }
JSON 검증 에러 처리
Axum의 Json extractor가 역직렬화에 실패하면 기본적으로 422 응답을 반환한다. 에러 메시지를 커스터마이징하려면 JsonRejection을 처리하는 커스텀 extractor 래퍼를 만들어야 한다. 대부분의 경우 기본 동작으로 충분하다.

Docker 배포 — 멀티스테이지 빌드로 이미지 최소화

Rust 바이너리를 Docker 이미지로 만들 때 핵심 포인트는 빌드 캐시와 최종 이미지 크기다. 멀티스테이지 빌드를 적용하면 최종 이미지에는 바이너리와 필수 런타임 라이브러리만 남아 200MB 이하로 줄어든다.

Rust 의존성 빌드는 첫 번째가 가장 오래 걸린다. 아래 Dockerfile은 의존성 레이어를 앞에 두고 소스코드 레이어를 분리하는 방식으로 캐시를 최대한 활용한다. 소스만 바뀔 때는 의존성 빌드 단계를 건너뛴다.

실제 바이너리 크기를 더 줄이려면 Cargo.toml[profile.release]strip = trueopt-level = "z"를 추가한다.

Dockerfile — 멀티스테이지 빌드
# 빌드 스테이지 FROM rust:1.78-slim AS builder WORKDIR /app # 의존성 캐시 레이어 COPY Cargo.toml Cargo.lock ./ RUN mkdir src && echo "fn main() {}" > src/main.rs RUN cargo build --release && rm -rf src # 실제 소스 빌드 COPY src ./src COPY migrations ./migrations RUN touch src/main.rs && cargo build --release # 실행 스테이지 FROM debian:bookworm-slim AS runtime WORKDIR /app RUN apt-get update && apt-get install -y ca-certificates libssl3 libpq5 && rm -rf /var/lib/apt/lists/* COPY --from=builder /app/target/release/axum-api ./axum-api COPY --from=builder /app/migrations ./migrations ENV DATABASE_URL="" ENV JWT_SECRET="" ENV RUST_LOG="info" EXPOSE 8080 CMD ["./axum-api"]
docker-compose.yml — 로컬 개발 환경
services: api: build: . ports: - "8080:8080" environment: DATABASE_URL: postgres://postgres:password@db:5432/axum_api JWT_SECRET: local-dev-secret-change-in-production RUST_LOG: debug depends_on: db: condition: service_healthy db: image: postgres:16-alpine environment: POSTGRES_USER: postgres POSTGRES_PASSWORD: password POSTGRES_DB: axum_api ports: - "5432:5432" healthcheck: test: ["CMD-SHELL", "pg_isready -U postgres"] interval: 5s timeout: 5s retries: 5 volumes: - pgdata:/var/lib/postgresql/data volumes: pgdata:

프로덕션 체크리스트 — Axum 서버 배포 전 필수 확인

Axum 서버를 프로덕션에 올리기 전에 확인해야 할 항목이다. Rust 특유의 사항과 Axum 0.8에서 달라진 부분을 중심으로 정리했다.

RustAxum웹서버백엔드JWTPostgreSQLsqlxDocker비동기Tokio

관련 도구

관련 포스트

Kotlin + Spring Boot 3 실전 튜토리얼 — Coroutines, JWT 인증, PostgreSQL, Docker 배포2026-04-26Rust vs Go — 2026년 백엔드 언어 선택 가이드2026-03-17Go 언어 REST API 서버 튜토리얼 — 환경 설정부터 DB 연동, Docker 배포까지2026-04-05Rust vs Go — 2026년 백엔드 실무 선택 가이드2026-03-20