라우팅

라우트는 HTTP 메서드와 경로 패턴을 핸들러에 매핑하며, 하나의 문장으로 등록된다. get, post, put, patch, delete`가 각각 하나를 등록한다. `head`와 `options`는 자동으로 답하므로, 등록하는 일이 드물다. 라우팅 테이블은 명시적인 목록이다. 애너테이션 스캔이 없으므로 등록된 것만 실행되며, `app.routes()가 그 목록을 데이터로 돌려준다. 이 메서드들은 각각 경로와 핸들러 사이에 설명도 받는데, 경로 자체가 말해 주지 않는 것을 위해서다.

경로 패턴

app.get("/decks", decks::list);                           // this path, exactly
app.get("/decks/{deckId}", decks::show);                  // {deckId} matches one segment
app.get("/files/{path*}", files::serve);                  // {path*} matches the rest of the path
app.beforeRoute("/admin/*", req -> requireAdmin(req));    // matched routes under /admin

패턴은 세그먼트 단위로 비교된다. 정규 표현식은 없다. {name} 세그먼트는 정확히 하나의 세그먼트에 매칭되고 슬래시에는 절대 매칭되지 않으므로, `/decks/{deckId}`는 `/decks/3`에 매칭되지만 `/decks`나 `/decks/3/cards`에는 매칭되지 않는다. 요청의 끝 슬래시는 무시된다: `/decks/`와 `/decks`는 같은 경로다.

마지막의 `는 경로의 나머지에 매칭되며, 아무것도 없는 경우도 포함한다. 따라서 `/admin/`은 `/admin, /admin/users, `/admin/users/7`을 포함하며, 이것이 필터 경로가 보통 뜻하는 바다: 접두사, 그리고 접두사 자체. ``는 마지막 세그먼트로만 허용된다. 라우트도 하나를 가질 수 있다. 순수한 ``는 아무것도 캡처하지 않으므로, 나머지가 필요한 핸들러는 대신 이름 붙은 꼬리 변수를 등록한다.

마지막의 \{name*}`는 ``가 매칭하는 것과 정확히 같은 것에 매칭되고, 매칭한 것을 바인딩한다. 따라서 `/files/\{path}`는 `/files, /files/a.txt, /files/docs/2026/a.txt`를 포함하며, `req.pathParam("path")`는 차례로 `"", a.txt, `docs/2026/a.txt`를 읽는다. 꼬리 변수 안의 슬래시는 보존되는데, 꼬리 변수는 세그먼트가 아니라 나머지이기 때문이다. 빈 꼬리 변수도 ``와 정확히 같이 매칭되므로, `/files`와 `/files/`는 변수가 빈 문자열로 바인딩된 채 라우트에 도달한다. 이름 붙은 꼬리 변수는 마지막 세그먼트로만 허용된다. `/files/`와 `/files/\{path*}`는 같은 요청에 매칭되므로, 하나의 메서드로 둘 다 등록하면 다른 동일한 형태의 쌍과 같은 방식으로 예외를 던진다.

경로 변수

핸들러는 {name} 세그먼트가 매칭한 것을 이름으로 읽고, 읽는 지점에서 타입을 밝힌다:

long deckId = req.pathParamLong("deckId");   // non-numeric input becomes a 400
String name = req.pathParam("name");
Direction direction = req.pathParamEnum("direction", Direction.class);

핸들러가 실행될 때 값은 항상 존재하는데, 그 세그먼트가 없는 요청은 라우트에 매칭되지 않았을 것이기 때문이다. 따라서 400은 존재가 아니라 타입을 다룬다. \{name*} 꼬리 변수도 같은 방식으로 읽으며, 비어 있는 채로 돌아올 수 있는 유일한 변수다.

그룹

접두사를 공유하는 라우트는 `path`를 통해 등록한다. 그룹은 주변 상태가 아니라 인자로 도착한다:

app.path("/api/decks", decks -> {
    decks.beforeRoute(req -> requireApiKey(req));    // guards matched routes under /api/decks
    decks.get("", api::listDecks);                   // GET  /api/decks
    decks.get("/{deckId}", api::showDeck);           // GET  /api/decks/{deckId}
    decks.path("/{deckId}/cards", cards ->           // groups nest, prefixes concatenate
        cards.get("", api::listCards));              // GET  /api/decks/{deckId}/cards
});

빈 경로 또는 `"/"`는 접두사 자체를 등록한다. 그룹의 `beforeRoute`와 `afterRoute`는 자체 경로가 주어지지 않으면 그룹의 매칭된 라우트를 필터링한다. `beforeRequest`는 같은 접두사를 쓰지만 정적 파일과 매칭되지 않은 경로도 다루며, 라우트 변수를 쓸 수 있기 전에 실행된다. 접두사는 등록 시점에 해석되므로, `app.routes()`는 전체 경로를 보고한다. 그룹은 라우트를 쓰는 방식을 바꿀 뿐, 등록되는 것을 바꾸지 않는다.

매칭 순서

겹치는 패턴 사이의 우선순위 충돌은 등록 순서로 해소된다. 따라서 변수 라우트보다 먼저 등록된 리터럴 라우트가 계속 이긴다:

app.get("/study/today", study::today);      // wins for /study/today
app.get("/study/{mode}", study::byMode);    // everything else under /study

정확히 같은 요청에 매칭되는 두 번째 라우트를 등록하면 그 자리에서 `IllegalStateException`을 던진다. 이는 같은 경로와, 변수 이름만 바꾼 같은 형태를 포함한다. 그런 라우트는 결코 실행될 수 없으므로, 테이블에 죽은 항목을 남기는 대신 등록 시점에 예외로 보고한다. `AppServlet`이 애플리케이션을 서비스하는 동안에는 등록이 닫히며, `start()`든, 직접 시작한 서버든, 외부 컨테이너가 실행하든 마찬가지다. 서블릿은 초기화될 때 가져온 테이블의 사본을 서비스하므로, 그때 추가된 라우트는 결코 답하지 못하며, 대신 `IllegalStateException`을 던진다. `stop()`은 등록을 다시 열며, 다음 시작은 그 사이에 등록된 것을 서비스한다.

아무것도 매칭되지 않을 때

어떤 라우트도 모르는 경로는 404로 답한다. 다른 메서드만 아는 경로는 405로 답하며, 동작했을 메서드를 나열한 Allow 헤더를 붙인다. GET이나 HEAD에서는 404 전에 정적 파일을 확인한다: 라우트가 먼저이므로, 라우트가 파일을 가릴 수 있다. `error(HttpStatus.NOT_FOUND, handler)`는 404가 답할 본문을 등록한다. 에러 핸들러가 본문을 채우는 방법은 필터와 에러에서 다룬다.

HEAD와 OPTIONS

둘 다 아무것도 등록하지 않아도 답한다. HEAD`는 `GET 라우트를 실행하고 본문을 버린다. 헤더는 유지되며, GET`이 보냈을 내용으로 계산한 `Content-Length`도 포함한다. `OPTIONS`는 경로의 라우트가 함의하는 `Allow 헤더로 답하며, 경로에 라우트가 없으면 404로 답한다:

$ curl -X OPTIONS -i localhost:8080/decks
HTTP/1.1 200 OK
Allow: GET, POST, HEAD, OPTIONS

자동 답이 원하는 것이 아닐 때는 `app.head(…​)`나 `app.options(…​)`로 직접 라우트를 등록하며, 보통 CORS 프리플라이트가 그 경우다. 두 자동 답 모두 `app.routes()`에 나타나지 않으며, `app.routes()`는 등록된 것만 보고한다.