본문 바로 가기

스프링 직접 구현하기 #4: DispatcherServlet v2, 그리고 실제 스프링과의 비교

들어가며

지난 글에서 직접 DispatcherServlet을 v1, v2로 발전시켜 보면서 OCP 원칙의 중요성과 인터페이스 기반 설계의 이점을 체감했습니다. 이번에는 그 경험을 바탕으로, 실제 Spring Framework의 DispatcherServlet은 어떻게 구성되어 있고, 제가 만든 방식과 어떤 점이 다른지, 그리고 왜 그렇게 설계되었는지 심층적으로 알아보겠습니다.

환경

- JDK 17
- Spring Framework 6.2.6

🚀 핵심 차이점: 책임의 분리 수준

제가 만든 v2 DispatcherServlet은 경로에 따라 적절한 Handler 객체를 찾아 실행하는 구조였습니다. PathContextInitiator가 스캔 시점에 경로와 Handler 객체(실행 방식 포함)를 미리 매핑해두었죠.

 

이에 비해, 실제 스프링의 DispatcherServlet핸들러를 찾는 행위핸들러를 실행하는 행위를 더욱 명확하게 분리해두었습니다.

 

핸들러 매핑 (Handler Mapping): HandlerMapping 인터페이스와 그 구현체들이 담당.

- 요청 정보를 기반으로 이 요청을 처리할 핸들러 객체(handler object) 들을 찾아 결정합니다.

 

핸들러 어댑터 (Handler Adapter): HandlerAdapter 인터페이스와 그 구현체들이 담당.

- HandlerMapping이 찾아낸 핸들러 객체를 실제로 실행하는 방법을 알고 있습니다.

왜 이렇게 정교하게 분리했을까요?

제 v2 구현에서는 "핸들러를 찾으면 실행 방식은 이미 정해져 있다"고 가정하여 Handler 인터페이스에 찾는 것과 실행하는 것의 암묵적인 연결을 두었습니다. 이는 단순한 시나리오에서는 충분할 수 있습니다. 하지만 스프링은 매우 다양한 유형의 핸들러를 지원해야 하는 프레임워크입니다.

 

1. 다양한 핸들러 유형 지원의 유연성

@Controller 내부의 @RequestMapping 메서드, HttpRequestHandler 인터페이스 구현체, WebFlux의 RouterFunction에서 정의된 핸들러 함수, 사용자가 정의한 커스텀 핸들러 타입등 다양한 핸들러를 유연하게 지원할 수 있어야 합니다.

 

2. 전략 패턴의 극대화 (Strategy Pattern):

  • HandlerMapping은 "어떤 전략으로 핸들러를 찾을 것인가?"에 대한 다양한 구현을 허용합니다. (어노테이션 기반, URL 규칙 기반, 빈 이름 기반 등)
  • HandlerAdapter는 "찾아낸 핸들러를 어떤 전략으로 실행할 것인가?"에 대한 다양한 구현을 허용합니다.
    이 두 전략의 조합을 통해 스프링 MVC는 엄청난 유연성과 확장성을 확보합니다.

3. 책임의 명확한 분리 (Single Responsibility Principle):

  • HandlerMapping: 요청 분석, 핸들러 객체 및 인터셉터 결정에만 집중.
  • HandlerAdapter: 특정 타입의 핸들러 객체 실행에만 집중.
  • DispatcherServlet: 전체 요청 처리 흐름을 조정하고, 적절한 HandlerMapping과 HandlerAdapter에게 작업을 위임.

4. 인터셉터 처리의 통합 

HandlerMapping은 핸들러를 찾는 과정에서 해당 요청에 적용될 HandlerInterceptor 체인도 함께 구성합니다. 이는 요청 처리 전후의 공통 로직을 효과적으로 적용할 수 있게 해줍니다.

 

5. 테스트 용이성 

각 컴포넌트가 명확한 책임을 가지므로 개별적으로 테스트하기가 훨씬 수월합니다.

⚙️ DispatcherServlet의 실제 동작 흐름

DispatcherServlet은 내부에 다음과 같은 주요 멤버를 가집니다.

// DispatcherServlet.java
private List<HandlerMapping> handlerMappings;
private List<HandlerAdapter> handlerAdapters;

소스코드
요청이 들어오면 doDispatch 메서드가 호출되며, 핵심적인 흐름은 다음과 같습니다 (추상화한 버전):

