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

안동민 개발노트

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

HandlerAdapter 확장

프런트 컨트롤러가 구체 컨트롤러 타입을 모르도록 핸들러와 어댑터를 분리하고 ModelView형·레거시 응답형 호출을 같은 디스패치 흐름에서 검증합니다.

프런트 컨트롤러가 PageController 하나만 지원하면 새 컨트롤러 규칙을 추가할 때 디스패처의 instanceof 분기가 늘어납니다. 매핑은 어떤 핸들러가 맞는지 찾고, 어댑터는 그 핸들러를 어떤 인자로 호출해 어떤 결과로 바꿀지 담당하게 나누면 디스패처 흐름은 유지됩니다.


핸들러와 컨트롤러

핸들러는 현재 요청을 처리하도록 매핑이 선택한 대상입니다. PageController 구현일 수도 있고 레거시 컨트롤러, 애노테이션 메서드를 감싼 객체일 수도 있습니다. 프런트 컨트롤러는 Object handler를 받고 지원하는 어댑터를 찾습니다.

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

import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;

public interface HandlerAdapter {
    boolean supports(Object handler);

    ModelView handle(
            HttpServletRequest request,
            HttpServletResponse response,
            Object handler)
            throws ServletException, IOException;
}

어댑터가 반환하는 ModelViewnull일 수 있습니다. 레거시 핸들러가 응답을 직접 완료한 경우입니다. 새 코드에서는 명시적 결과 타입이 더 안전하지만 기존 규칙과 함께 운영할 때 어댑터가 차이를 흡수합니다.

PageController 어댑터

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

import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

public final class PageControllerAdapter
        implements HandlerAdapter {
    @Override
    public boolean supports(Object handler) {
        return handler instanceof PageController;
    }

    @Override
    public ModelView handle(
            HttpServletRequest request,
            HttpServletResponse response,
            Object handler) {
        var controller = (PageController) handler;
        var parameters = request.getParameterMap()
                .entrySet()
                .stream()
                .collect(Collectors.toUnmodifiableMap(
                        Map.Entry::getKey,
                        entry -> List.of(entry.getValue())));
        return controller.handle(parameters);
    }
}

파라미터 맵 변환이 프런트 컨트롤러에서 어댑터로 이동했습니다. 다른 컨트롤러 규칙이 타입이 지정된 명령을 요구하면 별도 어댑터가 변환을 담당할 수 있습니다.

레거시 컨트롤러 어댑터

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

import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;

@FunctionalInterface
public interface LegacyController {
    void process(
            HttpServletRequest request,
            HttpServletResponse response)
            throws IOException;
}
src/main/java/board/mvc/LegacyControllerAdapter.java
package board.mvc;

import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;

public final class LegacyControllerAdapter
        implements HandlerAdapter {
    @Override
    public boolean supports(Object handler) {
        return handler instanceof LegacyController;
    }

    @Override
    public ModelView handle(
            HttpServletRequest request,
            HttpServletResponse response,
            Object handler)
            throws IOException {
        ((LegacyController) handler)
                .process(request, response);
        return null;
    }
}

레거시 어댑터는 마이그레이션을 위한 연결부입니다. 응답이 이미 커밋될 수 있어 뷰 리졸버를 호출하지 않습니다. 신규 기능까지 레거시 규칙으로 작성하는 근거가 되지 않으며 점진적으로 PageController나 Spring 애노테이션 컨트롤러로 옮깁니다.

디스패처의 어댑터 선택

src/main/java/board/mvc/AdaptedFrontController.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;

public final class AdaptedFrontController
        extends HttpServlet {
    private final HandlerMapping mapping;
    private final List<HandlerAdapter> adapters;
    private final ViewResolver views;

    public AdaptedFrontController(
            HandlerMapping mapping,
            List<HandlerAdapter> adapters,
            ViewResolver views) {
        this.mapping = mapping;
        this.adapters = List.copyOf(adapters);
        this.views = views;
    }

    @Override
    protected void service(
            HttpServletRequest request,
            HttpServletResponse response)
            throws ServletException, IOException {
        var handler = mapping.required(request);
        var adapter = adapters.stream()
                .filter(candidate ->
                        candidate.supports(handler))
                .findFirst()
                .orElseThrow(() ->
                        new IllegalStateException(
                                "No adapter for "
                                        + handler.getClass()));

        var result = adapter.handle(
                request, response, handler);
        if (result != null) {
            views.resolve(result.viewName())
                    .render(
                            result.model(),
                            request,
                            response);
        }
    }
}

