Published on

axum rust framework: 라우팅·미들웨어·배포 구성 정리

Authors
  • avatar
    Name
    Indo Yoon
    Twitter

axum은 Tokio 진영에서 관리하는 Rust 웹 프레임워크로, 라우팅과 추출자, 미들웨어를 타입 시스템 위에서 조합하는 방식이 핵심이에요. 서버 운영과 배포를 맡는 백엔드 개발자라면 "핸들러가 어떤 타입을 받고 무엇을 돌려주는가"라는 규칙 하나만 붙잡아도 전체 구조가 정리돼요. 이 글은 공식 문서에서 확인한 범위 안에서 axum rust framework의 구성 요소, 도입 순서, 배포 전에 점검할 지점을 차분히 정리했어요.

한눈에 보기

axum은 HTTP 처리를 hyper에, 비동기 실행을 Tokio에 맡기고, 그 위에 라우팅과 추출자 계층을 얹은 프레임워크예요. 그래서 axum을 이해한다는 것은 곧 tower의 Service 조합으로 요청 파이프라인을 만드는 일에 가까워요. 구성 요소는 아래처럼 나눠 볼 수 있어요.

  • Router: 경로와 HTTP 메서드를 핸들러에 연결해요. 라우터를 중첩하거나 병합해서 모듈 단위로 나눌 수 있어요.
  • 핸들러: 인자로 추출자를 받고, 반환값으로 IntoResponse 구현체를 돌려주는 비동기 함수예요.
  • 추출자: Path, Query, Json, HeaderMap, State처럼 요청의 각 부분을 타입으로 변환해요.
  • 미들웨어: tower의 Layer를 그대로 사용해요. 로깅, 타임아웃, 압축, CORS 같은 기능은 tower-http에서 가져와요.
  • 상태: with_state로 공유 상태를 주입하고, 핸들러에서 State 추출자로 꺼내요.
  • 런타임: #[tokio::main]으로 구동하고, 종료 시 graceful shutdown을 연결해요.

중요한 점은 이 요소들이 별도 설정 파일이 아니라 Rust 타입으로 연결된다는 점이에요. 라우터에 잘못된 핸들러를 붙이면 컴파일 단계에서 걸러지므로, 배포 후에야 드러나는 실수가 줄어들어요. 대신 타입 오류 메시지가 길게 나오는 경우가 많아서, 추출자 시그니처를 작게 유지하는 습관이 도움이 돼요.

다른 Rust 웹 프레임워크와의 비교

항목axumactix-webwarp
라우팅 표현Router에 메서드 체이닝라우터 설정과 매크로 혼용필터 조합
미들웨어tower Layer 재사용자체 미들웨어 체계필터로 확장
비동기 실행Tokio자체 런타임 기반Tokio
확장 방식tower 생태계 자산 활용프레임워크 생태계 중심필터 합성
진입 장벽추출자 타입 이해가 관건매크로에 익숙하면 빠름필터 개념 이해가 관건
잘 맞는 경우tower 기반 공통 계층이 필요한 API기능이 풍부한 단일 서비스함수형 합성을 선호하는 경우

표는 어디까지나 구조적 차이를 정리한 것이에요. 실제 성능은 워크로드와 설정에 따라 달라지므로, 프레임워크 선택은 벤치마크보다 팀이 이미 가진 tower 자산과 운영 도구에 맞는지를 기준으로 삼는 편이 안전해요.

단계별로 프로젝트 구성하기

의존성 추가

Cargo 프로젝트를 만들고 axum, tokio, serde를 추가해요. 미들웨어까지 붙일 계획이라면 tower와 tower-http도 함께 넣어요. Tokio는 매크로와 네트워크 기능을 쓰기 위해 필요한 feature를 켜야 하고, 어떤 feature가 필요한지는 공식 문서의 예제에서 확인할 수 있어요.

최소 서버 띄우기

가장 작은 형태는 라우터 하나와 리스너 하나로 시작해요. 아래 예시는 헬스체크 경로만 가진 서버예요.

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

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

let listener = tokio::net::TcpListener::bind("0.0.0.0:8080").await.unwrap(); axum::serve(listener, app).await.unwrap();

}

async fn health() -> &'static str {
    "ok"
}

