목표

2단계 저장소 앞에 HTTP 문을 단다. 이 단계가 끝나면 서비스는 세 가지 요청에 답한다.

  • POST /links, 본문 {"url": "…"} → 201과 {"code": "…", "url": "…"}.
  • GET /{code} → 307과 Location: <원래 주소>. 없는 코드는 404.
  • GET /health → 200과 {"status":"ok"}.

실패는 모두 {"error":{"kind":"…","message":"…"}} 모양의 JSON으로 답한다. 예외는 하나다. 경로는 맞는데 메서드가 틀린 요청(GET /links)에는 라우터가 빈 본문의 405를 직접 낸다. 이 구멍은 변형 2에서 막는다.

이 단계를 대충 하면 운영에서 세 가지가 깨진다. 첫째, axum의 기본 거절 응답은 평문(text/plain)이라서 JSON을 기대하는 클라이언트가 415·400 응답을 읽다가 파싱 에러를 낸다. 둘째, 저장소 에러를 문자열로 그대로 돌려주면 DB 드라이버의 메시지가 바깥에 보인다. 셋째, 영구 이동(301·308)으로 보내면 브라우저가 그 답을 기억해서, 링크를 지워도 사람들이 계속 옛 주소로 간다.

사용 개념

설계 결정

결정 고른 것 까닭과 다른 길
상태 공유 제네릭 AppState<S>, 필드는 Arc Arc<dyn LinkStore>가 더 짧아 보이지만 2단계 트레이트는 메서드마다 impl Future를 돌려줘서 dyn으로 쓸 수 없다. 그래서 라우터에 create_link::<S>처럼 타입을 박아 등록한다
Clone 손으로 쓴 impl<S> Clone #[derive(Clone)]은 제네릭 매개변수에 Clone 경계를 붙인다1. SqliteStore는 Clone이 아니라서 derive하면 AppState<SqliteStore>도 Clone이 아니게 된다. axum의 State로 꺼내려면 상태 타입이 Clone이어야 한다2
본문 거절 처리 인자를 Result<Json<CreateLink>, JsonRejection>으로 받는다 그냥 Json<…>으로 받으면 거절될 때 핸들러가 불리지 않고 axum의 기본 응답이 나간다. Result로 감싸면 핸들러가 거절 값을 받는다3
모르는 필드 #[serde(deny_unknown_fields)] serde는 기본으로 JSON의 모르는 필드를 무시한다4. {"url": …, "x": 1}을 조용히 받아 주면 클라이언트가 이름을 틀려도 모른다
상태 코드 잘못된 URL 422, 없음 404, 재시도 소진 503, 저장소 고장 500 JSON 문법이 깨진 400과 뜻이 틀린 422를 axum이 이미 나눠 준다. 도메인 검사 실패도 “문법은 맞는데 뜻이 틀림”이라 422로 맞췄다
리다이렉트 Redirect::temporary(307) Redirect::to는 303이고 다음 요청을 GET으로 바꾼다. 307은 메서드와 본문을 그대로 둔다5. 308(permanent)은 따로 지시가 없어도 캐시될 수 있는 응답이고 307은 아니다6
형식이 틀린 코드 404 400을 주면 “그런 형식은 없다”와 “그런 링크는 없다”를 구별해 알려 준다. 2단계 resolve가 DB에 묻지 않고 바로 NotFound를 내는 것을 그대로 쓴다
없는 경로 .fallback(not_found) /a/b/c처럼 어떤 라우트에도 안 맞는 경로도 같은 JSON 404를 받는다

코드 설명

하위목표 1: 상태를 정한다. 핸들러가 함께 쓰는 것은 저장소와 코드 생성기 둘이다. 둘 다 Arc로 감싸서 복제가 참조 수 하나 올리는 일로 끝나게 한다.

pub struct AppState<S> {
    store: Arc<S>,
    codes: Arc<Mutex<CodeGen>>,
}

// #[derive(Clone)]은 S: Clone을 요구하므로 손으로 쓴다.
impl<S> Clone for AppState<S> {
    fn clone(&self) -> Self {
        Self { store: Arc::clone(&self.store), codes: Arc::clone(&self.codes) }
    }
}

Mutex는 std 것이다. 2단계에서 본 대로 잠금은 next_code() 한 줄에서 풀리고 .await를 넘지 않는다.

