본문으로 건너뛰기

안동민 개발노트

본문 시작

프런트 컨트롤러 발전

정확한 HTTP 메서드와 컨텍스트 상대 원시 경로를 등록하고, Found·405+Allow·404를 구분하는 프런트 컨트롤러의 실행 순서와 Servlet 어댑터 경계를 검증합니다.

URL마다 Servlet을 만들면 매핑, 컨트롤러 호출, 논리 뷰 선택, 실패 처리가 각 진입점에 흩어집니다.

프런트 컨트롤러는 공통 입구 하나에서 실행 순서와 HTTP 결과를 조정합니다. 그렇다고 입력 검증, 업무 규칙, 템플릿 렌더링까지 모두 소유하는 만능 객체가 되어서는 안 됩니다. 컨트롤러는 애플리케이션 동작을, 앞 문서의 MVC 경계는 타입 있는 모델과 정확한 논리 뷰 선택을, 서블릿·JSP 경계는 실제 렌더링과 출력 문맥 이스케이프를 맡습니다.

URL마다 Servlet이 모든 절차를 소유하던 구조를 하나의 공통 진입점으로 모읍니다. Front Controller는 request를 받은 뒤 case-preserving method와 context-relative raw path를 만들고 closed HandlerMapping을 조회합니다. Found일 때만 PageController를 직접 호출해 ModelView를 받고, B78의 exact view registry와 B77의 view boundary를 차례로 거쳐 response를 완성합니다. Front Controller는 실행 순서와 non-Found 조기 종료만 소유하고 mapping, 업무 결정, view 해석, rendering은 각 경계에 남깁니다. HandlerAdapter는 다음 장, exception resolution은 ch5-9, 실제 rendering과 escaping 증거는 B77이 소유합니다.

REQUEST → CLOSED MAPPING → DIRECT CONTROLLER → VIEW

Front Controller는 실행 순서만 소유한다

공통 진입점은 exact application key를 만들고 닫힌 매핑 결과에 따라 직접 controller를 호출한 뒤 기존 ModelView·view 경계를 순서대로 연결합니다. 각 전략은 자신의 결정을 그대로 소유합니다.

Front Controller가 조정하는 여덟 단계 요청 흐름 request에서 context-relative raw-path adapter, closed HandlerMapping, 직접 PageController, ModelView, B78 exact view registry, B77 view boundary, response로 이어지는 순서입니다. non-Found는 controller 전에 종료되고 각 구성 요소가 자신의 결정만 소유합니다. WHAT FC OWNS 실행 순서와 non-Found 조기 종료 entry → mapping → invoke → render WHAT COMPONENTS OWN key·match·business·view 결정 각 경계가 관찰 가능한 출력만 반환 ADJACENT OWNERS HandlerAdapter · exception resolution ch5-8 · ch5-9에서 각각 확장 SERVLET ENTRY FRONT CONTROLLER · ORDER ONLY DELEGATED VIEW PIPELINE raw key → closed match → direct call → logical output exact name lookup → fixed View call → response HTTP request method · URI RAW-PATH ADAPTER context- relative path raw URI − context query excluded CLOSED MATCH Handler- Mapping Found · 405 · 404 FOUND ONLY direct PageController handle(request) no adapter yet LOGICAL OUTPUT ModelView fixed name + model B78 exact view registry registered key only B77 fixed View boundary evidence owned there HTTP response primitive marker EXACT APPLICATION KEY case-preserving method + context-relative raw path query·decode·template·matrix-variable semantics는 범위 밖 DIRECT CONTROLLER STAGE Found.handler를 정확히 한 번 호출 HandlerAdapter는 다음 장 · non-Found는 downstream invocation 0 COMPOSED VIEW BOUNDARIES ModelView → exact registry → fixed View B79는 호출 순서만 증명 · rendering과 escaping 증거는 B77 READING KEY ordered responsibility handoff Front Controller orchestration rail

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 메서드 토큰은 대소문자를 보존합니다. GETget을 같은 값으로 만드는 대문자 변환 보정은 하지 않습니다.
  • HttpServletRequest.getRequestURI()getContextPath()는 컨테이너가 디코딩하지 않은 값을 제공합니다. 요청 URI에서 디코딩하지 않은 컨텍스트 경로 길이만큼 정확히 잘라 애플리케이션 경로를 얻습니다.
  • 요청 URI는 ? 앞에서 끝나므로 쿼리 문자열은 라우트 키에 들어가지 않습니다. %2F/로 다시 디코딩하지 않습니다.
  • URI 템플릿, 경로 변수, 행렬 변수, 헤더 조건, consumes, produces는 이 작은 매퍼의 계약이 아닙니다.

완성된 Map을 생성자에 넘기면 그 전에 중복 키가 마지막 값으로 덮였는지 알 수 없습니다. 따라서 등록 목록을 받아 정확히 같은 메서드와 경로가 두 번 나오면 요청을 받기 전에 실패시킵니다.


하나의 실행 가능한 경계

다음 파일 하나가 라우트 키, 등록 테이블, 닫힌 매핑 결과, 순수 프런트 컨트롤러, Servlet 어댑터, 고정 뷰 경계와 일곱 테스트를 모두 포함합니다.

src/test/java/board/mvc/EmbeddedFrontControllerRoutingBoundaryTest.java
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를 닫힌 결과로 유지하기