여기서 확인할 점은 Router가 값이고, serve에 그대로 넘긴다는 사실이에요. 즉 라우터를 조립하는 코드와 실행하는 코드를 분리해 둘 수 있어요. 이 분리가 나중에 통합 테스트를 작성할 때 큰 차이를 만들어요.

핸들러와 추출자 연결

핸들러는 요청 데이터를 인자로 받아요. 경로 파라미터는 Path, 쿼리는 Query, JSON 본문은 Json으로 받고, 공유 상태는 State로 받아요. 응답은 Json이나 StatusCode처럼 IntoResponse를 구현한 타입이면 무엇이든 돌려줄 수 있어요.

use axum::{extract::{Path, State}, Json};
use serde::Serialize;

#[derive(Clone)]
struct AppState {
    // 커넥션 풀, 설정 값 등
}

#[derive(Serialize)]
struct Item {
    id: u32,
    name: String,
}

async fn get_item(
    State(state): State<AppState>,
    Path(id): Path<u32>,
) -> Json<Item> {
    Json(Item { id, name: "sample".to_string() })
}

라우터 조립과 상태 주입

도메인별로 라우터를 나눠 만들고 마지막에 합치는 방식이 유지보수에 유리해요. 상태는 with_state로 한 번만 주입하면 하위 라우터 전체에 전달돼요. 상태 타입은 Clone이어야 하므로, 내부 자원은 Arc로 감싸 공유하는 형태를 자주 써요.

미들웨어 붙이기

tower-http의 TraceLayer로 요청 로그를 남기고, 필요하면 타임아웃과 압축 레이어를 추가해요. 레이어는 ServiceBuilder로 묶어 적용 순서를 명시하면 실수를 줄일 수 있어요.

use axum::{routing::get, Router};
use tower::ServiceBuilder;
use tower_http::trace::TraceLayer;

let app = Router::new()
    .route("/health", get(health))
    .layer(ServiceBuilder::new().layer(TraceLayer::new_for_http()));

배포 전 점검

바이너리를 릴리스 모드로 빌드하고, 종료 신호를 받으면 새 요청을 막고 진행 중인 요청을 마무리하는 graceful shutdown을 연결해요. 헬스체크 경로와 준비 상태 경로를 분리해 두면 오케스트레이터의 판단이 정확해져요. 로그는 구조화된 형태로 남겨 수집기에서 바로 검색할 수 있게 해요.

라우팅과 추출자에서 자주 막히는 지점

추출자에는 요청 본문을 소비하는 것과 그렇지 않은 것이 있어요. Json처럼 본문을 읽는 추출자는 반드시 인자 목록의 마지막에 두어야 해요. 순서를 바꾸면 컴파일은 통과해도 의도한 값이 들어오지 않는 것처럼 보일 수 있어요.

또 하나는 경로 파라미터의 타입이에요. Path<u32>로 선언하면 숫자가 아닌 값은 자동으로 거부돼요. 이 동작은 편리하지만, 사용자에게 어떤 오류를 보여줄지는 별도로 정해야 해요. 기본 거부 응답을 그대로 노출하기보다, 핸들러에서 문자열로 받아 직접 검증하는 편이 설명하기 쉬운 경우도 많아요.

중첩 라우터를 쓸 때는 경로 접두사가 중복되지 않는지 확인해요. 같은 경로에 서로 다른 핸들러가 걸리면 마지막에 등록된 쪽이 이기므로, 라우터를 합치는 순서를 코드에서 명확히 드러내는 것이 좋아요.

미들웨어와 상태 공유

axum의 미들웨어는 tower의 Layer예요. 그래서 tower에 있는 타임아웃, 재시도, 부하 제한 같은 구성 요소를 그대로 가져다 쓸 수 있어요. 이미 tower 기반 클라이언트나 게이트웨이를 운영하고 있다면 같은 개념을 서버 쪽에도 적용할 수 있다는 뜻이에요.

상태 공유는 with_state로 주입한 값을 State 추출자로 꺼내는 방식이에요. 데이터베이스 풀, 설정 구조체, 캐시 클라이언트처럼 요청마다 새로 만들 필요가 없는 자원을 담아요. 상태 안에 가변 값이 필요하면 내부 동기화를 직접 책임져야 하므로, 처음에는 읽기 전용 설정과 커넥션 풀 정도로 범위를 좁히는 편이 안전해요.

