본문으로 건너뛰기
안동민 개발노트 아이콘

안동민 개발노트

본문 시작
5장 : 서블릿 MVC 구조

프런트 컨트롤러 발전

모든 게시판 페이지 요청을 하나의 Servlet에서 받아 메서드·경로 매핑, 파라미터 변환, 컨트롤러 실행, 뷰 렌더링, 404·오류 처리를 공통화합니다.

컨트롤러 Servlet을 URL마다 등록하면 파라미터 맵 만들기, UTF-8 설정, 예외 처리, 뷰 포워드가 반복됩니다. 프런트 컨트롤러는 공통 입구 하나가 요청을 받고 실제 컨트롤러를 찾아 실행합니다. Spring MVC의 DispatcherServlet이 이 패턴의 완성된 구현입니다.


URI와 컨트롤러 탐색

가장 작은 구현은 메서드와 애플리케이션 경로를 키로 컨트롤러 맵을 조회합니다.

src/main/java/board/mvc/HandlerKey.java
package board.mvc;

public record HandlerKey(
        String method,
        String path) {
    public HandlerKey {
        method = method.toUpperCase();
        if (!path.startsWith("/")) {
            throw new IllegalArgumentException(
                    "path must start with /");
        }
    }
}

경로만 키로 쓰면 같은 /posts의 GET 목록과 POST 등록을 구분하지 못합니다. 쿼리 문자열은 매핑 키에 넣지 않고 파라미터 바인딩으로 넘깁니다.

getRequestURI에는 컨텍스트 경로가 포함될 수 있으므로 애플리케이션 경로를 분리합니다. 리버스 프록시 접두사를 컨트롤러 맵에 직접 섞지 않고 컨테이너 컨텍스트와 전달된 헤더 정책을 정합니다.

프런트 컨트롤러 흐름

src/main/java/board/mvc/FrontControllerServlet.java
package board.mvc;

import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;

public final class FrontControllerServlet
        extends HttpServlet {
    private final Map<HandlerKey, PageController> handlers;
    private final ViewResolver viewResolver;

    public FrontControllerServlet(
            Map<HandlerKey, PageController> handlers,
            ViewResolver viewResolver) {
        this.handlers = Map.copyOf(handlers);
        this.viewResolver = viewResolver;
    }

    @Override
    protected void service(
            HttpServletRequest request,
            HttpServletResponse response)
            throws ServletException, IOException {
        var path = request.getRequestURI()
                .substring(
                        request.getContextPath().length());
        var key = new HandlerKey(
                request.getMethod(), path);
        var controller = handlers.get(key);
        if (controller == null) {
            response.sendError(404);
            return;
        }

        var parameters = request.getParameterMap()
                .entrySet()
                .stream()
                .collect(Collectors.toUnmodifiableMap(
                        Map.Entry::getKey,
                        entry -> List.of(entry.getValue())));
        var result = controller.handle(parameters);
        var view = viewResolver.resolve(
                result.viewName());
        view.render(
                result.model(), request, response);
    }
}

이 Servlet은 URL별 Servlet 등록을 하나로 줄입니다. 컨트롤러가 Servlet API를 모르고 PageController 규칙만 구현하므로 단위 테스트가 작아집니다.

service를 재정의했으므로 HttpServletdoGet·doPost 디스패치를 프런트 컨트롤러가 대신합니다. 메서드를 핸들러 키에 포함해 지원 여부를 판정합니다. 경로는 컨테이너가 제공한 값을 사용하되 디코딩과 행렬 변수 같은 세부 규칙을 직접 프레임워크로 구현하려 하지 않습니다.

단계별 공통화

프런트 컨트롤러 발전을 다음 순서로 볼 수 있습니다.

  1. URL마다 Servlet: 매핑과 생명주기가 분산됩니다.
  2. 하나의 프런트 컨트롤러: 핸들러 맵과 404를 공통화합니다.
  3. ModelView 반환: 컨트롤러에서 포워드 코드를 제거합니다.
  4. ViewResolver: 논리 이름과 템플릿 경로를 분리합니다.
  5. 인자 어댑터: 파라미터 맵 조립과 타입 변환을 공통화합니다.
  6. 예외 리졸버: 실패를 상태와 오류 뷰로 변환합니다.

모든 것을 하나의 거대한 service 메서드에 넣으면 또 다른 만능 객체가 됩니다. 매핑, 어댑터, 리졸버를 작은 전략으로 분리하고 프런트 컨트롤러는 실행 순서만 조정합니다.

공통 로깅을 넣을 때 컨트롤러마다 복사하지 않고 프런트 컨트롤러 또는 필터에서 트레이스 ID와 경과를 기록합니다. 인가처럼 핸들러 메타데이터가 필요한 정책은 매핑 이후 인터셉터 지점이 적합할 수 있습니다.