HandlerMapping은 case-preserving method와 context-relative raw path를 받습니다. path가 없으면 NotFound를 반환해 404를 만들고, path는 있지만 method가 없으면 MethodNotAllowed와 allowed method 집합을 반환해 Servlet adapter가 405와 deterministic Allow를 만듭니다. method와 path가 정확히 일치할 때만 Found와 PageController를 반환합니다. non-Found에서는 handler, query, view lookup, view execution을 호출하지 않으며 duplicate exact key나 모호한 등록은 request-time 분기가 아니라 startup failure로 닫습니다.

EXACT KEY → CLOSED MATCH → EXPLICIT RESPONSE

매핑은 Found·405+Allow·404를 닫힌 결과로 반환한다

nullable handler 하나로 모든 실패를 404로 축약하지 않습니다. path 존재, exact method, allowed methods를 보존하는 닫힌 결과로 만들어 Front Controller가 정해진 응답만 선택하게 합니다.

Found, MethodNotAllowed, NotFound 닫힌 매핑 결과 exact application key를 HandlerMapping이 검사해 path가 없으면 NotFound와 404, path는 있지만 method가 없으면 MethodNotAllowed(allowedMethods)와 405 Allow, 둘 다 정확히 일치하면 Found(handler)로 나눕니다. non-Found에서는 downstream을 호출하지 않고 모호한 등록은 startup에서 실패합니다. NO PATH PATH EXISTS METHOD FOUND NO METHOD CLOSED RESULT Found · MethodNotAllowed · NotFound CONSTRUCTION GUARD duplicate exact key·ambiguity → startup failure NON-FOUND GUARANTEE handler·query·view lookup·view call = 0 EXACT APPLICATION KEY case-preserving method + raw path HandlerMapping sealed Match · never null path registered? exact context-relative raw path NotFound() 404 no Allow method registered? exact case-preserving token Found(handler) direct PageController dispatch MethodNotAllowed (allowedMethods) 405 + Allow READING KEY step / result exhaustive decision closed Match branch

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 메서드에 모든 횡단 관심사를 쌓지 않습니다. 기존 세 번째 다이어그램의 고유 사실은 그림을 하나 더 유지하는 대신 다음 표에 한 번만 남깁니다.

관심사적합한 지점이유
요청 IDFilter모든 Servlet을 포함하는 요청 경계이기 때문입니다.
핸들러별 인가Interceptor매핑이 선택한 핸들러 메타데이터가 필요하기 때문입니다.
업무 트랜잭션애플리케이션 서비스HTTP와 독립된 사용 사례 경계이기 때문입니다.

이 표는 소유 위치를 설명할 뿐, 이 문서의 실행 파일이 위 세 구성요소를 검증한다는 뜻이 아닙니다.


테스트가 증명하는 경계

테스트 범위관찰하는 것주장하지 않는 것
라우트 키·등록 단위대소문자 보존, 원시 경로, 중복 등록 실패프록시 재작성, URI 템플릿, Spring 매핑
순수 매핑Found·MethodNotAllowed·NotFoundHTTP 상태와 헤더
순수 프런트 컨트롤러성공 호출 순서와 1회 호출, 실패 시 0회 호출Servlet 컨테이너와 실제 템플릿
컨텍스트 경로 단위루트와 /board에서 같은 원시 애플리케이션 경로, 쿼리 제외디코딩된 경로 변수와 전달 헤더 정책
embedded Tomcat loopbackGET 200 표식, PUT 405와 정확한 Allow, 없는 GET 404TLS, 운영 필터, Spring MVC, JSP·Thymeleaf·브라우저 렌더링

embedded Tomcat 테스트는 실제 소켓과 Servlet 6.1 디스패치를 통과하지만 운영 서버 전체를 증명하지 않습니다. 반대로 순수 테스트는 HTTP 상태를 만들지 않고 매핑과 호출 계약만 관찰합니다. 두 범위를 섞지 않아야 실패가 어느 경계에 속하는지 알 수 있습니다.


Spring MVC와의 경계

Spring MVC의 DispatcherServlet은 같은 프런트 컨트롤러 발상을 훨씬 넓은 전략 집합으로 구현합니다. 그러나 이 예제의 RouteTableRequestMappingHandlerMapping과 같다고 부를 수는 없습니다.

  • 다음 문서는 서로 다른 핸들러 호출 규칙을 맞추는 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 타입을 직접 사용하지 않지만, 인접 교재와 실행 증거의 버전 기준은 같은 그래프로 고정합니다.


공식 기준


연습 문제

GET /postsPOST /posts를 등록하고 같은 계약을 목록 화면에 적용하세요. PUT /postsMethodNotAllowed가 되어 Allow: GET, HEAD, POST, OPTIONS를 반환하고, GET /reportsNotFound가 되어 404를 반환해야 합니다.

점검 기준 보기
  • 등록 목록에 같은 메서드와 원시 경로를 두 번 넣으면 서버 시작 전에 실패합니다.
  • PUT /postsGET /reports는 컨트롤러, 조회, 뷰 레지스트리, 뷰 경계를 호출하지 않습니다.
  • doOptions는 알려진 경로의 실제 등록 메서드와 파생된 HEAD, 구현된 OPTIONS만 알립니다.
  • 컨트롤러는 B78의 검증된 요청과 타입 있는 페이지 모델을 사용하고 고정 논리 이름을 반환합니다.
  • 템플릿 출력과 문맥별 이스케이프는 B77 테스트에 추가하며 이 라우팅 테스트에 흉내 내지 않습니다.

다음 문서에서는 매핑이 찾은 핸들러의 구체 타입을 프런트 컨트롤러가 직접 알지 않도록 HandlerAdapter를 추가합니다.