라우팅
라우트는 HTTP 메서드와 경로 패턴을 핸들러에 매핑하며, 하나의 문장으로 등록된다.
get, post, put, patch, delete가 각각 하나를 등록한다.
HEAD와 OPTIONS 요청에는 자동으로 답하므로, 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는 같은 경로다.
안쪽에 빈 세그먼트가 있거나 ., .. 세그먼트가 있는 요청 경로는, 그대로 쓰였든 퍼센트 인코딩되었든, 모든 서버에서 라우팅 전에 400으로 답한다.
이 400은 앱 자신의 응답이므로, 모든 서버에서 statusPage(HttpStatus.BAD_REQUEST, …)가 그 본문을 만든다.
따라서 /a//b와 /files/../secret은 변수를 ""로, 꼬리 변수를 ../secret으로 바인딩하는 일이 없다.
마지막의 는 경로의 나머지에 매칭되며, 아무것도 없는 경우도 포함한다.
따라서 /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*} 꼬리 변수도 같은 방식으로 읽으며, 비어 있는 채로 돌아올 수 있는 유일한 변수다.
변수 이름은 이름 붙은 꼬리 변수를 포함해 한 패턴 안에서 한 번만 쓴다.
/tenants/{id}/items/{id}와 /files/{id}/\{id*}는 등록 시점에 IllegalArgumentException을 던진다.
예외 메시지는 패턴과 반복된 변수를 밝힌다.
두 번째 바인딩은 첫 번째를 덮어쓰므로, 핸들러가 엉뚱한 식별자를 읽게 된다.
그룹의 접두사는 패턴의 일부이므로, 접두사 /tenants/{id} 아래의 라우트 /items/{id}도 예외를 던진다.
필터 경로와 Cors.forPath도 같은 방식으로 검사한다.
변수에는 이름이 있어야 한다.
{}와 {}는 등록 시점에 IllegalArgumentException을 던지는데, 그대로 두면 ""나 라는 이름으로 세그먼트 하나에 매칭되기 때문이다.
경로의 나머지에 매칭해야 하는 패턴은 나 \{path} 같은 이름 붙은 꼬리 변수로 끝난다.
그룹
접두사를 공유하는 라우트는 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()은 등록을 다시 열며, 다음 시작은 그 사이에 등록된 것을 서비스한다.
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()는 등록된 것만 보고한다.