레이어 순서도 운영에 영향을 줘요. 인증이나 레이트 리밋처럼 요청을 조기에 끊는 레이어는 바깥쪽에, 로깅은 그보다 안쪽에 두는 식으로 설계 의도를 순서에 반영해요. 순서를 바꾸면 로그에 남는 요청과 남지 않는 요청이 달라지므로, 코드 리뷰에서 함께 확인할 항목이에요.

상황별 선택 기준

새 API 서비스를 처음 만든다면 axum의 기본 구성만으로도 충분한 경우가 많아요. 라우터와 추출자, 상태 주입만으로 대부분의 CRUD 엔드포인트가 정리돼요.

이미 tower 기반의 공통 계층을 운영하고 있다면 axum이 특히 잘 맞아요. 로깅, 타임아웃, 인증 레이어를 서버와 클라이언트 양쪽에서 같은 방식으로 재사용할 수 있어요.

반대로 프레임워크가 제공하는 기능 묶음이 중요하고 매크로 기반 정의가 편하다면 다른 프레임워크가 나을 수 있어요. 팀이 Rust 비동기 코드에 익숙하지 않다면, 추출자 타입 오류를 읽는 데 드는 시간을 먼저 고려해야 해요.

배포 환경이 컨테이너 오케스트레이터라면 헬스체크와 graceful shutdown을 초기에 넣어 두는 것이 좋아요. 이 두 가지는 나중에 붙이려면 main 함수 구조를 손봐야 하는 경우가 생겨요.

주의할 점

  • 본문 크기 제한을 명시하지 않으면 큰 요청이 그대로 메모리에 올라갈 수 있어요. 기본 제한을 확인하고 필요한 경로에만 완화해요.
  • 타임아웃 레이어를 두지 않으면 느린 의존성 하나가 전체 요청을 붙잡아요.
  • 오류 타입을 IntoResponse로 변환해 두면 핸들러마다 상태 코드를 흩뿌리지 않아도 돼요.
  • 패닉은 요청 단위로 격리되지 않아요. 복구 레이어를 둘지, 패닉이 나지 않게 설계할지 먼저 정해요.
  • 종료 신호 처리 없이 배포하면 진행 중인 요청이 끊겨요.
  • axum, tower, tower-http, hyper의 버전 호환을 함께 확인해요. 하나만 올리면 컴파일 오류가 나는 조합이 있어요.

자주 묻는 질문

axum은 어떤 런타임 위에서 동작하나요?

Tokio 위에서 동작해요. 공식 예제도 #[tokio::main]으로 시작하므로, Tokio의 작업 스케줄링과 취소 동작을 함께 이해하면 도움이 돼요.

actix-web과 무엇이 크게 다른가요?

axum은 tower의 Layer와 Service를 미들웨어 단위로 사용해요. 그래서 tower 생태계의 구성 요소를 그대로 조합할 수 있고, 이 점이 구조적 차이를 만드는 지점이에요.

공유 상태는 어떻게 주입하나요?

Router의 with_state로 값을 넘기고, 핸들러에서 State 추출자로 받아요. 상태 타입은 Clone이어야 하므로 내부 자원은 Arc로 감싸 공유하는 형태를 써요.

미들웨어는 어떤 순서로 두어야 하나요?

요청을 조기에 끊는 레이어를 바깥쪽에, 관측용 레이어를 안쪽에 두는 방식이 일반적이에요. 다만 로그로 무엇을 확인하고 싶은지에 따라 순서가 달라지므로 설계 의도를 코드에 남겨 두세요.

배포 전에 무엇을 먼저 확인해야 하나요?

헬스체크 경로, graceful shutdown, 타임아웃 설정을 먼저 확인해요. 이 셋이 없으면 장애 시 원인 파악과 안전한 재시작이 어려워져요.

추출자 순서가 왜 중요한가요?

본문을 소비하는 추출자는 인자 목록의 마지막에 와야 해요. 순서가 어긋나면 컴파일 오류가 나거나 의도와 다른 값이 전달될 수 있어요.

출처

서버 운영 관점에서 함께 보면 좋은 글도 정리해 두었어요. Rust 서버 운영 글 모음에서 배포 체크리스트와 관측성 구성 사례를 이어서 확인하실 수 있어요.