protected void doDispatch(HttpServletRequest request, HttpServletResponse response) {

        //들어온 요청 정보 바탕으로 HandlerMapping 을 찾는다
        HandlerMapping handlerMapping = getHandler(request);

        //HandlerMapping 내의 Handler 를 실행가능한 HandlerAdapter 를 찾는다.
        HandlerAdapter handlerAdapter = getHandlerAdapter(handlerMapping.getHandler());

           //핸들러 어댑터가 해당 Handler 실행
        handlerAdapter.handle(request, response, handlerMapping.getHandler());

    }

주요 단계 상세:

1. getHandler(request) - 핸들러 매핑 단계:

  • DispatcherServlet은 자신이 알고 있는 HandlerMapping 구현체 리스트(handlerMappings)를 순회합니다.
  • 각 HandlerMapping에게 "이 요청을 처리할 핸들러가 있는가?"라고 묻습니다
  • 최초로 핸들러를 반환하는 HandlerMapping이 선택됩니다.

2. getHandlerAdapter(handler) - 핸들러 어댑터 조회 단계:

  • HandlerMapping이 찾아준 핸들러 객체(HandlerMethod)를 실행할 수 있는 HandlerAdapter를 찾습니다.
  • DispatcherServlet은 자신이 알고 있는 HandlerAdapter 구현체 리스트(handlerAdapters)를 순회합니다.
  • 각 HandlerAdapter에게 "이 핸들러 객체를 당신이 실행할 수 있는가?"라고 묻습니다 (handlerAdapter.supports(handler)).
  • true를 반환하는 첫 번째 HandlerAdapter가 선택됩니다.

3. ha.handle(request, response, handler) - 핸들러 실행 단계:

  • 선택된 HandlerAdapter의 handle 메서드를 호출하여 실제 핸들러 로직을 실행합니다.
  • 이 과정에서 파라미터 바인딩, 값 변환, 메서드 실행, 반환 값 처리 등이 어댑터 내부 또는 위임된 컴포넌트에 의해 수행됩니다.

HandlerMapping 상세

HandlerMapping은 인터페이스이며, 스프링은 다양한 구현체를 기본으로 제공합니다.

  • RequestMappingHandlerMapping: @Controller 어노테이션과 @RequestMapping (및 그 변형인 @GetMapping 등) 어노테이션을 사용한 핸들러 메서드를 찾아 매핑합니다. (가장 흔히 사용)
  • BeanNameUrlHandlerMapping: 빈(Bean)의 이름을 URL 경로로 매핑합니다.
  • SimpleUrlHandlerMapping: URL 패턴과 핸들러 빈을 명시적으로 매핑합니다.
  • RouterFunctionMapping: WebFlux 스타일의 함수형 라우팅을 지원합니다.
  • (그 외 WelcomePageHandlerMapping, ResourceHandlerMapping 등)

제가 v1에서 PathContext.PATH_METHOD_MAP 하나로 모든 경로-메서드를 관리했던 것과 달리, 스프링은 여러 HandlerMapping 구현체가 각자의 전략에 따라 매핑 정보를 내부적으로 관리하고, DispatcherServlet은 이들에게 위임하여 핸들러를 찾습니다.

 

RequestMappingHandlerMapping을 예로 들면, 내부적으로 Map<T, MappingRegistration<T>> mappingRegistry와 유사한 자료구조를 사용하여 매핑 정보를 저장합니다. 여기서 T는 요청 매핑 조건 (RequestMappingInfo)이며, MappingRegistration은 실제 핸들러(HandlerMethod) 등을 포함합니다. HandlerMethod는 특정 빈의 특정 메서드를 가리키는, 메서드 정보를 한 번 더 캡슐화한 객체입니다.

 

초기화 과정 (AbstractHandlerMethodMapping):
RequestMappingHandlerMapping과 같은 어노테이션 기반 매핑 구현체들은 애플리케이션 컨텍스트가 로딩될 때 빈들을 스캔하여 핸들러 메서드를 찾아 자신의 mappingRegistry에 등록합니다. AbstractHandlerMethodMapping의 detectHandlerMethods와 registerHandlerMethod 등이 이 역할을 수행합니다.

// AbstractHandlerMethodMapping.java
protected void detectHandlerMethods(final Object handler) {
    Class<?> handlerType = (handler instanceof String ?
            obtainApplicationContext().getType((String) handler) : handler.getClass());

    if (handlerType != null) {
        final Class<?> userType = ClassUtils.getUserClass(handlerType); 
        // 1. 핸들러 타입(클래스) 내의 모든 메서드를 가져와서,
        // 2. 각 메서드에 대해 자식 클래스(예: RequestMappingHandlerMapping)의 getMappingForMethod를 호출하여 매핑 정보(예: RequestMappingInfo)를 얻음
        Map<Method, T> methods = MethodIntrospector.selectMethods(userType,
                (MethodIntrospector.MetadataLookup<T>) method -> getMappingForMethod(method, userType));

        // 3. 얻어온 매핑 정보(T)와 메서드를 반복 처리
        methods.forEach((method, mapping) -> {
            Method invocableMethod = AopUtils.selectInvocableMethod(method, userType); 
            // 4. 실제 등록 로직 호출 (mappingRegistry에 저장)
            registerHandlerMethod(handler, invocableMethod, mapping);
        });
    }
}