매핑·뷰 통합 검증

src/test/java/board/mvc/FrontControllerServletTest.java
package board.mvc;

import static org.assertj.core.api.Assertions.assertThat;

import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.Test;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.mock.web.MockServletContext;

class FrontControllerServletTest {
    @Test
    void method와_path로_controller를_찾아_view를_render한다()
            throws Exception {
        PageController controller = parameters ->
                new ModelView(
                        "post-detail",
                        Map.of(
                                "id",
                                parameters.get("id").getFirst()));
        var servlet = new FrontControllerServlet(
                Map.of(
                        new HandlerKey(
                                "GET", "/posts/detail"),
                        controller),
                name -> new JspView(
                        "/WEB-INF/views/" + name + ".jsp"));
        var context = new MockServletContext();
        var request = new MockHttpServletRequest(
                context, "GET", "/posts/detail");
        request.addParameter("id", "42");
        var response = new MockHttpServletResponse();

        servlet.service(request, response);

        assertThat(request.getAttribute("id"))
                .isEqualTo("42");
        assertThat(response.getForwardedUrl())
                .isEqualTo(
                        "/WEB-INF/views/post-detail.jsp");
    }

    @Test
    void 등록되지_않은_method_path는_404다()
            throws Exception {
        var servlet = new FrontControllerServlet(
                Map.of(), name -> {
                    throw new AssertionError(
                            "view must not resolve");
                });
        var request = new MockHttpServletRequest(
                "POST", "/missing");
        var response = new MockHttpServletResponse();

        servlet.service(request, response);

        assertThat(response.getStatus()).isEqualTo(404);
    }
}
실행 결과
FrontControllerServletTest
  > method와_path로_controller를_찾아_view를_render한다() PASSED
FrontControllerServletTest
  > 등록되지_않은_method_path는_404다() PASSED
BUILD SUCCESSFUL

404·405 매핑 결과

현재 맵은 정확한 키가 없으면 모두 404입니다. 경로는 존재하지만 메서드만 다른 경우 405와 Allow 헤더를 반환하려면 핸들러 매핑이 다음 정보를 제공해야 합니다.

  • 정확한 메서드+경로 핸들러
  • 경로는 일치하지만 허용 메서드 목록
  • 경로 변수와 추출 값
  • 미디어 타입 consumes·produces 조건
  • 요청 헤더·파라미터 조건

매핑 결과를 단순 null 허용 컨트롤러가 아니라 HandlerMatch로 만들면 404·405·415·406을 정확히 나눌 수 있습니다. 이 복잡성을 직접 구현하기 시작하면 Spring의 RequestMappingHandlerMapping이 해결하는 문제에 도달합니다.

공통 처리 적용 지점

관심사적합한 지점이유
요청 ID·전체 경과필터모든 Servlet과 오류 디스패치 포함 가능
핸들러별 인가인터셉터선택된 핸들러 메타데이터 접근
파라미터 타입 변환인자 어댑터컨트롤러 호출 직전
업무 트랜잭션애플리케이션 서비스HTTP와 독립된 사용 사례 경계
뷰 이름 해석뷰 리졸버컨트롤러와 템플릿 경로 분리
예외→응답예외 리졸버커밋 전 일관된 변환

프런트 컨트롤러에 트랜잭션을 열어 모든 뷰 렌더링까지 유지하면 DB 연결 수명이 길어집니다. 반대로 지연 엔티티를 뷰에서 읽기 위해 트랜잭션을 억지로 늘리지 말고 애플리케이션 서비스가 필요한 뷰 모델을 완성합니다.

연습 문제

현재 프런트 컨트롤러가 경로는 있지만 메서드가 다를 때 405와 Allow 헤더를 반환하도록 HandlerMapping을 분리하세요. GET /postsPOST /posts를 등록하고 PUT은 405, 없는 /reports는 404여야 합니다.

해설 보기

매핑은 정확한 일치와 같은 경로에서 허용된 메서드를 함께 인덱스합니다.

sealed interface Match {
    record Found(PageController controller)
            implements Match {}
    record MethodNotAllowed(Set<String> allowed)
            implements Match {}
    record NotFound() implements Match {}
}

프런트 컨트롤러는 MethodNotAllowed이면 상태 405와 쉼표로 결합한 Allow를 설정합니다. 컨트롤러나 뷰 리졸버를 호출하지 않아야 합니다. 정확한 경로 문자열을 넘어 URI 템플릿이 필요해지면 직접 파서를 계속 확장하기보다 Spring 매핑을 사용합니다.

다음 문서에서는 반환 규칙이 서로 다른 컨트롤러를 하나의 호출 방식으로 맞추기 위해 HandlerAdapter를 추가하고 매핑과 실행 책임을 분리합니다.