hyunsu blog

홈강좌Rust 실무 16편 중 14편째

Rust axum 실무 구성 — 에러를 응답으로, 입력 검증, 서버 없이 테스트

목차 · 4
  1. 011. 에러가 곧 응답이 되게
  2. 022. 핸들러는 Result 를 돌려준다
  3. 033. 서버 없이 테스트한다
  4. 04실습 — 경우 하나 더하기

axum 으로 REST API 를 실무 모양으로 짜는 법이다. thiserror 로 만든 AppError 에 IntoResponse 를 구현해 에러가 알맞은 상태 코드와 JSON 이 되게 하고, 입력을 핸들러 앞부분에서 검증하고, Router 를 서버 없이 바로 불러 테스트한다. 7편의 메모 API 를 이 모양으로 다시 짠다.

toml
[package]
name = "notes-service"
version = "0.1.0"
edition = "2024"

[dependencies]
axum = "=0.8.9"
tokio = { version = "=1.54.1", features = ["rt-multi-thread", "macros", "net", "time"] }
serde = { version = "=1.0.229", features = ["derive"] }
serde_json = "=1.0.154"
thiserror = "=2.0.21"

[dev-dependencies]
tower = { version = "=0.5.3", features = ["util"] }
http-body-util = "=0.1.5"

[dev-dependencies] 는 테스트할 때만 쓰는 크레이트다. 실행 파일에는 들어가지 않는다.

01 1. 에러가 곧 응답이 되게

7편에서는 핸들러마다 StatusCode 를 직접 골랐다. 경로가 늘면 같은 판단이 여기저기 흩어진다. 에러를 8편처럼 enum 으로 모으고, 그 enum 이 스스로 응답이 되게 한다.

rust
#[derive(Debug, thiserror::Error)]
enum AppError {
    #[error("title must be 1 to {max} characters")]
    InvalidTitle { max: usize },
    #[error("note {0} not found")]
    NotFound(usize),
}

impl IntoResponse for AppError {
    fn into_response(self) -> Response {
        let status = match self {
            AppError::InvalidTitle { .. } => StatusCode::BAD_REQUEST,
            AppError::NotFound(_) => StatusCode::NOT_FOUND,
        };
        let body = Json(serde_json::json!({ "error": self.to_string() }));
        (status, body).into_response()
    }
}
  • IntoResponse 는 axum 의 트레이트(입문 33편)로, "응답으로 바뀔 수 있다" 는 약속이다. 핸들러가 돌려주는 Json·StatusCode·튜플이 모두 이것을 구현하고 있었다.
  • 경우마다 상태 코드를 한곳에서 정한다. 본문은 {"error": "…"} 하나로 통일한다. serde_json::json! 은 JSON 을 바로 쓰는 매크로다.
  • 에러 문장은 8편의 #[error("…")] 를 그대로 쓴다.

02 2. 핸들러는 Result 를 돌려준다

rust
async fn create(State(state): State<AppState>, Json(input): Json<NewNote>) -> Result<(StatusCode, Json<Note>), AppError> {
    let title = input.title.trim();
    if title.is_empty() || title.chars().count() > TITLE_MAX {
        return Err(AppError::InvalidTitle { max: TITLE_MAX });
    }
    let mut notes = state.notes.lock().unwrap();
    let note = Note { id: notes.len() + 1, title: title.to_string() };
    notes.push(note.clone());
    Ok((StatusCode::CREATED, Json(note)))
}

async fn one(State(state): State<AppState>, Path(id): Path<usize>) -> Result<Json<Note>, AppError> {
    let notes = state.notes.lock().unwrap();
    notes.iter().find(|n| n.id == id).cloned().map(Json).ok_or(AppError::NotFound(id))
}

fn app(state: AppState) -> Router {
    let api = Router::new()
        .route("/notes", axum::routing::post(create))
        .route("/notes/{id}", get(one));
    Router::new()
        .route("/health", get(|| async { "ok" }))
        .nest("/api", api)
        .with_state(state)
}
  • 핸들러가 Result<…, AppError> 를 돌려주니, 안에서 입문 31편의 ? 와 return Err(…) 를 그대로 쓴다. Err 는 1번의 변환으로 알맞은 응답이 된다.
  • 입력 검증은 핸들러 맨 앞에서 한다. 앞뒤 빈칸을 지우고(trim), 비었거나 50자를 넘으면 400 이다. 글자 수는 입문 21편처럼 chars().count() 로 센다.
  • nest("/api", api) 는 경로 묶음을 /api 아래에 붙인다. /health 같은 점검용 경로는 밖에 둔다.