하위목표 2: 실패를 응답 하나로 모은다. axum은 모든 서비스가 실패하지 않을 것(Infallible)을 요구한다. 에러가 hyper까지 올라가면 응답 없이 연결이 끊기기 때문이다7. 그래서 핸들러의 에러는 IntoResponse를 구현한 타입이어야 한다.

pub enum ApiError {
    BadJson(JsonRejection),
    Link(LinkError),
}

impl IntoResponse for ApiError {
    fn into_response(self) -> Response {
        let (status, kind, message) = match &self {
            Self::BadJson(r) => (r.status(), "bad_request", r.body_text()),
            Self::Link(LinkError::Invalid(e)) => (StatusCode::UNPROCESSABLE_ENTITY, "invalid_url", e.to_string()),
            Self::Link(LinkError::NotFound) => (StatusCode::NOT_FOUND, "not_found", "no such link".into()),
            Self::Link(LinkError::CodeSpaceExhausted(_)) => (StatusCode::SERVICE_UNAVAILABLE, "unavailable", "try again later".into()),
            // 내부 원인(DB 메시지)은 응답에 싣지 않는다. 4단계에서 로그로 남긴다.
            Self::Link(LinkError::Storage(_)) => (StatusCode::INTERNAL_SERVER_ERROR, "internal", "internal error".into()),
        };
        let body = serde_json::json!({ "error": { "kind": kind, "message": message } });
        (status, Json(body)).into_response()
    }
}

From<JsonRejection>과 From<LinkError>를 구현해 두어서 핸들러 안에서 ? 하나로 바뀐다. 거절 쪽은 axum이 정한 상태 코드(r.status())와 문장(r.body_text())을 그대로 쓰고 겉모양만 우리 JSON으로 바꾼다. DomainError의 메시지는 1단계에서 바깥에 보여도 되는 문장만 골랐으므로 그대로 싣는다. Storage만 “internal error”로 가린다.

하위목표 3: 핸들러. 본문을 읽는 추출기는 인자 목록의 맨 끝에 와야 한다. 본문은 한 번만 읽을 수 있기 때문이다8.

async fn create_link<S: LinkStore>(
    State(st): State<AppState<S>>,
    body: Result<Json<CreateLink>, JsonRejection>,
) -> Result<(StatusCode, Json<LinkBody>), ApiError> {
    let Json(req) = body?;
    let (code, url) = shorten(&*st.store, &st.codes, &req.url).await?;
    Ok((StatusCode::CREATED, Json(LinkBody { code: code.to_string(), url: url.to_string() })))
}

async fn follow<S: LinkStore>(
    State(st): State<AppState<S>>,
    Path(code): Path<String>,
) -> Result<Redirect, ApiError> {
    let url = resolve(&*st.store, &code).await?;
    Ok(Redirect::temporary(url.as_str()))
}

Redirect는 주소를 헤더 값으로 바꾸다 실패하면 500을 낸다9. 1단계가 보이는 ASCII만 받기 때문에 이 경로는 생기지 않는다. 6단계의 성질 테스트(property-based testing)가 이 약속을 지킨다.

하위목표 4: 라우터를 조립한다. axum 0.8부터 경로 변수는 /:code가 아니라 /{code}로 쓴다10.

pub fn router<S: LinkStore>(state: AppState<S>) -> Router {
    Router::new()
        .route("/health", get(health))
        .route("/links", post(create_link::<S>))
        .route("/{code}", get(follow::<S>))
        .fallback(not_found)
        .with_state(state)
}

/links는 /{code}와도 모양이 맞지만, 고정 경로가 먼저 잡힌다. 그래서 GET /links는 리다이렉트 시도가 아니라 405가 된다. fallback은 어떤 라우트에도 안 맞는 경로에만 불리고, 경로는 맞는데 메서드가 틀린 경우에는 불리지 않는다11. with_state를 부르면 상태를 다 채운 Router<()>가 되고, axum::serve에는 이것만 넘길 수 있다11.

라우터 테스트와 같은 요청을 넣어 응답 본문까지 찍어 보면 이렇다.

