프런트 컨트롤러 발전
정확한 HTTP 메서드와 컨텍스트 상대 원시 경로를 등록하고, Found·405+Allow·404를 구분하는 프런트 컨트롤러의 실행 순서와 Servlet 어댑터 경계를 검증합니다.
URL마다 서블릿을 만들면 매핑과 컨트롤러 호출, 논리 뷰 선택, 실패 처리가 각 진입점에 흩어집니다. 프런트 컨트롤러(front controller)는 공통 입구 하나에서 실행 순서와 HTTP 결과를 조정합니다. 그렇다고 입력 검증과 업무 규칙, 템플릿 렌더링까지 모두 떠안는 만능 객체가 되어서는 안 됩니다. 애플리케이션의 동작은 컨트롤러가, 타입이 있는 모델과 정확한 논리 뷰 선택은 MVC 역할 경계에서 만든 구조가, 실제 렌더링과 출력 문맥의 이스케이프는 서블릿·JSP 책임에서 다룬 뷰가 맡습니다.
Front Controller는 실행 순서만 소유한다
closed mapping Found 뒤 controller·ModelView·registry·fixed View를 실행하고 실패는 모두 생략한다.
REQUEST → CLOSED MAPPING → DIRECT CONTROLLER → VIEW
Front Controller는 실행 순서만 소유한다
공통 진입점은 exact application key를 만들고 닫힌 매핑 결과에 따라 직접 controller를 호출한 뒤 기존 ModelView·view 경계를 순서대로 연결합니다. 각 전략은 자신의 결정을 그대로 소유합니다.
이 선은 Found 경로만 연결합니다. non-Found에서는 handler·query·View 호출이 0이며, template를 실행하거나 안전한 HTML을 생성했다는 검증으로 해석하지 않습니다.
01 · EXACT REQUEST KEY
raw-path adapter가 case-preserving application key를 만든다
닫힌 method enum과
raw request URI − context path를 결합합니다. query
string은 key에 섞지 않으며 decode·URI template·matrix variable은
이 작은 mapper의 범위 밖입니다.
02 · CLOSED HANDLER MAPPING
mapping은 nullable handler가 아니라 닫힌 결과를 반환한다
Found일 때만 controller 단계로 갑니다. method만
다르면 405와 Allow, path도 없으면 404로
handler·query·view 호출 전에 종료합니다.
03 · DIRECT PAGECONTROLLER
이 단계는 HandlerAdapter 없이 PageController를 직접 호출한다
controller는 앞 문서에서 확정한 typed request·page-model 경계를
재사용해 고정 ModelView를 반환합니다. Front
Controller는 업무 규칙이나 view 결정을 대신 구현하지 않습니다.
04 · EXISTING VIEW BOUNDARIES
exact registry와 fixed View 경계를 순서대로 호출한다
이 예제는 등록된 논리 이름을 fixed test View로 연결해 primitive marker가 response에 도달하는 순서만 증명합니다. 실제 rendering과 output-context escaping 검증은 앞 문서, exception resolution은 ch5-9가 소유합니다.
URL별 Servlet에 흩어진 절차를 공통 entry로 모으되 mapping, handler,
registry, view의 판단까지 service에 흡수하지 않습니다.
HandlerAdapter는 ch5-8, exception resolver는 ch5-9에서 확장합니다.
이 예의 조건
- 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
- 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
- raw key에서 closed mapping을 조회합니다. Found일 때만 controller, ModelView, exact registry, fixed test View를 거쳐 primitive marker를 응답합니다. 실패 분기는 모든 downstream 호출을 건너뜁니다.
- 기존 데스크톱 그림과 같은 일곱 노드와 여섯 관계다. 원시 method와 path로 매핑하고 Found일 때만 controller를 직접 호출한다. ModelView, exact registry, fixed test View를 거쳐 text marker 응답을 쓴다. nonFound이면 이 downstream을 호출하지 않으며 실제 template 출력은 별도 검증이다.
관계도의 보충 설명
| 순번 | 원문 표시 |
|---|---|
| 22 | 이 fixture는 실제 template을 실행하지 않음 |
| 24 | 기존 데스크톱 그림과 같은 일곱 노드와 여섯 관계다. 원시 method와 path로 매핑하고 Found일 때만 controller를 직접 호출한다. ModelView, exact registry, fixed test View를 거쳐 text marker 응답을 쓴다. nonFound이면 이 downstream을 호출하지 않으며 실제 template 출력은 별도 검증이다. |
| 25 | 이 선은 Found 경로만 연결합니다. non-Found에서는 handler·query·View 호출이 0이며, template을 실행하거나 안전한 HTML을 생성했다는 검증으로 해석하지 않습니다. |
| 장면 | 관찰 값 | 전환 이유 |
|---|---|---|
| closed mapping raw key로 closed mapping을 조회합니다. | Found: 진행 · 실패: downstream 생략 | 사용할 controller가 허용 집합에서 선택됩니다. |
| controller·ModelView Found일 때만 controller와 ModelView를 실행합니다. | controller: Found만 · 산출물: ModelView | 명시적 화면 결정이 생성됩니다. |
| registry·fixed View exact registry와 fixed test View가 primitive marker를 만듭니다. | 출력: primitive marker · 실패: 실행 없음 | 선택된 화면 경로만 응답으로 이어집니다. |
Front Controller는 실행 순서만 소유한다
closed mapping Found 뒤 controller·ModelView·registry·fixed View를 실행하고 실패는 모두 생략한다.
REQUEST → CLOSED MAPPING → DIRECT CONTROLLER → VIEW
Front Controller는 실행 순서만 소유한다
공통 진입점은 exact application key를 만들고 닫힌 매핑 결과에 따라 직접 controller를 호출한 뒤 기존 ModelView·view 경계를 순서대로 연결합니다. 각 전략은 자신의 결정을 그대로 소유합니다.
이 선은 Found 경로만 연결합니다. non-Found에서는 handler·query·View 호출이 0이며, template를 실행하거나 안전한 HTML을 생성했다는 검증으로 해석하지 않습니다.
01 · EXACT REQUEST KEY
raw-path adapter가 case-preserving application key를 만든다
닫힌 method enum과
raw request URI − context path를 결합합니다. query
string은 key에 섞지 않으며 decode·URI template·matrix variable은
이 작은 mapper의 범위 밖입니다.
02 · CLOSED HANDLER MAPPING
mapping은 nullable handler가 아니라 닫힌 결과를 반환한다
Found일 때만 controller 단계로 갑니다. method만
다르면 405와 Allow, path도 없으면 404로
handler·query·view 호출 전에 종료합니다.
03 · DIRECT PAGECONTROLLER
이 단계는 HandlerAdapter 없이 PageController를 직접 호출한다
controller는 앞 문서에서 확정한 typed request·page-model 경계를
재사용해 고정 ModelView를 반환합니다. Front
Controller는 업무 규칙이나 view 결정을 대신 구현하지 않습니다.
04 · EXISTING VIEW BOUNDARIES
exact registry와 fixed View 경계를 순서대로 호출한다
이 예제는 등록된 논리 이름을 fixed test View로 연결해 primitive marker가 response에 도달하는 순서만 증명합니다. 실제 rendering과 output-context escaping 검증은 앞 문서, exception resolution은 ch5-9가 소유합니다.
URL별 Servlet에 흩어진 절차를 공통 entry로 모으되 mapping, handler,
registry, view의 판단까지 service에 흡수하지 않습니다.
HandlerAdapter는 ch5-8, exception resolver는 ch5-9에서 확장합니다.
이 예의 조건
- 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
- 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
- raw key에서 closed mapping을 조회합니다. Found일 때만 controller, ModelView, exact registry, fixed test View를 거쳐 primitive marker를 응답합니다. 실패 분기는 모든 downstream 호출을 건너뜁니다.
- 기존 데스크톱 그림과 같은 일곱 노드와 여섯 관계다. 원시 method와 path로 매핑하고 Found일 때만 controller를 직접 호출한다. ModelView, exact registry, fixed test View를 거쳐 text marker 응답을 쓴다. nonFound이면 이 downstream을 호출하지 않으며 실제 template 출력은 별도 검증이다.
관계도의 보충 설명
| 순번 | 원문 표시 |
|---|---|
| 22 | 이 fixture는 실제 template을 실행하지 않음 |
| 24 | 기존 데스크톱 그림과 같은 일곱 노드와 여섯 관계다. 원시 method와 path로 매핑하고 Found일 때만 controller를 직접 호출한다. ModelView, exact registry, fixed test View를 거쳐 text marker 응답을 쓴다. nonFound이면 이 downstream을 호출하지 않으며 실제 template 출력은 별도 검증이다. |
| 25 | 이 선은 Found 경로만 연결합니다. non-Found에서는 handler·query·View 호출이 0이며, template을 실행하거나 안전한 HTML을 생성했다는 검증으로 해석하지 않습니다. |
| 장면 | 관찰 값 | 전환 이유 |
|---|---|---|
| closed mapping raw key로 closed mapping을 조회합니다. | Found: 진행 · 실패: downstream 생략 | 사용할 controller가 허용 집합에서 선택됩니다. |
| controller·ModelView Found일 때만 controller와 ModelView를 실행합니다. | controller: Found만 · 산출물: ModelView | 명시적 화면 결정이 생성됩니다. |
| registry·fixed View exact registry와 fixed test View가 primitive marker를 만듭니다. | 출력: primitive marker · 실패: 실행 없음 | 선택된 화면 경로만 응답으로 이어집니다. |
그림은 프런트 컨트롤러가 실행 순서만 맡는 모습을 보여 줍니다. 요청에서 대소문자를 보존한 메서드와 컨텍스트 기준의 원시 경로로 키를 만들고 닫힌 매핑 결과를 조회합니다. 결과가 Found일 때만 PageController를 직접 호출해 ModelView를 받고, 정확한 뷰 레지스트리와 고정된 테스트 뷰를 거쳐 응답에 표식을 씁니다. Found가 아니면 핸들러와 조회, 뷰를 한 번도 호출하지 않습니다. HandlerAdapter는 다음 글에서, 예외 해석은 5장 마지막 글에서 더합니다.
정확한 라우트 키
이 글의 작은 매핑은 HTTP 메서드와 컨텍스트 기준의 원시 경로만 키로 씁니다.
- HTTP 메서드 토큰은 대소문자를 보존합니다.
GET과get을 같은 값으로 만드는 대문자 변환은 하지 않습니다. HttpServletRequest.getRequestURI()와getContextPath()는 컨테이너가 디코딩하지 않은 값을 돌려줍니다. 요청 URI에서 디코딩하지 않은 컨텍스트 경로의 길이만큼 정확히 잘라 애플리케이션 경로를 얻습니다.- 요청 URI는
?앞에서 끝나므로 쿼리 문자열은 라우트 키에 들어가지 않습니다.%2F도/로 다시 디코딩하지 않습니다. - URI 템플릿과 경로 변수, 행렬 변수(matrix variable), 헤더 조건,
consumes,produces는 이 작은 매퍼가 다루지 않습니다.
이미 만들어진 Map을 생성자에 넘기면, 그 전에 중복 키가 마지막 값으로 덮였는지 알 수 없습니다. 그래서 등록 목록을 받아, 같은 메서드와 경로가 두 번 나오면 요청을 받기 전에 실패시킵니다.
예를 들어 ServletTarget에 요청 URI /board/posts/%2Fdetail과 컨텍스트 경로 /board를 주면, 앞의 여섯 글자를 잘라 낸 원시 경로는 /posts/%2Fdetail입니다. 쿼리 값이 trace=one에서 trace=two로 바뀌어도 이 키는 그대로이고, %2F를 /로 바꾸지 않으므로 /posts/detail의 등록과도 다른 키입니다. 이것은 단위 객체에서의 문자열 계산일 뿐이며, 실제 HTTP 요청의 인코딩된 슬래시를 컨테이너가 받아들인다는 뜻은 아닙니다.
원시 키를 보존하는 것은 사용자가 뜻했을 것 같은 경로를 매핑 계층에서 임의로 고치지 않기 위해서입니다. /boardwalk/posts는 글자로는 /board로 시작하지만, 다음 글자가 /가 아니므로 이 예제의 컨텍스트 경로 검사에서 실패합니다. 접두사가 같다는 것과 경로의 구간이 맞는다는 것을 같은 검사로 다루지 않습니다.
더 깊이: 인코딩된 슬래시를 Tomcat은 어떻게 다룰까
위의 %2F 예가 단위 객체의 계산일 뿐이라고 한 데는 이유가 있습니다. Tomcat 11의 HTTP 커넥터 문서에서 encodedSolidusHandling의 기본값은 reject이고, 이때 요청 경로에 %2f가 들어 있으면 Tomcat이 400으로 거절합니다. 그래서 기본 설정의 내장 Tomcat에서는 /board/posts/%2Fdetail 같은 요청이 이 프런트 컨트롤러까지 오지 않습니다. 값을 decode로 바꾸면 다른 %nn과 함께 /로 디코딩되어 경로 조각의 수가 달라지고, passthrough로 바꾸면 %2f가 그대로 남는 대신 나머지 디코딩을 애플리케이션이 맡아야 하며 이때는 %25도 바꾸지 않고 넘깁니다.
역슬래시도 비슷한 설정을 따로 가집니다. %5c를 다루는 encodedReverseSolidusHandling의 기본값은 decode이고, 역슬래시를 경로 구분자로 받아들일지 정하는 allowBackslash의 기본값은 false입니다. 서블릿 6.1 명세가 정한 의심스러운 URI 패턴을 거절하는 rejectSuspiciousURIs는 기본값이 false입니다. 이런 값을 바꾸면 같은 바이트가 다른 경로로 해석되므로, 프록시와 컨테이너, 애플리케이션의 라우팅이 모두 같은 규칙을 쓰는지 함께 확인해야 합니다.
한 파일로 실행 순서를 확인합니다
다음 파일 하나에 라우트 키와 등록 테이블, 닫힌 매핑 결과, 순수 프런트 컨트롤러, 서블릿 어댑터, 고정 뷰와 일곱 개의 테스트가 모두 들어 있습니다. 파일이 길므로 아래쪽의 타입 정의부터 읽으면 쉽습니다. RouteKey와 RouteTable이 키와 등록을, Match의 세 구현이 매핑 결과를, FrontController와 FrontControllerServlet이 순수 실행과 서블릿 연결을 맡습니다. 일곱 테스트는 맨 위에 모여 있으며, 이름이 embeddedTomcat으로 시작하는 마지막 테스트만 내장 Tomcat으로 실제 HTTP 응답을 확인합니다.
package board.mvc;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Collections;
import java.util.EnumSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.atomic.AtomicInteger;
import org.apache.catalina.startup.Tomcat;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class EmbeddedFrontControllerRoutingBoundaryTest {
@TempDir
Path tomcatBase;
@Test
void methodTokensAreCaseSensitive() {
assertThat(RouteKey.parse("GET", "/posts/%2Fdetail"))
.isEqualTo(new RouteKey(
RouteMethod.GET, "/posts/%2Fdetail"));
assertThatThrownBy(() ->
RouteKey.parse("get", "/posts/%2Fdetail"))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("unsupported method token");
assertThatThrownBy(() ->
RouteKey.parse("Get", "/posts/%2Fdetail"))
.isInstanceOf(IllegalArgumentException.class);
}
@Test
void duplicateRegistrationsFailBeforeServing() {
PageController controller = () -> new ModelView(
"post-detail", new PageModel(42L, "등록 확인"));
var duplicate = new Route(
RouteKey.parse("GET", "/posts/detail"),
controller);
assertThatThrownBy(() ->
new RouteTable(List.of(duplicate, duplicate)))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("duplicate route")
.hasMessageContaining("GET /posts/detail");
}
@Test
void mappingReturnsFoundMethodNotAllowedAndNotFound() {
PageController controller = () -> new ModelView(
"post-detail", new PageModel(42L, "매핑 결과"));
var table = new RouteTable(List.of(new Route(
new RouteKey(RouteMethod.GET, "/posts/detail"),
controller)));
assertThat(table.match(new RouteKey(
RouteMethod.GET, "/posts/detail")))
.isInstanceOf(Found.class);
var rejected = table.match(new RouteKey(
RouteMethod.PUT, "/posts/detail"));
assertThat(rejected).isInstanceOf(MethodNotAllowed.class);
assertThat(((MethodNotAllowed) rejected).allowedMethods())
.containsExactly(RouteMethod.GET);
assertThat(table.match(new RouteKey(
RouteMethod.GET, "/missing")))
.isEqualTo(new NotFound());
}
@Test
void foundInvokesHandlerThenViewExactlyOnceInOrder() {
var events = new ArrayList<String>();
var handlerCalls = new AtomicInteger();
var viewCalls = new AtomicInteger();
PageController controller = () -> {
handlerCalls.incrementAndGet();
events.add("handler");
return new ModelView(
"post-detail",
new PageModel(42L, "실행 순서"));
};
ViewBoundary view = model -> {
viewCalls.incrementAndGet();
events.add("view");
return "post-detail:" + model.postId();
};
var front = frontController(controller, view);
var result = front.dispatch(
RouteMethod.GET, "/posts/detail");
assertThat(result)
.isEqualTo(new Rendered("post-detail:42"));
assertThat(events).containsExactly("handler", "view");
assertThat(handlerCalls).hasValue(1);
assertThat(viewCalls).hasValue(1);
}
@Test
void failuresInvokeNeitherHandlerNorView() {
var handlerCalls = new AtomicInteger();
var viewCalls = new AtomicInteger();
PageController controller = () -> {
handlerCalls.incrementAndGet();
return new ModelView(
"post-detail",
new PageModel(42L, "호출되면 안 됨"));
};
ViewBoundary view = model -> {
viewCalls.incrementAndGet();
return "post-detail:" + model.postId();
};
var front = frontController(controller, view);
assertThat(front.dispatch(
RouteMethod.PUT, "/posts/detail"))
.isInstanceOf(Rejected.class);
assertThat(front.dispatch(
RouteMethod.GET, "/missing"))
.isEqualTo(new Missing());
assertThat(handlerCalls).hasValue(0);
assertThat(viewCalls).hasValue(0);
}
@Test
void rootAndNamedContextsDeriveTheSameRawPathAndExcludeQuery() {
var root = new ServletTarget(
"/posts/%2Fdetail", "", "trace=root");
var named = new ServletTarget(
"/board/posts/%2Fdetail",
"/board",
"trace=named");
assertThat(root.contextRelativeRawPath())
.isEqualTo("/posts/%2Fdetail");
assertThat(named.contextRelativeRawPath())
.isEqualTo(root.contextRelativeRawPath());
assertThat(root.queryString())
.isNotEqualTo(named.queryString());
assertThatThrownBy(() -> new ServletTarget(
"/boardwalk/posts", "/board", null)
.contextRelativeRawPath())
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("context path boundary");
}
@Test
void embeddedTomcatReturns200Then405WithExactAllowThen404()
throws Exception {
var handlerCalls = new AtomicInteger();
var viewCalls = new AtomicInteger();
PageController controller = () -> {
handlerCalls.incrementAndGet();
return new ModelView(
"post-detail",
new PageModel(42L, "컨테이너 확인"));
};
ViewBoundary view = model -> {
viewCalls.incrementAndGet();
return "post-detail:" + model.postId();
};
var servlet = new FrontControllerServlet(
frontController(controller, view));
var tomcat = new Tomcat();
tomcat.setBaseDir(tomcatBase.toString());
tomcat.setPort(0);
tomcat.getConnector();
var context = tomcat.addContext(
"/board", tomcatBase.toString());
Tomcat.addServlet(context, "front", servlet);
context.addServletMappingDecoded("/*", "front");
var started = false;
try {
tomcat.start();
started = true;
var base = "http://127.0.0.1:"
+ tomcat.getConnector().getLocalPort()
+ "/board";
var client = HttpClient.newHttpClient();
var success = client.send(
HttpRequest.newBuilder(URI.create(
base
+ "/posts/detail"
+ "?trace=ignored"))
.GET()
.build(),
HttpResponse.BodyHandlers.ofString(
StandardCharsets.UTF_8));
var rejected = client.send(
HttpRequest.newBuilder(URI.create(
base + "/posts/detail"))
.PUT(HttpRequest.BodyPublishers.noBody())
.build(),
HttpResponse.BodyHandlers.ofString(
StandardCharsets.UTF_8));
var missing = client.send(
HttpRequest.newBuilder(URI.create(
base + "/missing"))
.GET()
.build(),
HttpResponse.BodyHandlers.ofString(
StandardCharsets.UTF_8));
assertThat(success.statusCode()).isEqualTo(200);
assertThat(success.body()).isEqualTo("post-detail:42");
assertThat(rejected.statusCode()).isEqualTo(405);
assertThat(rejected.headers().firstValue("Allow"))
.contains("GET, HEAD, OPTIONS");
assertThat(missing.statusCode()).isEqualTo(404);
assertThat(missing.headers().firstValue("Allow"))
.isEmpty();
assertThat(handlerCalls).hasValue(1);
assertThat(viewCalls).hasValue(1);
} finally {
if (started) {
tomcat.stop();
}
tomcat.destroy();
}
}
private static FrontController frontController(
PageController controller,
ViewBoundary view) {
var routes = new RouteTable(List.of(new Route(
new RouteKey(
RouteMethod.GET, "/posts/detail"),
controller)));
return new FrontController(
routes, new ExactViewRegistry(view));
}
enum RouteMethod {
GET,
POST,
PUT,
DELETE,
PATCH;
static RouteMethod parseExact(String token) {
Objects.requireNonNull(token, "method token");
for (var method : values()) {
if (method.name().equals(token)) {
return method;
}
}
throw new IllegalArgumentException(
"unsupported method token: " + token);
}
}
record RouteKey(RouteMethod method, String rawPath) {
RouteKey {
Objects.requireNonNull(method, "method");
Objects.requireNonNull(rawPath, "rawPath");
if (!rawPath.startsWith("/")) {
throw new IllegalArgumentException(
"raw path must start with /");
}
if (rawPath.indexOf('?') >= 0) {
throw new IllegalArgumentException(
"query is not part of a route key");
}
}
static RouteKey parse(String method, String rawPath) {
return new RouteKey(
RouteMethod.parseExact(method), rawPath);
}
@Override
public String toString() {
return method + " " + rawPath;
}
}
record Route(RouteKey key, PageController controller) {
Route {
Objects.requireNonNull(key, "key");
Objects.requireNonNull(controller, "controller");
}
}
@FunctionalInterface
interface PageController {
ModelView handle();
}
record PageModel(long postId, String title) {
PageModel {
if (postId <= 0) {
throw new IllegalArgumentException(
"postId must be positive");
}
Objects.requireNonNull(title, "title");
}
}
record ModelView(String logicalView, PageModel model) {
ModelView {
Objects.requireNonNull(logicalView, "logicalView");
Objects.requireNonNull(model, "model");
}
}
@FunctionalInterface
interface ViewBoundary {
String render(PageModel model);
}
static final class ExactViewRegistry {
private final Map<String, ViewBoundary> views;
ExactViewRegistry(ViewBoundary postDetailView) {
views = Map.of(
"post-detail",
Objects.requireNonNull(
postDetailView, "postDetailView"));
}
ViewBoundary resolve(String logicalView) {
var view = views.get(logicalView);
if (view == null) {
throw new IllegalArgumentException(
"unregistered logical view: "
+ logicalView);
}
return view;
}
}
sealed interface Match
permits Found, MethodNotAllowed, NotFound {}
record Found(PageController controller) implements Match {
Found {
Objects.requireNonNull(controller, "controller");
}
}
record MethodNotAllowed(Set<RouteMethod> allowedMethods)
implements Match {
MethodNotAllowed {
Objects.requireNonNull(
allowedMethods, "allowedMethods");
if (allowedMethods.isEmpty()) {
throw new IllegalArgumentException(
"allowedMethods must not be empty");
}
allowedMethods = Collections.unmodifiableSet(
EnumSet.copyOf(allowedMethods));
}
}
record NotFound() implements Match {}
static final class RouteTable {
private final Map<RouteKey, PageController> handlers;
private final Map<String, Set<RouteMethod>> methodsByPath;
RouteTable(List<Route> registrations) {
Objects.requireNonNull(
registrations, "registrations");
var mutableHandlers =
new LinkedHashMap<RouteKey, PageController>();
var mutableMethods =
new LinkedHashMap<String, EnumSet<RouteMethod>>();
for (var route : registrations) {
Objects.requireNonNull(route, "route");
if (mutableHandlers.putIfAbsent(
route.key(), route.controller())
!= null) {
throw new IllegalArgumentException(
"duplicate route: " + route.key());
}
mutableMethods
.computeIfAbsent(
route.key().rawPath(),
ignored -> EnumSet.noneOf(
RouteMethod.class))
.add(route.key().method());
}
handlers = Collections.unmodifiableMap(
new LinkedHashMap<>(mutableHandlers));
var frozenMethods =
new LinkedHashMap<String, Set<RouteMethod>>();
mutableMethods.forEach((path, methods) ->
frozenMethods.put(
path,
Collections.unmodifiableSet(
EnumSet.copyOf(methods))));
methodsByPath = Collections.unmodifiableMap(
frozenMethods);
}
Match match(RouteKey key) {
var controller = handlers.get(key);
if (controller != null) {
return new Found(controller);
}
var allowed = methodsByPath.get(key.rawPath());
if (allowed != null) {
return new MethodNotAllowed(allowed);
}
return new NotFound();
}
Optional<Set<RouteMethod>> registeredMethods(
String rawPath) {
return Optional.ofNullable(
methodsByPath.get(rawPath));
}
}
sealed interface DispatchResult
permits Rendered, Rejected, Missing {}
record Rendered(String marker) implements DispatchResult {
Rendered {
Objects.requireNonNull(marker, "marker");
}
}
record Rejected(Set<RouteMethod> allowedMethods)
implements DispatchResult {
Rejected {
allowedMethods = Collections.unmodifiableSet(
EnumSet.copyOf(allowedMethods));
}
}
record Missing() implements DispatchResult {}
static final class FrontController {
private final RouteTable routes;
private final ExactViewRegistry views;
FrontController(
RouteTable routes,
ExactViewRegistry views) {
this.routes = Objects.requireNonNull(
routes, "routes");
this.views = Objects.requireNonNull(
views, "views");
}
DispatchResult dispatch(
RouteMethod method, String rawPath) {
return switch (routes.match(
new RouteKey(method, rawPath))) {
case Found found -> {
var modelView =
found.controller().handle();
var view = views.resolve(
modelView.logicalView());
yield new Rendered(
view.render(modelView.model()));
}
case MethodNotAllowed rejected ->
new Rejected(rejected.allowedMethods());
case NotFound ignored -> new Missing();
};
}
Optional<Set<RouteMethod>> registeredMethods(
String rawPath) {
return routes.registeredMethods(rawPath);
}
}
record ServletTarget(
String requestUri,
String contextPath,
String queryString) {
ServletTarget {
Objects.requireNonNull(requestUri, "requestUri");
Objects.requireNonNull(contextPath, "contextPath");
if (requestUri.indexOf('?') >= 0) {
throw new IllegalArgumentException(
"requestUri must end before ?");
}
}
String contextRelativeRawPath() {
if (!contextPath.isEmpty()
&& (!requestUri.startsWith(contextPath)
|| (requestUri.length()
> contextPath.length()
&& requestUri.charAt(
contextPath.length())
!= '/'))) {
throw new IllegalArgumentException(
"request URI violates context path boundary");
}
var rawPath = requestUri.substring(
contextPath.length());
return rawPath.isEmpty() ? "/" : rawPath;
}
}
static final class FrontControllerServlet
extends HttpServlet {
private final FrontController front;
FrontControllerServlet(FrontController front) {
this.front = Objects.requireNonNull(
front, "front");
}
@Override
protected void doGet(
HttpServletRequest request,
HttpServletResponse response)
throws IOException {
dispatch(RouteMethod.GET, request, response);
}
@Override
protected void doPost(
HttpServletRequest request,
HttpServletResponse response)
throws IOException {
dispatch(RouteMethod.POST, request, response);
}
@Override
protected void doPut(
HttpServletRequest request,
HttpServletResponse response)
throws IOException {
dispatch(RouteMethod.PUT, request, response);
}
@Override
protected void doDelete(
HttpServletRequest request,
HttpServletResponse response)
throws IOException {
dispatch(RouteMethod.DELETE, request, response);
}
@Override
protected void doPatch(
HttpServletRequest request,
HttpServletResponse response)
throws IOException {
dispatch(RouteMethod.PATCH, request, response);
}
@Override
protected void doOptions(
HttpServletRequest request,
HttpServletResponse response) {
var target = target(request);
var registered = front.registeredMethods(
target.contextRelativeRawPath());
if (registered.isEmpty()) {
response.setStatus(
HttpServletResponse.SC_NOT_FOUND);
return;
}
response.setHeader(
"Allow", effectiveAllow(registered.orElseThrow()));
response.setStatus(HttpServletResponse.SC_OK);
}
private void dispatch(
RouteMethod method,
HttpServletRequest request,
HttpServletResponse response)
throws IOException {
var target = target(request);
switch (front.dispatch(
method, target.contextRelativeRawPath())) {
case Rendered rendered -> {
response.setStatus(HttpServletResponse.SC_OK);
response.setContentType("text/plain");
response.setCharacterEncoding(
StandardCharsets.UTF_8.name());
response.getWriter().write(
rendered.marker());
}
case Rejected rejected -> {
response.setHeader(
"Allow",
effectiveAllow(
rejected.allowedMethods()));
response.setStatus(
HttpServletResponse
.SC_METHOD_NOT_ALLOWED);
}
case Missing ignored -> response.setStatus(
HttpServletResponse.SC_NOT_FOUND);
}
}
private static ServletTarget target(
HttpServletRequest request) {
return new ServletTarget(
request.getRequestURI(),
request.getContextPath(),
request.getQueryString());
}
private static String effectiveAllow(
Set<RouteMethod> registered) {
var tokens = new ArrayList<String>();
for (var method : RouteMethod.values()) {
if (!registered.contains(method)) {
continue;
}
tokens.add(method.name());
if (method == RouteMethod.GET) {
tokens.add("HEAD");
}
}
// OPTIONS is present because this adapter implements
// doOptions for every known path.
tokens.add("OPTIONS");
return String.join(", ", tokens);
}
}
}RouteMethod.parseExact는 등록용 토큰을 대문자로 바꾸지 않습니다. RouteTable은 등록 목록을 차례로 보면서 같은 RouteKey를 만나는 즉시 생성에 실패하고, 조회할 때는 null 대신 Found, MethodNotAllowed, NotFound 가운데 하나만 돌려줍니다.
순수 FrontController는 성공일 때만 PageController, ModelView, ExactViewRegistry, ViewBoundary의 순서로 진행합니다. ExactViewRegistry는 앞 글에서 정한 논리 이름 post-detail만 정확히 찾습니다. 테스트용 ViewBoundary가 쓰는 post-detail:42는 원시 타입과 String으로 만든 확인용 표식이지, JSP나 Thymeleaf의 결과나 안전한 HTML이 아닙니다. 실제 뷰 렌더링과 이스케이프는 서블릿·JSP 책임에서 확인했습니다.
Found·405·404를 닫힌 결과로 유지하기
EXACT KEY → CLOSED MATCH → EXPLICIT RESPONSE
매핑은 Found·405+Allow·404를 닫힌 결과로 반환한다
nullable handler 하나로 모든 실패를 404로 축약하지 않습니다. path 존재, exact method, allowed methods를 보존하는 닫힌 결과로 만들어 Front Controller가 정해진 응답만 선택하게 합니다.
GET만 등록한 이 fixture의 HTTP Allow는 GET, HEAD, OPTIONS입니다. 이는 매핑의 등록 집합과 Servlet 어댑터가 더한 실제 HEAD·OPTIONS 지원을 구분한 결과입니다.
01 · CLOSED MATCH TYPE
mapping은 nullable handler 대신 세 결과 가운데 하나만 반환한다
Found(handler),
MethodNotAllowed(allowedMethods),
NotFound()를 sealed result로 모델링해 호출자가 모든
경우를 명시적으로 처리합니다.
02 · FOUND
context-relative path와 method가 정확히 일치할 때만 handler를 준다
case-preserving method와 raw path의 exact key가 일치해야
Found입니다. 이 분기만 직접
PageController를 호출합니다.
03 · METHOD NOT ALLOWED
path는 있지만 method가 다르면 allowed methods를 보존한다
Servlet adapter는 application methods를 deterministic
Allow로 만들고, GET이 있으면 HEAD를, 이 adapter가 구현한
OPTIONS를 더해 405로 종료합니다.
04 · NOT FOUND + STARTUP GUARD
path도 없으면 404이고 duplicate exact key는 startup에서 막는다
NotFound에는 Allow가 없습니다.
non-Found 두 분기는 handler·query·view를 호출하지 않습니다.
duplicate exact key는 serving 전에 실패합니다.
이 흐름은 exact method+context-relative raw path의 작은 custom mapping 계약입니다. URI template, decoding, matrix variable, media type, header·parameter 조건과 실제 Spring DispatcherServlet 동작은 범위 밖입니다.
그림처럼 매핑은 세 결과 가운데 하나를 고릅니다. 경로와 메서드가 모두 일치하면 Found가 컨트롤러를 담고, 같은 경로에 등록된 메서드는 있지만 요청 메서드가 다르면 MethodNotAllowed가 등록된 메서드 집합을 담으며, 경로 자체가 없으면 NotFound가 됩니다. 세 결과는 순서대로 모두 실행되는 단계가 아니라 하나만 선택되는 분기이므로, 405와 404는 컨트롤러나 뷰를 호출하기 전에 끝납니다. 같은 키의 중복 등록은 요청 시점의 분기가 아니라 서버가 요청을 받기 전의 구성 실패입니다.
순수 매핑의 허용 집합과 HTTP Allow 값도 구분합니다. 이 예제의 서블릿 어댑터는 등록된 메서드만 기준으로 삼고, GET이 있으면 함께 처리되는 HEAD를 더합니다. 또 알려진 경로에 대해 doOptions를 실제로 구현했으므로 OPTIONS도 더합니다. doPost, doPut, doDelete, doPatch를 재정의했다는 이유만으로 등록되지 않은 메서드를 Allow에 넣지는 않습니다. RFC에서 Allow는 순서 없는 집합이지만, 테스트와 운영 로그를 안정적으로 비교할 수 있도록 enum의 순서에 마지막 OPTIONS를 붙여 헤더 문자열을 항상 같게 만듭니다. GET만 등록한 이 예제의 Allow는 GET, HEAD, OPTIONS입니다.
HttpServlet.service는 재정의하지 않습니다. doGet, doPost, doPut, doDelete와 서블릿 6.1의 doPatch가 공유 디스패치 함수에 enum 값을 명시해 넘깁니다. 그래서 상위 클래스가 HEAD를 doGet으로 보낼 때 요청의 HEAD 문자열을 다시 읽어 없는 키로 만들지 않고 GET 라우트를 씁니다. HEAD 응답의 본문을 생략하는 일은 서블릿 응답 출력에서 다뤘습니다. 알 수 없거나 구현되지 않은 메서드의 501 정책도 이 작은 라우팅 단위가 아니라 바깥 서버가 정합니다.
공통 관심사의 적용 지점
프런트 컨트롤러의 service 메서드에 모든 횡단 관심사를 쌓지 않습니다. 요청 ID와 핸들러별 인가, 업무 트랜잭션은 필요한 정보와 실행 수명에 맞춰 적용 지점을 나눕니다.
| 관심사 | 알맞은 지점 | 이유 |
|---|---|---|
| 요청 ID | 필터(Filter) | 모든 서블릿을 감싸는 요청 단위이기 때문입니다. |
| 핸들러별 인가 | 인터셉터(Interceptor) | 매핑이 고른 핸들러의 메타데이터가 필요하기 때문입니다. |
| 업무 트랜잭션 | 애플리케이션 서비스 | HTTP와 독립된 사용 사례 단위이기 때문입니다. |
이 표는 각 관심사가 놓일 위치를 설명할 뿐이며, 이 글의 실행 파일이 세 구성 요소를 검증하는 것은 아닙니다.
테스트가 확인하는 범위
| 테스트 범위 | 확인하는 것 | 단정하지 않는 것 |
|---|---|---|
| 라우트 키와 등록 단위 | 대소문자 보존, 원시 경로, 중복 등록 실패 | 프록시의 경로 재작성, URI 템플릿, Spring 매핑 |
| 순수 매핑 | Found, MethodNotAllowed, NotFound | HTTP 상태와 헤더 |
| 순수 프런트 컨트롤러 | 성공 시 호출 순서와 1회 호출, 실패 시 0회 호출 | 서블릿 컨테이너와 실제 템플릿 |
| 컨텍스트 경로 단위 | 루트와 /board에서 같은 원시 애플리케이션 경로, 쿼리 제외 | 디코딩된 경로 변수와 전달 헤더 정책 |
| embedded Tomcat 루프백(loopback) | GET 200과 표식, PUT 405와 정확한 Allow, 없는 경로의 GET 404 | TLS, 운영 필터, Spring MVC, JSP·Thymeleaf·브라우저 렌더링 |
embedded Tomcat 테스트는 실제 소켓과 서블릿 6.1의 디스패치를 거치지만 운영 서버 전체를 보여 주지는 않습니다. 반대로 순수 테스트는 HTTP 상태를 만들지 않고 매핑과 호출만 확인합니다. 두 범위를 섞지 않아야 실패가 어느 단계에서 났는지 알 수 있습니다.
Spring MVC와의 관계
Spring MVC의 DispatcherServlet은 같은 프런트 컨트롤러 발상을 훨씬 넓은 전략의 묶음으로 구현합니다. 그렇다고 이 예제의 RouteTable을 RequestMappingHandlerMapping과 같다고 부를 수는 없습니다. 이어지는 두 글과 앞의 글들이 다루는 범위는 다음과 같습니다.
- HandlerAdapter 확장은 서로 다른 핸들러의 호출 방식을 맞추는
HandlerAdapter를 다룹니다. - 디스패처 서블릿 구조는 실제
DispatcherServlet과 Spring의 매핑 조건, 인터셉터의 생명 주기, 인자와 반환 값 처리, 예외 리졸버, 406과 415를 다룹니다. - MVC 역할 경계는 컨트롤러의 입력 검증과 타입이 있는 페이지 모델, 고정된
ModelView, 정확한 뷰 레지스트리, 직접 호출과 Spring 디스패치 테스트의 차이를 다뤘습니다. - 서블릿·JSP 책임은 고정된
/WEB-INF뷰와 포워드, JSP·Thymeleaf 렌더링과 출력 문맥의 이스케이프를 다뤘습니다. - 서블릿 응답 출력은 응답 본문과 커밋,
sendError, HEAD 본문, 204와 304의 의미를 다뤘습니다.
이 글의 버전 기준은 Java 25와 Spring Boot 4.1.2 BOM, Spring Framework 7.0.10, Tomcat 11.0.27과 서블릿 6.1, JUnit 6.0.3, AssertJ 3.27.7입니다. 이 Java 파일은 Spring 타입을 직접 쓰지 않지만, 앞뒤 글과 같은 버전 기준을 따릅니다.
이 글의 내용은 RFC 9110, Tomcat 11.0의 HttpServlet과 HttpServletRequest, Jakarta Servlet 6.1 명세, Spring MVC의 요청 매핑과 DispatcherServlet, 처리 순서, Spring Boot 관리 의존성 좌표, JUnit 6.0.3 사용자 가이드를 기준으로 합니다.
정리
- 프런트 컨트롤러는 공통 입구에서 실행 순서와 HTTP 결과만 조정하고, 업무 규칙과 뷰 결정, 렌더링은 각 구성 요소에 남깁니다.
- 라우트 키는 대소문자를 보존한 메서드와, 디코딩하지 않은 요청 URI에서 컨텍스트 경로를 잘라 낸 원시 경로로 만들며 쿼리는 넣지 않습니다.
- 매핑은
null대신Found,MethodNotAllowed,NotFound가운데 하나를 돌려주고, 405와 404는 컨트롤러와 뷰를 호출하기 전에 끝납니다. Allow에는 등록된 메서드와 함께 GET에서 파생되는 HEAD, 실제로 구현한 OPTIONS만 넣고, 중복 등록은 서버가 요청을 받기 전에 실패시킵니다.- 요청 ID는 필터, 핸들러별 인가는 인터셉터, 트랜잭션은 애플리케이션 서비스처럼 관심사마다 적용 지점을 나눕니다.
연습 문제
1. 목록 라우트 추가하기
GET /posts와 POST /posts를 등록해 같은 구조를 목록 화면에 적용합니다. PUT /posts와 GET /reports는 각각 어떤 매핑 결과와 HTTP 응답이 되어야 하나요? 이때 무엇이 호출되지 않아야 하는지도 함께 설명하세요.
해설 보기
PUT /posts는 경로는 있지만 메서드가 다르므로 MethodNotAllowed가 되어 405와 Allow: GET, HEAD, POST, OPTIONS를 돌려줍니다. enum의 순서대로 GET 뒤에 HEAD가 붙고, POST가 이어지며, 마지막에 OPTIONS가 붙습니다. GET /reports는 경로가 없으므로 NotFound가 되어 Allow 없이 404를 돌려줍니다. 두 경우 모두 컨트롤러와 조회, 뷰 레지스트리, 뷰를 호출하지 않습니다.
그 밖에 다음을 지키는지 봅니다. 등록 목록에 같은 메서드와 원시 경로를 두 번 넣으면 서버를 시작하기 전에 실패합니다. doOptions는 알려진 경로의 등록된 메서드와 파생된 HEAD, 구현된 OPTIONS만 알립니다. 컨트롤러는 앞 글의 방식대로 검증된 요청과 타입이 있는 페이지 모델을 쓰고 고정된 논리 이름을 돌려줍니다. 템플릿 출력과 문맥별 이스케이프는 서블릿·JSP 글의 테스트에 더하고, 이 라우팅 테스트에서 흉내 내지 않습니다.
2. 원시 경로 계산하기
컨텍스트 경로가 /board일 때 요청 URI ① /board, ② /board/posts/%2Fdetail, ③ /boardwalk/posts에 대해 contextRelativeRawPath()의 결과를 구하세요. ②에 쿼리 trace=a가 붙으면 라우트 키가 달라지는지도 판단하세요.
해설 보기
① 요청 URI가 컨텍스트 경로와 정확히 같으므로 잘라 낸 나머지가 빈 문자열이고, 메서드는 이를 /로 돌려줍니다. ② 앞의 /board 여섯 글자를 잘라 /posts/%2Fdetail이 되며, %2F는 디코딩하지 않습니다. 쿼리는 요청 URI에 들어 있지 않으므로 trace=a가 붙어도 키는 같습니다. ③ /board로 시작하지만 일곱째 글자가 /가 아니라 w이므로 IllegalArgumentException이 납니다. 이 결과는 코드의 분기를 따라간 예상이므로 테스트를 실행해 확인합니다.
다음 글 HandlerAdapter 확장에서는 매핑이 찾은 핸들러의 구체 타입을 프런트 컨트롤러가 직접 알지 않도록 HandlerAdapter를 더합니다.