03 3. 서버 없이 테스트한다

7편에서는 서버를 띄우고 TCP 로 요청을 보냈다. 실무 테스트에서는 그럴 필요가 없다. Router 도 요청을 받아 응답을 돌려주는 하나의 서비스라서, 요청을 바로 넣어 볼 수 있다.

rust
#[cfg(test)]
mod tests {
    use super::*;
    use axum::body::Body;
    use axum::http::Request;
    use http_body_util::BodyExt;
    use tower::ServiceExt;

    async fn call(app: Router, method: &str, uri: &str, body: &str) -> (StatusCode, String) {
        let req = Request::builder()
            .method(method)
            .uri(uri)
            .header("content-type", "application/json")
            .body(Body::from(body.to_string()))
            .unwrap();
        let res = app.oneshot(req).await.unwrap();
        let status = res.status();
        let bytes = res.into_body().collect().await.unwrap().to_bytes();
        (status, String::from_utf8(bytes.to_vec()).unwrap())
    }

    #[tokio::test]
    async fn creates_note() {
        let (status, body) = call(app(AppState::default()), "POST", "/api/notes", r#"{"title":" learn axum "}"#).await;
        assert_eq!(status, StatusCode::CREATED);
        assert_eq!(body, r#"{"id":1,"title":"learn axum"}"#);
    }

    #[tokio::test]
    async fn rejects_empty_title() {
        let (status, body) = call(app(AppState::default()), "POST", "/api/notes", r#"{"title":"   "}"#).await;
        assert_eq!(status, StatusCode::BAD_REQUEST);
        assert_eq!(body, r#"{"error":"title must be 1 to 50 characters"}"#);
    }

    #[tokio::test]
    async fn missing_note_is_404_json() {
        let (status, body) = call(app(AppState::default()), "GET", "/api/notes/7", "").await;
        assert_eq!(status, StatusCode::NOT_FOUND);
        assert_eq!(body, r#"{"error":"note 7 not found"}"#);
    }

    #[tokio::test]
    async fn health_is_outside_api() {
        let (status, body) = call(app(AppState::default()), "GET", "/health", "").await;
        assert_eq!((status, body.as_str()), (StatusCode::OK, "ok"));
    }
}
bash
cargo test
text
running 4 tests
test tests::health_is_outside_api ... ok
test tests::creates_note ... ok
test tests::rejects_empty_title ... ok
test tests::missing_note_is_404_json ... ok

test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
  • app.oneshot(req) 는 tower 의 ServiceExt 가 주는 메서드로, 요청 하나를 넣고 응답 하나를 받는다. 포트를 열지 않으니 빠르고, 테스트끼리 포트가 겹칠 일도 없다.
  • #[tokio::test] 는 async 테스트 함수를 tokio 위에서 돌린다(입문 46편의 #[tokio::main] 의 테스트판).
  • 응답 본문은 http-body-util 의 collect() 로 모아 문자열로 바꿨다.
  • 네 테스트가 만들기 201·빈 제목 400·없는 메모 404·/health 200 을 모두 확인했다. 에러 본문이 {"error": …} 모양인지까지 본다.

04 실습 — 경우 하나 더하기

같은 제목의 메모가 이미 있으면 409(Conflict)를 돌려주자.

  1. AppError 에 #[error("note '{0}' already exists")] Duplicate(String) 를 더한다.
  2. into_response 의 match 에 AppError::Duplicate(_) => StatusCode::CONFLICT 를 더한다. 빠뜨리면 입문 25편의 E0004 가 알려 준다.
  3. create 에서 같은 제목을 찾으면 return Err(AppError::Duplicate(title.to_string())).
  4. 같은 제목을 두 번 보내는 테스트를 더해 409 와 에러 본문을 확인한다.

에러 enum 이 한곳에 모여 있으니, API 가 어떤 에러를 줄 수 있는지 이 파일만 보면 안다. 다음 편부터는 보안이다. 먼저 Rust 가 막아 주는 것과 막지 못하는 것을 나눈다.