본문으로 건너뛰기

안동민 개발노트

본문 시작

HandlerAdapter 확장

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

프런트 컨트롤러가 PageController 하나만 지원하면 새 컨트롤러 규칙을 추가할 때 디스패처의 instanceof 분기가 늘어납니다.

매핑은 어떤 핸들러가 맞는지 찾고, 어댑터는 그 핸들러를 어떤 인자로 호출해 어떤 결과로 바꿀지 담당하게 나누면 디스패처 흐름은 유지됩니다.

HandlerAdapter가 dispatcher와 controller contract를 분리한다

mapping은 Object handler를 찾고 adapter가 지원 여부·인자·결과 변환을 소유한다.

  1. HandlerMapping

    Object handler 반환

  2. supports

    adapter가 contract 판별

  3. invoke

    인자 준비·handler 호출

  4. ModelView

    결과를 공통 형태로 변환


핸들러와 컨트롤러

핸들러는 현재 요청을 처리하도록 매핑이 선택한 대상입니다.

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);
    }
}

파라미터 맵 변환이 프런트 컨트롤러에서 어댑터로 이동했습니다.

다른 컨트롤러 규칙이 타입이 지정된 명령을 요구하면 별도 어댑터가 변환을 담당할 수 있습니다.

dispatch는 지원 adapter 하나를 찾아 호출한다

handler를 찾았지만 adapter가 없으면 client 404가 아니라 server 구성 오류다.

  1. Handler 없음

    404 mapping failure

  2. Adapter 하나

    정상 invoke

  3. Adapter 없음

    server configuration error

  4. Adapter 여러 개

    모호성으로 fail fast


레거시 컨트롤러 어댑터

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가 아니라 서버 구성 오류입니다.

핸들러는 찾았지만 실행 방법을 등록하지 않았으므로 시작 시점 검증으로 더 일찍 발견하는 것이 좋습니다.

서로 다른 controller 결과를 adapter가 정규화한다

migration contract를 지원하되 신규 code의 반환 의미를 모호하게 만들지 않는다.

HandlerAdapter 결과후속 처리
PageControllerModelViewViewResolver
LegacyControllernull이미 response 작성
ApiControllernullJSON converter 작성

복수 컨트롤러 규칙

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가 인자 리졸버, 반환 값 핸들러, 데이터 바인딩을 조정해 메서드를 호출합니다.

adapter 모호성은 startup에서 발견한다

두 adapter가 같은 handler를 지원하거나 하나도 지원하지 않는 구성을 첫 요청까지 늦추지 않는다.

  1. Discover

    handler·adapter 목록

  2. Matrix

    supports 관계 계산

  3. Validate

    각 handler에 정확히 하나

  4. Start / Fail

    정상 시작 또는 즉시 중단


연습 문제

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 트레이스로 연결합니다.