Rust axum 실무 구성 — 에러를 응답으로, 입력 검증, 서버 없이 테스트
axum 으로 REST API 를 실무 모양으로 짜는 법이다. thiserror 로 만든 AppError 에 IntoResponse 를 구현해 에러가 알맞은 상태 코드와 JSON 이 되게 하고, 입력을 핸들러 앞부분에서 검증하고, Router 를 서버 없이 바로 불러 테스트한다. 7편의 메모 API 를 이 모양으로 다시 짠다.
[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 이 스스로 응답이 되게 한다.
#[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 를 돌려준다
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 도 요청을 받아 응답을 돌려주는 하나의 서비스라서, 요청을 바로 넣어 볼 수 있다.
#[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"));
}
}cargo testrunning 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.00sapp.oneshot(req)는 tower 의ServiceExt가 주는 메서드로, 요청 하나를 넣고 응답 하나를 받는다. 포트를 열지 않으니 빠르고, 테스트끼리 포트가 겹칠 일도 없다.#[tokio::test]는 async 테스트 함수를 tokio 위에서 돌린다(입문 46편의#[tokio::main]의 테스트판).- 응답 본문은
http-body-util의collect()로 모아 문자열로 바꿨다. - 네 테스트가 만들기 201·빈 제목 400·없는 메모 404·
/health200 을 모두 확인했다. 에러 본문이{"error": …}모양인지까지 본다.
04 실습 — 경우 하나 더하기
같은 제목의 메모가 이미 있으면 409(Conflict)를 돌려주자.
AppError에#[error("note '{0}' already exists")] Duplicate(String)를 더한다.into_response의match에AppError::Duplicate(_) => StatusCode::CONFLICT를 더한다. 빠뜨리면 입문 25편의 E0004 가 알려 준다.create에서 같은 제목을 찾으면return Err(AppError::Duplicate(title.to_string())).- 같은 제목을 두 번 보내는 테스트를 더해 409 와 에러 본문을 확인한다.
에러 enum 이 한곳에 모여 있으니, API 가 어떤 에러를 줄 수 있는지 이 파일만 보면 안다. 다음 편부터는 보안이다. 먼저 Rust 가 막아 주는 것과 막지 못하는 것을 나눈다.