프런트 컨트롤러 발전
정확한 HTTP 메서드와 컨텍스트 상대 원시 경로를 등록하고, Found·405+Allow·404를 구분하는 프런트 컨트롤러의 실행 순서와 Servlet 어댑터 경계를 검증합니다.
URL마다 Servlet을 만들면 매핑, 컨트롤러 호출, 논리 뷰 선택, 실패 처리가 각 진입점에 흩어집니다.
프런트 컨트롤러는 공통 입구 하나에서 실행 순서와 HTTP 결과를 조정합니다. 그렇다고 입력 검증, 업무 규칙, 템플릿 렌더링까지 모두 소유하는 만능 객체가 되어서는 안 됩니다. 컨트롤러는 애플리케이션 동작을, 앞 문서의 MVC 경계는 타입 있는 모델과 정확한 논리 뷰 선택을, 서블릿·JSP 경계는 실제 렌더링과 출력 문맥 이스케이프를 맡습니다.
REQUEST → CLOSED MAPPING → DIRECT CONTROLLER → VIEW
Front Controller는 실행 순서만 소유한다
공통 진입점은 exact application key를 만들고 닫힌 매핑 결과에 따라 직접 controller를 호출한 뒤 기존 ModelView·view 경계를 순서대로 연결합니다. 각 전략은 자신의 결정을 그대로 소유합니다.
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는 B78에서 확정한 typed request·page-model 경계를
재사용해 고정 ModelView를 반환합니다. Front
Controller는 업무 규칙이나 view 결정을 대신 구현하지 않습니다.
04 · EXISTING VIEW BOUNDARIES
B78 exact registry와 B77 fixed View 경계를 순서대로 호출한다
B79는 등록된 논리 이름을 fixed test View로 연결해 primitive marker가 response에 도달하는 순서만 증명합니다. 실제 rendering과 output-context escaping 증거는 B77, exception resolution은 ch5-9가 소유합니다.
URL별 Servlet에 흩어진 절차를 공통 entry로 모으되 mapping, handler,
registry, view의 판단까지 service에 흡수하지 않습니다.
HandlerAdapter는 ch5-8, exception resolver는 ch5-9에서 확장합니다.
정확한 라우트 키
이 문서의 작은 매핑은 HTTP 메서드 + 컨텍스트 상대 원시 경로만 키로 사용합니다.
- HTTP 메서드 토큰은 대소문자를 보존합니다.
GET과get을 같은 값으로 만드는 대문자 변환 보정은 하지 않습니다. HttpServletRequest.getRequestURI()와getContextPath()는 컨테이너가 디코딩하지 않은 값을 제공합니다. 요청 URI에서 디코딩하지 않은 컨텍스트 경로 길이만큼 정확히 잘라 애플리케이션 경로를 얻습니다.- 요청 URI는
?앞에서 끝나므로 쿼리 문자열은 라우트 키에 들어가지 않습니다.%2F도/로 다시 디코딩하지 않습니다. - URI 템플릿, 경로 변수, 행렬 변수, 헤더 조건,
consumes,produces는 이 작은 매퍼의 계약이 아닙니다.
완성된 Map을 생성자에 넘기면 그 전에 중복 키가 마지막 값으로 덮였는지 알 수 없습니다. 따라서 등록 목록을 받아 정확히 같은 메서드와 경로가 두 번 나오면 요청을 받기 전에 실패시킵니다.
하나의 실행 가능한 경계
다음 파일 하나가 라우트 키, 등록 테이블, 닫힌 매핑 결과, 순수 프런트 컨트롤러, Servlet 어댑터, 고정 뷰 경계와 일곱 테스트를 모두 포함합니다.
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는 B78에서 확정한 post-detail 논리 이름만 정확히 찾습니다. 테스트용 ViewBoundary가 쓰는 post-detail:42는 primitive와 String으로 만든 관찰 표식이지 JSP·Thymeleaf 결과나 안전한 HTML이 아닙니다. 실제 뷰 렌더링과 이스케이프 증거는 B77이 소유합니다.
Found·405·404를 닫힌 결과로 유지하기
EXACT KEY → CLOSED MATCH → EXPLICIT RESPONSE
매핑은 Found·405+Allow·404를 닫힌 결과로 반환한다
nullable handler 하나로 모든 실패를 404로 축약하지 않습니다. path 존재, exact method, allowed methods를 보존하는 닫힌 결과로 만들어 Front Controller가 정해진 응답만 선택하게 합니다.
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를, 실제 지원하면
OPTIONS를 더해 405로 종료합니다.
04 · NOT FOUND + STARTUP GUARD
path도 없으면 404이고 duplicate·ambiguous 등록은 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 값도 구분해야 합니다. 이 예제의 Servlet 어댑터는 등록된 메서드만 기준으로 삼고, GET이 있으면 유효한 HEAD를 추가합니다. 또한 알려진 경로에 대해 doOptions를 실제로 구현했으므로 OPTIONS를 추가합니다. doPost, doPut, doDelete, doPatch를 재정의했다는 이유만으로 등록되지 않은 메서드를 Allow에 넣지 않습니다. RFC에서 Allow는 집합이지만 테스트와 운영 관찰을 안정적으로 만들기 위해 enum 순서와 마지막 OPTIONS로 헤더 문자열을 결정적으로 만듭니다.
HttpServlet.service는 재정의하지 않습니다. doGet, doPost, doPut, doDelete, Servlet 6.1의 doPatch가 공유 디스패치 함수에 명시적인 enum을 넘깁니다. 그래서 superclass가 HEAD를 doGet으로 보낼 때 요청의 HEAD 문자열을 다시 읽어 누락 키로 만들지 않고 GET 라우트를 사용합니다. HEAD 응답 본문 억제 자체는 응답 생명주기 문서의 소유 범위입니다. 알려지지 않았거나 구현되지 않은 메서드의 501 정책도 이 작은 라우팅 단위가 아니라 바깥 서버의 책임입니다.
공통 관심사의 적용 지점
프런트 컨트롤러의 service 메서드에 모든 횡단 관심사를 쌓지 않습니다. 기존 세 번째 다이어그램의 고유 사실은 그림을 하나 더 유지하는 대신 다음 표에 한 번만 남깁니다.
| 관심사 | 적합한 지점 | 이유 |
|---|---|---|
| 요청 ID | Filter | 모든 Servlet을 포함하는 요청 경계이기 때문입니다. |
| 핸들러별 인가 | Interceptor | 매핑이 선택한 핸들러 메타데이터가 필요하기 때문입니다. |
| 업무 트랜잭션 | 애플리케이션 서비스 | HTTP와 독립된 사용 사례 경계이기 때문입니다. |
이 표는 소유 위치를 설명할 뿐, 이 문서의 실행 파일이 위 세 구성요소를 검증한다는 뜻이 아닙니다.
테스트가 증명하는 경계
| 테스트 범위 | 관찰하는 것 | 주장하지 않는 것 |
|---|---|---|
| 라우트 키·등록 단위 | 대소문자 보존, 원시 경로, 중복 등록 실패 | 프록시 재작성, URI 템플릿, Spring 매핑 |
| 순수 매핑 | Found·MethodNotAllowed·NotFound | HTTP 상태와 헤더 |
| 순수 프런트 컨트롤러 | 성공 호출 순서와 1회 호출, 실패 시 0회 호출 | Servlet 컨테이너와 실제 템플릿 |
| 컨텍스트 경로 단위 | 루트와 /board에서 같은 원시 애플리케이션 경로, 쿼리 제외 | 디코딩된 경로 변수와 전달 헤더 정책 |
| embedded Tomcat loopback | GET 200 표식, PUT 405와 정확한 Allow, 없는 GET 404 | TLS, 운영 필터, Spring MVC, JSP·Thymeleaf·브라우저 렌더링 |
embedded Tomcat 테스트는 실제 소켓과 Servlet 6.1 디스패치를 통과하지만 운영 서버 전체를 증명하지 않습니다. 반대로 순수 테스트는 HTTP 상태를 만들지 않고 매핑과 호출 계약만 관찰합니다. 두 범위를 섞지 않아야 실패가 어느 경계에 속하는지 알 수 있습니다.
Spring MVC와의 경계
Spring MVC의 DispatcherServlet은 같은 프런트 컨트롤러 발상을 훨씬 넓은 전략 집합으로 구현합니다. 그러나 이 예제의 RouteTable을 RequestMappingHandlerMapping과 같다고 부를 수는 없습니다.
- 다음 문서는 서로 다른 핸들러 호출 규칙을 맞추는
HandlerAdapter를 소유합니다. - 그다음 문서는 실제
DispatcherServlet, Spring 매핑 조건, 인터셉터 생명주기, 인자·반환 값 처리, 예외 리졸버, 406·415를 소유합니다. - B78은 컨트롤러 입력 검증, 타입 있는 페이지 모델, 고정
ModelView, 정확한 뷰 레지스트리와 직접 호출 대 Spring 디스패치 테스트 범위를 유지합니다. - B77은 고정
/WEB-INF뷰, forward, JSP·Thymeleaf 렌더링과 출력 문맥 이스케이프를 유지합니다. - B76은 응답 본문·커밋·
sendError·HEAD 본문·204·304 의미를 유지합니다.
현재 잠금 그래프는 Java 25.0.4.1, Spring Boot BOM 4.1.1, Spring Framework 7.0.9, Tomcat 11.0.24와 Servlet 6.1, JUnit 6.0.3, AssertJ 3.27.7을 사용합니다. 이 문서의 Java 파일은 Spring 타입을 직접 사용하지 않지만, 인접 교재와 실행 증거의 버전 기준은 같은 그래프로 고정합니다.
공식 기준
- RFC 9110 HTTP Semantics
- Tomcat 11.0.24
HttpServlet - Tomcat 11.0.24
HttpServletRequest - Jakarta Servlet 6.1 명세
- Spring MVC 요청 매핑
- Spring MVC
DispatcherServlet - Spring MVC 처리 순서
- Spring Boot 관리 의존성 좌표
- JUnit 6.0.3 사용자 가이드
연습 문제
GET /posts와 POST /posts를 등록하고 같은 계약을 목록 화면에 적용하세요. PUT /posts는 MethodNotAllowed가 되어 Allow: GET, HEAD, POST, OPTIONS를 반환하고, GET /reports는 NotFound가 되어 404를 반환해야 합니다.
점검 기준 보기
- 등록 목록에 같은 메서드와 원시 경로를 두 번 넣으면 서버 시작 전에 실패합니다.
PUT /posts와GET /reports는 컨트롤러, 조회, 뷰 레지스트리, 뷰 경계를 호출하지 않습니다.doOptions는 알려진 경로의 실제 등록 메서드와 파생된 HEAD, 구현된 OPTIONS만 알립니다.- 컨트롤러는 B78의 검증된 요청과 타입 있는 페이지 모델을 사용하고 고정 논리 이름을 반환합니다.
- 템플릿 출력과 문맥별 이스케이프는 B77 테스트에 추가하며 이 라우팅 테스트에 흉내 내지 않습니다.
다음 문서에서는 매핑이 찾은 핸들러의 구체 타입을 프런트 컨트롤러가 직접 알지 않도록 HandlerAdapter를 추가합니다.