POST text/plain        → 415 {"error":{"kind":"bad_request","message":"Expected request with `Content-Type: application/json`"}}
{"url":                → 400 {"error":{"kind":"bad_request","message":"Failed to parse the request body as JSON: url: EOF while parsing a value at line 1 column 7"}}
{"link":"http://a.b"}  → 422 {"error":{"kind":"bad_request","message":"Failed to deserialize the JSON body into the target type: link: unknown field `link`, expected `url` at line 1 column 7"}}
{"url":"http://user@a.b"} → 422 {"error":{"kind":"invalid_url","message":"url must not contain user info (user@host)"}}
GET /a/b/c             → 404 {"error":{"kind":"not_found","message":"no such link"}}
GET /links             → 405 (본문 없음, allow: POST)

deny_unknown_fields가 없었다면 {"link": …}는 “url 필드가 없다”는 메시지를 받았을 것이다. 지금은 무엇이 틀렸는지(link라는 모르는 필드)를 정확히 알려 준다.

하위목표 5: 테스트한다. tower::ServiceExt::oneshot은 서비스를 소비해서 준비되면 요청 하나를 넣는다12. Router는 서비스이므로 소켓을 열지 않고 요청을 넣고 응답을 받는다. 테스트마다 app.clone()을 넘기는 까닭은 oneshot이 서비스를 가져가기 때문이다.

async fn send(app: Router, method: &str, uri: &str, ctype: &str, body: &str) -> Response {
    let req = Request::builder()
        .method(method).uri(uri).header("content-type", ctype)
        .body(Body::from(body.to_owned())).unwrap();
    app.oneshot(req).await.unwrap()
}

bad_requests_get_json_errors는 위 표의 415·400·422 다섯 경우가 상태 코드와 error.kind를 모두 갖췄는지 본다. 응답 본문은 axum::body::to_bytes(…, 64 * 1024)처럼 상한을 주고 읽는다. 상한 없이 읽으면 테스트도 큰 응답에 메모리를 다 쓴다.

자체 테스트(self_check)만 진짜 소켓을 쓴다. 127.0.0.1:0으로 열어 운영체제가 빈 포트를 고르게 하고, 하네스에 HTTP 클라이언트 크레이트가 없어서 TcpStream에 요청 줄을 직접 써서 보낸다. 끝나면 oneshot 채널로 with_graceful_shutdown에 신호를 준다. 실행 출력은 이렇다.

POST /links → 201 {"code":"pJ1XgKB","url":"https://example.com/x"}
잘못된 URL → 422 {"error":{"kind":"invalid_url","message":"url must start with http:// or https://"}}
ALL CHECKS PASSED

씨앗을 3으로 고정해서 코드는 실행할 때마다 pJ1XgKB다. 남은 문제는 serve_forever다. 주소 127.0.0.1:3000과 DB sqlite://links.db가 코드에 박혀 있어서, 다른 포트나 DB로 띄우려면 다시 컴파일해야 한다. 4단계가 이것을 설정으로 뺀다.

검증: rustfmt·clippy(-D warnings)·테스트 4개(self_check_passes, create_then_follow, bad_requests_get_json_errors, wrong_method_is_405)·자체 테스트 통과, ALL CHECKS PASSED — 46_capstone-3_p1.rs. 위 응답 표와 변형 문제의 답은 같은 파일의 사본에 출력 줄이나 한 줄 수정을 넣어 실행한 결과다.

변형 문제

변형 1. create_link의 인자를 body: Result<Json<CreateLink>, JsonRejection> 대신 Json(req): Json<CreateLink>로 바꾸고 let Json(req) = body?; 줄을 지운다. 컴파일되는가? Content-Type: text/plain으로 보낸 요청의 응답은 어떻게 바뀌고, 어느 테스트가 실패하는가?

답

컴파일된다. 상태 코드는 그대로 415다. 바뀌는 것은 본문이다. 핸들러가 불리지 않고 axum의 기본 거절 응답이 나가서 content-type: text/plain; charset=utf-8에 본문은 Expected request with `Content-Type: application/json`(54바이트)가 된다. 400도 같은 식으로 평문이 된다. bad_requests_get_json_errors가 실패한다. 상태 코드 검사는 통과하지만 json_of가 평문을 JSON으로 읽다가 unwrap에서 패닉한다. 상태 코드만 보는 테스트였다면 이 회귀를 놓쳤을 것이다.

변형 2. GET /links와 DELETE /abcdefg의 405도 우리 JSON 모양으로 답하게 하려면 라우터에 무엇을 더하는가? Allow 헤더는 남는가?

답

Router::method_not_allowed_fallback에 핸들러를 준다. 경로에 등록된 메서드 라우터가 있는데 메서드가 틀렸을 때만 불린다11.

async fn method_not_allowed() -> (StatusCode, Json<serde_json::Value>) {
    (StatusCode::METHOD_NOT_ALLOWED,
     Json(serde_json::json!({ "error": { "kind": "method_not_allowed", "message": "method not allowed" } })))
}
// router(): .fallback(not_found).method_not_allowed_fallback(method_not_allowed)

실행해 보면 GET /links는 405에 content-type: application/json, allow: POST, 본문 {"error":{"kind":"method_not_allowed","message":"method not allowed"}}를 받는다. DELETE /abcdefg는 allow: GET,HEAD가 남는다. Allow 헤더는 axum이 그대로 붙여 준다.

변형 3. create_link의 두 인자 순서를 바꿔 body를 먼저, State(st)를 나중에 쓴다. 컴파일되는가?

답

컴파일되지 않는다. 함수 자체는 문제없지만, 라우터에 등록하는 post(create_link::<S>) 줄에서 E0277이 난다. 실제 메시지는 the trait bound `fn(Result<Json<_>, _>, _) -> _ {create_link::<S>}: Handler<_, _>` is not satisfied이고, 덧붙인 설명은 Consider using #[axum::debug_handler] to improve the error message다. 본문을 소비하는 추출기는 마지막 인자만 될 수 있기 때문이다8. 메시지가 원인을 직접 말해 주지 않으므로, 핸들러 에러가 이 모양이면 #[axum::debug_handler]를 붙여 다시 컴파일해 본다.

  1. std 문서, trait Clone의 “Derivable”(제네릭 구조체에서는 매개변수에 Clone 경계를 붙여 조건부로 구현) — https://doc.rust-lang.org/std/clone/trait.Clone.html ↩

  2. axum 문서, struct State(공유 상태는 Arc로 감싼다, FromRef<T> for T는 T: Clone을 요구) — https://docs.rs/axum/latest/axum/extract/struct.State.html ↩

  3. axum 0.8.9 문서, extract 모듈의 추출기 거절(rejection) 설명(Result<Json<Value>, JsonRejection>로 받으면 요청이 자동으로 거절되지 않고 핸들러가 거절 값을 받는다) — https://docs.rs/axum/0.8.9/axum/extract/index.html ↩

  4. serde 문서, Container attributes의 deny_unknown_fields(“by default unknown fields are ignored for self-describing formats like JSON”) — https://serde.rs/container-attrs.html ↩

  5. axum 0.8.9 문서, struct Redirect(to는 303, temporary는 307로 메서드와 본문 유지, permanent는 308) — https://docs.rs/axum/0.8.9/axum/response/struct.Redirect.html ↩

  6. RFC 9110 HTTP Semantics, 15.1 Overview of Status Codes(따로 지시 없이 캐시될 수 있는 상태 코드 목록에 308이 있고, 나머지는 아니다)와 15.4 Redirection 3xx(307·308은 메서드를 바꾸지 않는 리다이렉트) — https://www.rfc-editor.org/rfc/rfc9110.html ↩

  7. axum 문서, error_handling 모듈(서비스의 에러 타입은 Infallible, 에러가 hyper에 닿으면 응답 없이 연결이 끊긴다) — https://docs.rs/axum/latest/axum/error_handling/index.html ↩

  8. axum 0.8.9 문서, struct Json(거절 조건 네 가지, 추출기가 여럿이면 Json은 마지막이어야 한다) — https://docs.rs/axum/0.8.9/axum/struct.Json.html ↩ ↩2

  9. axum 0.8.9 소스, impl IntoResponse for Redirect(HeaderValue::try_from 실패 시 INTERNAL_SERVER_ERROR) — https://docs.rs/axum/0.8.9/src/axum/response/redirect.rs.html ↩

  10. Tokio Blog, “Announcing axum 0.8.0”(2025-01-01), 경로 문법 /:single → /{single} — https://tokio.rs/blog/2025-01-01-announcing-axum-0-8-0 ↩

  11. axum 0.8.9 문서, struct Router의 fallback·method_not_allowed_fallback·with_state(Router<()>만 axum::serve에 넘길 수 있다) — https://docs.rs/axum/0.8.9/axum/struct.Router.html ↩ ↩2 ↩3

  12. tower 0.5.3 문서, trait ServiceExt::oneshot(“Consume this Service, calling it with the provided request once it is ready”) — https://docs.rs/tower/0.5.3/tower/trait.ServiceExt.html ↩