어댑터 없음은 클라이언트 404가 아니라 서버 구성 오류입니다. 핸들러는 찾았지만 실행 방법을 등록하지 않았으므로 시작 시점 검증으로 더 일찍 발견하는 것이 좋습니다.

복수 컨트롤러 규칙

src/test/java/board/mvc/HandlerAdapterDispatchTest.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;

class HandlerAdapterDispatchTest {
    @Test
    void model_view_controller는_view를_render한다()
            throws Exception {
        PageController handler = parameters ->
                new ModelView(
                        "post-list",
                        Map.of("count", 2));
        var servlet = frontController(handler);
        var request = new MockHttpServletRequest(
                "GET", "/posts");
        var response = new MockHttpServletResponse();

        servlet.service(request, response);

        assertThat(request.getAttribute("count"))
                .isEqualTo(2);
        assertThat(response.getForwardedUrl())
                .isEqualTo(
                        "/WEB-INF/views/post-list.jsp");
    }

    @Test
    void legacy_controller는_직접_쓴_response를_유지한다()
            throws Exception {
        LegacyController handler =
                (request, response) -> {
                    response.setContentType("text/plain");
                    response.getWriter().write("legacy-ok");
                };
        var servlet = frontController(handler);
        var response = new MockHttpServletResponse();

        servlet.service(
                new MockHttpServletRequest(
                        "GET", "/legacy"),
                response);

        assertThat(response.getContentAsString())
                .isEqualTo("legacy-ok");
        assertThat(response.getForwardedUrl()).isNull();
    }

    private AdaptedFrontController frontController(
            Object handler) {
        return new AdaptedFrontController(
                request -> handler,
                List.of(
                        new PageControllerAdapter(),
                        new LegacyControllerAdapter()),
                name -> new JspView(
                        "/WEB-INF/views/" + name + ".jsp"));
    }
}
실행 결과
HandlerAdapterDispatchTest
  > model_view_controller는_view를_render한다() PASSED
HandlerAdapterDispatchTest
  > legacy_controller는_직접_쓴_response를_유지한다() PASSED
BUILD SUCCESSFUL

어댑터 순서·지원 범위

두 어댑터가 같은 핸들러를 지원하면 첫 번째 순서에 따라 실행이 달라질 수 있습니다. 지원 규칙을 상호 배타적으로 만들거나 우선순위를 명시하고 시작 검증으로 모호성을 찾습니다.

구성 오류관찰 결과방어
어댑터 없음핸들러 실행 전 500시작 시 범위 검사
어댑터 둘 이상순서에 따른 동작정확한 지원 규칙·우선순위
잘못된 형 변환런타임 ClassCastException지원과 실행을 같은 어댑터에 둠
null 결과 오해뷰 리졸버 호출 오류응답 완료 계약 명시

Spring MVC의 HandlerAdapter도 핸들러 종류마다 호출 방식을 추상화합니다. 애노테이션 컨트롤러는 RequestMappingHandlerAdapter가 인자 리졸버, 반환 값 핸들러, 데이터 바인딩을 조정해 메서드를 호출합니다.

연습 문제

JSON만 반환하는 ApiController 규칙과 어댑터를 추가하세요. 어댑터는 반환 객체를 ObjectMapper로 JSON 응답에 쓰고 ModelView는 반환하지 않습니다. 기존 PageController와 LegacyController 테스트를 수정하지 않고 세 종류 모두 동작해야 합니다.

해설 보기

ApiController는 요청 파라미터를 받아 응답 DTO를 반환하고 어댑터가 상태, 콘텐츠 타입, 직렬화를 담당합니다.

interface ApiController {
    Object handle(Map<String, List<String>> parameters);
}

final class ApiControllerAdapter
        implements HandlerAdapter {
    // supports에서 ApiController만 선택
    // handle에서 JSON을 쓰고 null 반환
}

같은 핸들러를 PageController와 ApiController 두 인터페이스로 동시에 구현하지 않게 하거나 어댑터 우선순위를 명시합니다. 테스트는 Content-Type: application/json, JSON 본문, 뷰 포워드 없음까지 검증합니다.

다음 문서에서는 이 매핑·어댑터·뷰 리졸버 조합이 Spring DispatcherServlet 안에서 어떤 순서로 실행되고 예외 리졸버와 인터셉터가 어디에 놓이는지 실제 MVC 트레이스로 연결합니다.