Axum에서 CRUD API를 만드는 일은 route 몇 개를 연결하는 것으로 끝나지 않습니다. 요청 본문을 누가 읽는지, 잘못된 JSON과 도메인 규칙 위반을 어떤 상태 코드로 구분할지, handler가 저장 방식에 얼마나 의존할지까지 함께 정해야 합니다. 이 경계가 흐리면 SQLite를 붙일 때 route와 검증 코드까지 다시 고치게 됩니다.
이 예제는 Rust 2024 edition, Axum 0.8.9, Tower 0.5.2, Tokio 1.53.1을 고정합니다. /status와 endpoint CRUD route를 만듭니다. AppState에는 repository 경계를 주입합니다. 저장소는 메모리 구현만 제공하므로 다음 글에서 SQLite adapter를 추가해도 HTTP 계약은 그대로 둘 수 있습니다.
1. HTTP 계약부터 고정합니다
API 표면은 작습니다. GET /status는 서비스 상태와 등록된 endpoint 수를 반환합니다. /endpoints에는 생성과 목록 조회를, /endpoints/{id}에는 단건 조회·수정·삭제를 배치합니다.
| Method | Path | Success | Failure |
|---|---|---|---|
GET |
/status |
200 |
– |
POST |
/endpoints |
201 |
400, 415, 422 |
GET |
/endpoints |
200 |
– |
GET |
/endpoints/{id} |
200 |
404 |
PUT |
/endpoints/{id} |
200 |
400, 404, 415, 422 |
DELETE |
/endpoints/{id} |
204 |
404 |
400은 JSON 문법이나 역직렬화가 실패한 경우입니다. 415는 Content-Type: application/json이 빠진 요청, 422는 JSON 구조는 맞지만 name, URL, interval 규칙을 통과하지 못한 요청에 씁니다. 이 구분은 Axum의 기본 오류 문자열을 외부 계약으로 노출하지 않으려는 선택입니다.
2. typed state 뒤에 repository를 둡니다
State<AppState>가 handler와 애플리케이션 상태를 연결합니다. 상태 타입이 맞지 않으면 컴파일 단계에서 드러납니다. AppState가 구체적인 RwLock<BTreeMap<...>>을 직접 공개하지 않고 Arc<dyn EndpointRepository>를 소유하도록 만들었습니다.
pub trait EndpointRepository: Send + Sync {
fn count(&self) -> usize;
fn list(&self) -> Vec<Endpoint>;
fn get(&self, id: u64) -> Option<Endpoint>;
fn create(&self, input: EndpointInput) -> Endpoint;
fn update(&self, id: u64, input: EndpointInput) -> Option<Endpoint>;
fn delete(&self, id: u64) -> bool;
}
#[derive(Clone)]
pub struct AppState {
repository: Arc<dyn EndpointRepository>,
}
impl AppState {
pub fn new(repository: Arc<dyn EndpointRepository>) -> Self {
Self { repository }
}
}
메모리 저장소는 RwLock 안에 BTreeMap과 다음 ID를 둡니다. handler는 lock을 알지 못하며 repository 호출 사이에 await도 없습니다. 여기서 repository trait은 영속성 기능을 미리 흉내 내는 장식이 아닙니다. Article 29가 SQLx 구현을 별도 adapter로 추가할 수 있게 만드는 교체 경계입니다.
이 예제의 동기 trait은 작업이 짧은 메모리 연산이라는 조건에 맞습니다. 실제 데이터베이스 구현은 비동기 호출이 필요하므로 trait의 반환 타입이나 application service 경계를 다시 설계해야 합니다. 지금의 동기 trait을 그대로 SQLx에 억지로 끼우면 blocking 호출이나 불필요한 runtime 우회가 생깁니다.
3. route 조립과 상태 주입을 한곳에 모읍니다
Router는 path와 method의 조합을 명시합니다. state를 필요로 하는 route를 모두 만든 뒤 with_state로 AppState를 제공합니다.
pub fn app(state: AppState) -> Router {
Router::new()
.route("/status", get(status))
.route("/endpoints", get(list_endpoints).post(create_endpoint))
.route(
"/endpoints/{id}",
get(get_endpoint)
.put(update_endpoint)
.delete(delete_endpoint),
)
.with_state(state)
}
Axum 0.8의 path capture 문법은 /{id}입니다. Path(id): Path<u64>는 문자열 segment를 u64로 변환합니다. State(state): State<AppState>는 router가 가진 typed state를 꺼냅니다. body를 소비하는 Json은 handler 인자에서 마지막에 둡니다. request body는 한 번만 소비할 수 있기 때문입니다.
GET /status가 repository의 count를 읽으므로 status 응답과 CRUD state가 같은 저장소를 봅니다. 별도 전역 변수나 process-local singleton은 없습니다. 테스트도 각자 새 repository를 주입해 서로 격리됩니다.
4. JSON 추출 실패와 값 검증을 나눕니다
Json<EndpointInput>이 성공했다는 사실은 필드의 의미가 유효하다는 뜻이 아닙니다. 먼저 Axum이 content type, JSON 문법, target type 역직렬화를 처리합니다. 그 다음 application validation이 name, URL scheme, interval 범위를 검사합니다.
handler는 Result<Json<EndpointInput>, JsonRejection>을 직접 받아 extractor 실패를 안정적인 ApiError로 바꿉니다. State와 Path처럼 body를 읽지 않는 extractor가 앞에 오고 JSON payload가 마지막에 옵니다.
async fn create_endpoint(
State(state): State<AppState>,
payload: Result<Json<EndpointInput>, JsonRejection>,
) -> Result<(StatusCode, Json<Endpoint>), ApiError> {
let Json(input) = payload.map_err(ApiError::from_json_rejection)?;
let input = validate(input)?;
Ok((StatusCode::CREATED, Json(state.repository.create(input))))
}
검증은 name의 양끝 공백을 제거한 뒤 빈 문자열과 64자 상한을 확인합니다. URL은 absolute URL로 파싱하고 http 또는 https scheme만 허용합니다. interval은 5초 이상 86,400초 이하입니다. 모든 오류를 모아 한 번에 반환하되 실패한 입력은 repository에 전달하지 않습니다.
fn validate(mut input: EndpointInput) -> Result<EndpointInput, ApiError> {
let mut errors = Vec::new();
input.name = input.name.trim().to_owned();
if input.name.is_empty() {
errors.push("name must not be blank");
}
match url::Url::parse(&input.url) {
Ok(parsed) if matches!(parsed.scheme(), "http" | "https") => input.url = parsed.to_string(),
Ok(_) => errors.push("url scheme must be http or https"),
Err(_) => errors.push("url must be an absolute URL"),
}
if !(5..=86_400).contains(&input.interval_seconds) {
errors.push("interval_seconds must be between 5 and 86400");
}
if errors.is_empty() {
Ok(input)
} else {
Err(ApiError::validation(errors.join("; ")))
}
}
본문에는 간결하게 보이도록 name 길이 검사를 생략했지만 실행 fixture에는 포함되어 있습니다. 실제 동작의 기준은 fixture입니다. 두 언어 문서의 코드 블록은 같은 내용을 사용합니다.
Extractor rejection도 외부 오류 envelope로 통일합니다. content type 누락은 json_content_type_required, 그 밖의 JSON 문법·데이터 오류는 invalid_json입니다. JsonRejection은 non-exhaustive이므로 특정 variant만 모두 나열한 match에 의존하지 않습니다.
5. TCP 없이 Router를 Service로 테스트합니다
Axum의 Router는 Tower Service입니다. ServiceExt::oneshot에 Request<Body>를 넘기면 socket을 열지 않고도 routing, extractor, state, response body를 함께 검사할 수 있습니다. handler를 직접 호출하는 unit test보다 실제 HTTP 경계를 더 많이 통과합니다.
#[tokio::test]
async fn json_extractor_rejection_has_a_stable_error_envelope() {
let response = request("POST", "/endpoints", Body::from(r#"{"name":"broken""#)).await;
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
assert_eq!(
json(response).await,
serde_json::json!({
"error": {"code":"invalid_json","message":"request body must be valid JSON matching the endpoint schema"}
})
);
}
테스트는 일곱 가지 계약을 고정합니다. 빈 repository의 status, 생성 후 목록·단건 조회, 검증 실패 시 state 불변, malformed JSON의 안정적인 오류 envelope, update와 delete lifecycle, content type 누락의 415, demo binary의 정확한 출력입니다. CRUD 테스트는 같은 router clone을 계속 사용하므로 동일한 typed state가 요청 사이에 유지되는지도 확인합니다.
이 방식은 network stack 자체를 검증하지 않습니다. 대신 route table과 application boundary를 빠르고 결정적으로 검사합니다. listener binding, TLS, proxy header 같은 항목은 배포 통합 테스트의 몫입니다.
6. fixture를 실행하고 범위를 확인합니다
프로젝트 루트에서 다음 명령을 실행합니다.
cd examples/article-28-axum-rest-api-routing-state
cargo fmt --all -- --check
cargo check --all-targets --all-features
cargo clippy --all-targets --all-features -- -D warnings
cargo test --all-features
cargo run --quiet -- --demo
마지막 명령은 실제 Router에 세 요청을 보내는 짧은 demo입니다. 출력은 다음과 같습니다.
POST /endpoints -> 201 id=1
GET /status -> 200 endpoint_count=1
DELETE /endpoints/1 -> 204
fixture의 기본 실행 모드는 127.0.0.1:3000에 listener를 열어 같은 router를 제공합니다. demo mode는 자동 검증이 끝나는 실행 경로이고 서버 mode는 수동 호출에 쓸 수 있습니다.
현재 저장소는 process가 끝나면 사라지고 여러 process가 데이터를 공유하지 못합니다. 인증, pagination, conditional update, request body limit도 아직 없습니다. 이 글의 목표는 그 기능을 한꺼번에 넣는 것이 아니라 route, state, extractor, validation, repository 경계를 먼저 고정하는 데 있습니다. 다음 저장소 adapter는 이 HTTP 계약을 바꾸지 않고 교체할 수 있어야 합니다.
전체 소스 코드
이 글의 전체 실행 가능한 소스는 GitHub의 Chapter 28 프로젝트에서 확인할 수 있습니다.
답글 남기기