이 코드는 빈이 초기화될 때 호출되어 @RequestMapping 등이 붙은 메서드를 찾아 HandlerMapping 내부에 등록하는 과정을 보여줍니다.

HandlerAdapter 상세

HandlerAdapter 역시 인터페이스이며, 주요 메서드는 다음과 같습니다.

  • boolean supports(Object handler): 주어진 handler 객체를 이 어댑터가 실행할 수 있는지 여부를 반환합니다.
  • handle(request, response, handler): 실제 핸들러를 실행하고, 결과를 ModelAndView로 반환합니다.

스프링이 기본으로 제공하는 HandlerAdapter 구현체들:

  • RequestMappingHandlerAdapter: HandlerMethod 타입의 핸들러(주로 @RequestMapping 메서드)를 실행합니다.
  • HttpRequestHandlerAdapter: HttpRequestHandler 인터페이스를 구현한 핸들러를 실행합니다.
  • SimpleControllerHandlerAdapter: 구식 Controller 인터페이스를 구현한 핸들러를 실행합니다.
  • HandlerFunctionAdapter: WebFlux의 HandlerFunction을 실행합니다.

DispatcherServlet의 getHandlerAdapter(handlerMapping.getHandler()) 호출 시, handlerMapping.getHandler()가 반환한 핸들러 객체(예: HandlerMethod 인스턴스)를 supports() 메서드를 통해 여러 HandlerAdapter에게 전달하여 처리 가능한 어댑터를 찾습니다. 예를 들어, HandlerMethod 객체는 RequestMappingHandlerAdapter의 supports() 메서드에서 true를 반환받게 됩니다.

 

이후 RequestMappingHandlerAdapter의 handle() 메서드가 호출되면, 해당 HandlerMethod (즉, 개발자가 작성한 컨트롤러 메서드)가 다양한 전처리(인자 준비)와 후처리(반환값 처리)를 거쳐 실행됩니다.

마치며

스프링 DispatcherServlet의 내부 동작과 설계를 살펴보면서, 제가 v1, v2를 만들며 고민했던 지점들이 실제 프레임워크 수준에서는 얼마나 더 정교하고 체계적으로 해결되는지 알 수 있었습니다.

  • 단순함 vs. 유연성/확장성: 제가 만든 v2의 Handler 인터페이스는 스프링의 HandlerAdapter와 유사한 아이디어의 시작점이었습니다. 하지만 스프링은 이를 HandlerMapping (무엇을, 어떻게 찾을 것인가)과 HandlerAdapter (찾은 것을 어떻게 실행할 것인가)라는 두 개의 큰 축으로 더욱 세분화하여 각 컴포넌트의 책임을 명확히 하고, 엄청난 유연성과 확장성을 확보했습니다. 이는 단순한 애플리케이션에서는 과도한 복잡성일 수 있지만, 수많은 상황과 요구사항을 지원해야 하는 프레임워크에게는 필수적인 설계입니다.
  • 전략 패턴의 힘: 다양한 HandlerMappingHandlerAdapter 구현체들은 전형적인 전략 패턴의 예시입니다. DispatcherServlet은 컨텍스트(요청)에 따라 적절한 전략(매퍼와 어댑터)을 선택하여 사용합니다.

좀더 생각을 확장 해보자면, 도메인 관점에서 A 행동과 B 행동이 현실에서 밀접하게 연관되어 보이더라도, 코드 수준에서는 이를 분리했을 때 얻는 이점이 클 수 있다는 것을 깨달았습니다. 물론 항상 트레이드오프를 고려해야 합니다. 모든 상황에 스프링과 같은 복잡한 분리가 필요한 것은 아니지만, 그 기저에 깔린 설계 원칙을 이해하는 것은 더 나은 코드를 작성하는 데 큰 도움이 될 것입니다.

Reference

https://github.com/spring-projects/spring-framework/blob/v6.2.6/spring-webmvc/src/main/java/org/springframework/web/servlet/DispatcherServlet.java
https://github.com/spring-projects/spring-framework/blob/v6.2.6/spring-webmvc/src/main/java/org/springframework/web/servlet/handler/AbstractHandlerMethodMapping.java