Spring MVC DispatcherServlet 동작 원리 — 요청 한 줄이 컨트롤러를 만나기까지
Spring MVC 위에서
@GetMapping("/hello")만 적으면 컨트롤러가 호출됩니다. 하지만 그 한 줄 뒤에는 서블릿 컨테이너에서 시작해 HandlerMapping, HandlerAdapter, ArgumentResolver, ReturnValueHandler, ViewResolver를 차례로 거치는 긴 파이프라인이 있습니다. 이 글은 Spring Framework 6 기준으로 DispatcherServlet의 초기화와 요청 처리 흐름을 한 단계씩 따라가 봅니다. 대상 독자는 Spring Boot로 컨트롤러는 많이 작성해 봤지만 그 안에서 무슨 일이 벌어지는지 한 번도 들춰보지 않은 분입니다.
Front Controller라는 패턴
웹 애플리케이션이 처음 등장했을 때는 URL마다 별개의 서블릿을 매핑하는 것이 일반적이었습니다. /users는 UserServlet, /orders는 OrderServlet처럼 말입니다. 이 구조는 단순하지만 인증, 로깅, 트랜잭션, 뷰 렌더링 같은 횡단 관심사를 서블릿마다 중복 작성해야 한다는 문제가 있습니다.
Front Controller 패턴은 모든 요청을 하나의 서블릿으로 받은 다음, 그 안에서 정해진 알고리즘에 따라 적절한 핸들러로 위임하는 구조입니다. 공통 로직은 Front Controller에 한 번만 두면 됩니다. Spring MVC의 진입점인 DispatcherServlet이 바로 이 Front Controller입니다.
flowchart LR
Client[HTTP Request] --> Container[Servlet Container]
Container --> DS[DispatcherServlet]
DS --> HM[HandlerMapping]
HM --> HA[HandlerAdapter]
HA --> Controller[Controller method]
Controller --> VR[ViewResolver]
VR --> Response[HTTP Response]
핵심은 DispatcherServlet 자체가 컨트롤러를 호출하지 않는다는 점입니다. 호출 책임을 전략 인터페이스(strategy interface)들에 위임하고, DispatcherServlet은 그 전략들을 순서대로 호출하는 흐름만 관리합니다. 이런 구조 덕분에 같은 DispatcherServlet 위에서 @Controller, @RestController, RouterFunction, 정적 자원 서빙이 모두 공존할 수 있습니다.
클래스 계층: HttpServlet에서 DispatcherServlet까지
DispatcherServlet은 사실 세 단계의 추상 계층 위에 얹혀 있습니다.
flowchart TD
HttpServlet --> HttpServletBean
HttpServletBean --> FrameworkServlet
FrameworkServlet --> DispatcherServlet
HttpServlet— Jakarta(구 javax) Servlet API의 기본 클래스HttpServletBean— Servlet 초기화 파라미터를 빈 프로퍼티로 자동 바인딩FrameworkServlet—WebApplicationContext를 만들고 관리,service()를 Spring 흐름으로 가로채는 책임DispatcherServlet— 실제 요청 디스패치 알고리즘 구현
Spring Boot를 쓰면 이 계층 구조는 DispatcherServletAutoConfiguration이 자동으로 등록해 주기 때문에 잘 드러나지 않지만, 전통적인 web.xml 시절에는 다음처럼 직접 등록했습니다.
<servlet>
<servlet-name>dispatcher</servlet-name>
<servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class>
<load-on-startup>1</load-on-startup>
</servlet>
<servlet-mapping>
<servlet-name>dispatcher</servlet-name>
<url-pattern>/</url-pattern>
</servlet-mapping>
<url-pattern>/</url-pattern>은 "정적 자원을 포함한 거의 모든 요청을 DispatcherServlet으로 보내라"는 의미입니다. 정적 자원을 어떻게 처리할지는 그 다음 문제이고, 일단 모든 요청이 한 군데로 들어와야 Front Controller가 성립합니다.
WebApplicationContext의 두 계층
DispatcherServlet은 일반 빈 컨테이너인 ApplicationContext를 그대로 쓰지 않습니다. 서블릿 컨테이너 환경에 맞춰 확장된 WebApplicationContext를 사용하며, 흔히 루트 컨텍스트와 서블릿 컨텍스트라는 두 계층 구조로 나뉩니다.
flowchart TD
Root[Root WebApplicationContext<br/>services, repositories, infrastructure beans]
Servlet[Servlet WebApplicationContext<br/>controllers, view resolvers, handler mappings]
Root --> Servlet
- 루트 컨텍스트는
ContextLoaderListener가 만들고, 비즈니스 계층(Service, Repository, DataSource, TransactionManager 등)을 담습니다. - 서블릿 컨텍스트는 각 DispatcherServlet이 만들고, 웹 계층(Controller, HandlerMapping, ViewResolver 등)을 담습니다.
- 자식인 서블릿 컨텍스트에서는 부모인 루트 컨텍스트의 빈을 참조할 수 있지만, 그 반대는 불가능합니다.
Spring Boot에서는 보통 두 계층을 굳이 분리하지 않고 단일 WebApplicationContext만 둡니다. 하나의 임베디드 톰캣과 하나의 DispatcherServlet만 쓰는 경우가 대부분이라 분리할 동기가 약하기 때문입니다. 다만 한 애플리케이션이 여러 DispatcherServlet을 띄우는 경우(예: REST용 / 관리 콘솔용 분리), 공통 인프라 빈을 루트로 끌어올리는 패턴이 다시 유용해집니다.
초기화: initStrategies()의 8가지 전략
FrameworkServlet이 WebApplicationContext 준비를 마치고 나면, DispatcherServlet의 onRefresh(ApplicationContext)가 호출되고, 그 안에서 initStrategies(ApplicationContext)가 실행됩니다. 이 메서드는 DispatcherServlet이 의존하는 전략 빈들을 컨텍스트에서 찾아 자기 자신의 필드에 주입합니다. 호출 순서는 다음과 같습니다.
initMultipartResolver(context)— 파일 업로드 처리initLocaleResolver(context)— 사용자 로케일 결정initHandlerMappings(context)— URL → 핸들러 매핑initHandlerAdapters(context)— 핸들러 호출 어댑터initHandlerExceptionResolvers(context)— 예외 → 응답 변환initRequestToViewNameTranslator(context)— 뷰 이름 생성 전략initViewResolvers(context)— 뷰 이름 → View 렌더링initFlashMapManager(context)— 리다이렉트 사이의 일회성 속성 저장소
Spring Framework 5.x까지 존재하던
initThemeResolver(context)는ThemeResolver/ThemeSourceAPI가 deprecated 후 6.0에서 제거되면서 함께 사라졌습니다. 따라서 6.x 기준 초기화 전략은 위와 같이 8가지입니다.
각각의 initXxx 메서드는 동일한 패턴으로 동작합니다. 먼저 컨텍스트에서 해당 인터페이스 타입의 빈을 모두 찾고, 하나도 없으면 DispatcherServlet.properties에 적힌 기본 구현을 직접 생성합니다.
# org/springframework/web/servlet/DispatcherServlet.properties (요약)
org.springframework.web.servlet.LocaleResolver=org.springframework.web.servlet.i18n.AcceptHeaderLocaleResolver
org.springframework.web.servlet.HandlerMapping=org.springframework.web.servlet.handler.BeanNameUrlHandlerMapping,\
org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping,\
org.springframework.web.servlet.function.support.RouterFunctionMapping
org.springframework.web.servlet.HandlerAdapter=org.springframework.web.servlet.mvc.HttpRequestHandlerAdapter,\
org.springframework.web.servlet.mvc.SimpleControllerHandlerAdapter,\
org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter,\
org.springframework.web.servlet.function.support.HandlerFunctionAdapter
org.springframework.web.servlet.HandlerExceptionResolver=org.springframework.web.servlet.mvc.method.annotation.ExceptionHandlerExceptionResolver,\
org.springframework.web.servlet.mvc.annotation.ResponseStatusExceptionResolver,\
org.springframework.web.servlet.mvc.support.DefaultHandlerExceptionResolver
org.springframework.web.servlet.RequestToViewNameTranslator=org.springframework.web.servlet.view.DefaultRequestToViewNameTranslator
org.springframework.web.servlet.ViewResolver=org.springframework.web.servlet.view.InternalResourceViewResolver
org.springframework.web.servlet.FlashMapManager=org.springframework.web.servlet.support.SessionFlashMapManager
여기서 중요한 두 가지가 있습니다.
첫째, 이 파일은 최후의 수단입니다. 컨텍스트에 해당 타입의 빈이 하나라도 있으면 properties 파일은 무시됩니다. Spring Boot나 @EnableWebMvc는 이 빈들을 모두 컨텍스트에 등록해 두므로, 실제 운영 환경에서는 거의 항상 컨텍스트의 빈이 사용됩니다.
둘째, HandlerMapping과 HandlerAdapter는 목록입니다. 등록된 순서대로 시도해서 처음 매칭되는 빈을 사용합니다. 그래서 BeanNameUrlHandlerMapping(URL을 빈 이름으로 매핑하는 레거시 방식)과 RequestMappingHandlerMapping(@RequestMapping 처리)이 한 애플리케이션 안에서 공존할 수 있습니다.
doDispatch(): 요청 디스패치의 정수
요청이 들어오면 서블릿 컨테이너는 DispatcherServlet.service()를 호출합니다. FrameworkServlet이 이를 가로채 doService()로 넘기고, doService()가 다시 doDispatch(request, response)를 호출합니다. 이 doDispatch가 Spring MVC의 심장이라고 봐도 무방합니다. 단순화한 의사 코드로 표현하면 다음과 같습니다.
protected void doDispatch(HttpServletRequest request, HttpServletResponse response) throws Exception {
HttpServletRequest processedRequest = request;
HandlerExecutionChain mappedHandler = null;
ModelAndView mv = null;
Exception dispatchException = null;
try {
// 1. 멀티파트 요청이면 MultipartHttpServletRequest로 감싼다
processedRequest = checkMultipart(request);
// 2. HandlerMapping 목록을 돌며 핸들러를 찾는다
mappedHandler = getHandler(processedRequest);
if (mappedHandler == null) {
noHandlerFound(processedRequest, response);
return;
}
// 3. 이 핸들러를 호출할 수 있는 HandlerAdapter를 고른다
HandlerAdapter ha = getHandlerAdapter(mappedHandler.getHandler());
// 4. 인터셉터 preHandle 체인 실행, false면 즉시 종료
if (!mappedHandler.applyPreHandle(processedRequest, response)) {
return;
}
// 5. 실제 핸들러 실행 — 컨트롤러 메서드가 여기서 호출된다
mv = ha.handle(processedRequest, response, mappedHandler.getHandler());
// 6. 뷰 이름이 비어 있다면 RequestToViewNameTranslator로 기본값 생성
applyDefaultViewName(processedRequest, mv);
// 7. 인터셉터 postHandle 체인
mappedHandler.applyPostHandle(processedRequest, response, mv);
} catch (Exception ex) {
dispatchException = ex;
}
// 8. 결과 처리: 예외가 있으면 HandlerExceptionResolver 거치고, 아니면 뷰 렌더링
processDispatchResult(processedRequest, response, mappedHandler, mv, dispatchException);
}
processDispatchResult는 다음을 차례로 수행합니다.
private void processDispatchResult(/* ... */) throws Exception {
if (exception != null) {
// 예외 → ModelAndView 변환
mv = processHandlerException(request, response, handler, exception);
}
if (mv != null && !mv.wasCleared()) {
// 뷰 이름 → View로 변환 후 render()
render(mv, request, response);
}
if (mappedHandler != null) {
// 인터셉터 afterCompletion 체인 (예외가 발생해도 항상 호출)
mappedHandler.triggerAfterCompletion(request, response, null);
}
}
전체 흐름을 그림으로 정리하면 다음과 같습니다.
flowchart TD
Start[doDispatch] --> CM[checkMultipart]
CM --> GH[getHandler]
GH --> NoHandler{handler<br/>found?}
NoHandler -- no --> NHF[noHandlerFound 404]
NoHandler -- yes --> GA[getHandlerAdapter]
GA --> Pre[applyPreHandle]
Pre --> PreOk{returns true?}
PreOk -- no --> End[return]
PreOk -- yes --> Handle[ha.handle - invoke controller]
Handle --> ADV[applyDefaultViewName]
ADV --> Post[applyPostHandle]
Post --> PDR[processDispatchResult]
PDR --> Render[render view]
Render --> AC[triggerAfterCompletion]
이 알고리즘의 흥미로운 점은 컨트롤러를 직접 호출하는 곳이 단 한 줄(ha.handle(...))이라는 사실입니다. 나머지는 전부 라우팅과 인터셉터 체인의 후처리입니다.
HandlerMapping: URL을 핸들러로
getHandler()는 등록된 HandlerMapping들을 순서대로 호출해 HandlerExecutionChain을 얻습니다. 가장 많이 쓰이는 구현은 RequestMappingHandlerMapping으로, @RequestMapping(및 그 메타 어노테이션인 @GetMapping, @PostMapping 등)이 붙은 메서드를 스캔해 다음 정보를 인덱싱해 둡니다.
- HTTP method (
GET,POST, ...) - URL 패턴 (
/users/{id}) - 헤더, 파라미터 조건
- consume/produce 미디어 타입
요청이 들어오면 이 인덱스를 조회해 가장 구체적인 매핑을 찾고, 그 메서드를 HandlerMethod라는 객체로 감싸 반환합니다. HandlerMethod는 곧 "어떤 빈의 어떤 Method 객체를 호출할지"에 대한 핸들입니다.
HandlerExecutionChain은 이 HandlerMethod(= 진짜 핸들러)와, 이 요청에 적용될 HandlerInterceptor 배열을 함께 묶은 것입니다. 이후 doDispatch는 이 체인을 들고 다니며 applyPreHandle, applyPostHandle, triggerAfterCompletion을 차례로 호출합니다.
여러 HandlerMapping이 공존할 때 우선순위는 Ordered 인터페이스의 값으로 정해집니다. 일반적으로 RequestMappingHandlerMapping(0)이 가장 먼저, 정적 리소스를 다루는 SimpleUrlHandlerMapping(Integer.MAX_VALUE - 1)이 나중에 시도됩니다.
HandlerAdapter: 어떻게 호출할지를 가르치는 어댑터
HandlerMapping이 반환하는 핸들러의 타입이 한 가지가 아니라는 것이 HandlerAdapter가 존재하는 이유입니다. @Controller 메서드는 HandlerMethod이지만, 오래된 Controller 인터페이스 구현체나 HttpRequestHandler(정적 리소스 서빙용), 함수형 RouterFunction은 호출 시그니처가 전혀 다릅니다. DispatcherServlet이 이 차이를 알 필요는 없게, 모두 같은 HandlerAdapter.handle(req, resp, handler) 시그니처 뒤로 감추는 것이 어댑터 패턴의 목적입니다.
flowchart LR
Handler[Handler object] --> Adapter{HandlerAdapter<br/>supports?}
Adapter --> RMA[RequestMappingHandlerAdapter<br/>for HandlerMethod]
Adapter --> SCA[SimpleControllerHandlerAdapter<br/>for Controller iface]
Adapter --> HRA[HttpRequestHandlerAdapter<br/>for HttpRequestHandler]
Adapter --> HFA[HandlerFunctionAdapter<br/>for RouterFunction]
가장 많이 보게 될 RequestMappingHandlerAdapter는 그냥 HandlerMethod를 호출하는 정도가 아니라, 컨트롤러 메서드를 호출 가능한 형태로 변환하는 거의 모든 작업을 책임집니다. 크게 두 가지 합성기를 들고 있습니다.
HandlerMethodArgumentResolver목록 — 메서드 파라미터를 만든다HandlerMethodReturnValueHandler목록 — 반환값을 응답으로 만든다
ArgumentResolver: 메서드 파라미터를 만드는 30가지 방법
public ResponseEntity<UserDto> getUser(@PathVariable Long id, @RequestHeader("X-Tenant") String tenant, Pageable pageable) 같은 시그니처가 가능한 이유는 각 파라미터에 맞는 HandlerMethodArgumentResolver가 따로 있기 때문입니다. 기본 등록되는 주요 리졸버는 다음과 같습니다.
RequestParamMethodArgumentResolver—@RequestParamPathVariableMethodArgumentResolver—@PathVariableRequestHeaderMethodArgumentResolver—@RequestHeaderRequestBodyMethodArgumentResolver—@RequestBody(HttpMessageConverter 호출)ModelAttributeMethodProcessor—@ModelAttribute및 일반 객체 바인딩ServletRequestMethodArgumentResolver—HttpServletRequest,Locale등 서블릿 타입HttpEntityMethodProcessor—HttpEntity<T>,RequestEntity<T>
RequestMappingHandlerAdapter는 이들을 HandlerMethodArgumentResolverComposite에 합쳐 두고, 파라미터마다 supportsParameter()를 호출해 처음 true를 반환한 리졸버에 값 생성을 위임합니다. 어노테이션 기반과 타입 기반이 한 컬렉션에 섞여 있기 때문에 등록 순서가 의미를 가집니다.
@RequestBody처럼 본문 역직렬화가 필요한 경우 리졸버는 HttpMessageConverter 목록을 다시 순회합니다. JSON이면 MappingJackson2HttpMessageConverter, XML이면 MappingJackson2XmlHttpMessageConverter 또는 Jaxb2RootElementHttpMessageConverter가 선택됩니다. Content-Type 헤더와 컨버터의 canRead()가 매칭 기준입니다.
ReturnValueHandler: 반환값을 응답으로
메서드가 끝나면 반대 방향의 처리가 일어납니다. 반환 타입에 따라 처리 방식이 완전히 갈리기 때문입니다.
String이나void: 뷰 이름으로 해석 (또는RequestToViewNameTranslator가 기본값 생성)ModelAndView: 그대로 사용@ResponseBody또는@RestController메서드:HttpMessageConverter로 직렬화ResponseEntity<T>: 헤더/상태 코드까지 함께 결정Callable<T>,DeferredResult<T>,WebAsyncTask<T>: 비동기 처리로 전환ResponseBodyEmitter,SseEmitter,StreamingResponseBody: 스트리밍
이 분기는 HandlerMethodReturnValueHandler 목록의 supportsReturnType()으로 처리됩니다. 즉 "JSON으로 직렬화한다"는 동작이 DispatcherServlet이나 RequestMappingHandlerAdapter에 하드코딩돼 있는 게 아니라, RequestResponseBodyMethodProcessor라는 별도 빈에 정의돼 있고 그 결과로 응답이 만들어집니다. 새로운 반환 타입(예: RxJava Single)을 추가하고 싶다면 새 HandlerMethodReturnValueHandler를 등록하면 됩니다.
HandlerInterceptor: AOP보다 가볍게
HandlerInterceptor는 세 개의 콜백을 가진 단순한 인터페이스입니다.
public interface HandlerInterceptor {
default boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
return true;
}
default void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
}
default void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
}
}
세 콜백이 호출되는 시점은 doDispatch 그림에서 본 그대로입니다.
preHandle: 컨트롤러 호출 직전.false를 반환하면 컨트롤러는 호출되지 않습니다.postHandle: 컨트롤러가 정상적으로 끝났을 때, 뷰가 렌더링되기 직전.ModelAndView를 수정할 수 있는 마지막 기회입니다.afterCompletion: 응답이 완료되거나 예외가 발생한 뒤. 자원 정리에 쓰입니다.
이 인터페이스는 Servlet Filter보다 위, Spring AOP보다 아래에 위치합니다. Filter가 서블릿 컨테이너 레벨에서 모든 서블릿에 공통으로 적용되는 반면, HandlerInterceptor는 DispatcherServlet 안에서 핸들러 단위로 등록되며, HandlerMethod 정보를 그대로 받아볼 수 있습니다. 즉 "어떤 컨트롤러가 매칭됐는지" 알면서도 가벼운 횡단 관심사를 처리할 수 있는 자리입니다.
ViewResolver와 뷰 렌더링
processDispatchResult가 예외 없이 ModelAndView를 얻으면 render()가 호출됩니다. 여기서 핵심은 ModelAndView가 들고 있는 것이 "뷰" 자체가 아니라 "뷰 이름" 또는 View 인스턴스라는 점입니다. 뷰 이름이면 ViewResolver 목록을 순회하면서 실제 View 객체를 찾아야 합니다.
flowchart LR
MV[ModelAndView] --> Name{view name?}
Name -- yes --> VR1[ViewResolver 1]
Name -- yes --> VR2[ViewResolver 2]
VR1 --> View[View instance]
VR2 --> View
Name -- no --> View
View --> Render[view.render model, request, response]
대표적인 구현:
InternalResourceViewResolver— JSP를 forward 방식으로 렌더링ThymeleafViewResolver— Thymeleaf 템플릿 엔진BeanNameViewResolver— 뷰 이름과 같은 이름의 빈을View로 사용ContentNegotiatingViewResolver—Accept헤더에 따라 위 리졸버 중 하나에 위임
Spring Boot에서 @RestController만 쓰는 REST API라면 이 뷰 처리 단계는 사실상 건너뛰게 됩니다. @ResponseBody가 붙은 메서드의 반환값은 ReturnValueHandler 단계에서 이미 응답 본문에 직접 써지기 때문에 ModelAndView 없이 render()가 호출되지 않습니다.
HandlerExceptionResolver: 예외를 응답으로
컨트롤러나 인터셉터에서 던져진 예외는 processDispatchResult 안의 processHandlerException()으로 전달됩니다. 이 메서드는 등록된 HandlerExceptionResolver 목록을 차례로 호출하고, 처음으로 ModelAndView를 반환하는 리졸버의 결과를 사용합니다. 기본으로 등록되는 세 가지가 다음과 같습니다.
ExceptionHandlerExceptionResolver—@ExceptionHandler메서드 (컨트롤러 내부 또는@ControllerAdvice)ResponseStatusExceptionResolver—@ResponseStatus가 붙은 예외 또는ResponseStatusExceptionDefaultHandlerExceptionResolver—HttpMessageNotReadableException,MethodArgumentNotValidException같은 Spring 표준 예외들을 적절한 상태 코드로 매핑
이 순서가 중요합니다. @ExceptionHandler가 명시된 예외는 1번에서 처리되어 끝나고, 그렇지 않으면 2번 → 3번으로 내려갑니다. 모두 처리하지 못하면 예외는 서블릿 컨테이너로 다시 던져지고, 컨테이너의 에러 페이지 매핑이 동작합니다(Spring Boot에서는 BasicErrorController가 받아 /error로 라우팅).
비동기 처리: Servlet 3.1 위에서
Servlet 3.1부터 비동기 요청 처리가 표준화됐고, Spring MVC는 이를 활용해 Callable, DeferredResult, WebAsyncTask, ListenableFuture, CompletableFuture 등을 반환할 수 있게 합니다. 흐름은 다음과 같습니다.
RequestMappingHandlerAdapter가 컨트롤러 메서드를 호출하고 비동기 결과를 받는다.WebAsyncManager가 결과 산출을 별도 스레드 풀(또는 콜백)로 위임하고, 원래 서블릿 스레드는 즉시 반환된다.- 결과가 준비되면
WebAsyncManager가request.startAsync()로 잡아둔 비동기 컨텍스트로 디스패치를 재시도(ASYNCdispatch type)하고, DispatcherServlet은 같은 doDispatch를 다시 돌며 뷰 렌더링/응답 직렬화를 마무리합니다.
요점은 비동기 처리에서도 DispatcherServlet의 알고리즘이 바뀌지 않는다는 것입니다. 같은 doDispatch가 두 번 호출될 뿐입니다. 처음에는 컨트롤러 호출과 비동기 토큰 반환을 위해, 두 번째에는 결과가 준비된 뒤 응답 직렬화를 위해.
Spring Boot가 숨겨주는 것
지금까지 본 모든 빈은 Spring Boot에서 자동 구성됩니다. 핵심 자동 구성 클래스는 다음과 같습니다.
DispatcherServletAutoConfiguration—DispatcherServlet을ServletRegistrationBean으로 등록WebMvcAutoConfiguration—RequestMappingHandlerMapping,RequestMappingHandlerAdapter, 메시지 컨버터, 정적 리소스 핸들러 등 MVC 전반HttpMessageConvertersAutoConfiguration— JSON/XML 컨버터 후보들ErrorMvcAutoConfiguration—BasicErrorController와/error라우팅
핵심 동작 원리를 알고 나면, Spring Boot의 "마법"이 사실은 @ConditionalOnMissingBean + 잘 정리된 디폴트 빈 등록이라는 점이 분명해집니다. 직접 RequestMappingHandlerAdapter의 ArgumentResolver 목록을 커스터마이즈하고 싶다면 WebMvcConfigurer.addArgumentResolvers()를 구현하면 되고, 메시지 컨버터를 바꾸려면 configureMessageConverters()를 쓰면 됩니다. 이 모두가 결국에는 DispatcherServlet이 들고 있는 전략 빈 목록을 조정하는 일입니다.
정리
DispatcherServlet은 "Front Controller 패턴을 충실히 구현한 서블릿"이지만, 그 충실함은 곧 알고리즘 구조 자체를 단순하게 유지하고 책임을 전략 인터페이스로 쪼개 둔다는 의미였습니다. 한 번 더 정리하면 다음과 같습니다.
- DispatcherServlet은 7가지 전략(
HandlerMapping,HandlerAdapter,HandlerExceptionResolver,ViewResolver,LocaleResolver,MultipartResolver,FlashMapManager)과 보조 빈(RequestToViewNameTranslator)에 위임한다.ThemeResolver는 Spring 5.x까지 존재했으나 6.0에서 제거되었다. - 컨텍스트에 빈이 없으면
DispatcherServlet.properties의 디폴트를 쓰지만, Spring Boot나@EnableWebMvc는 거의 항상 컨텍스트에 등록해 둔다. doDispatch는 멀티파트 감싸기 → HandlerMapping → HandlerAdapter → 인터셉터 preHandle → 핸들러 호출 → 인터셉터 postHandle → 결과/예외 처리 → 뷰 렌더링 → afterCompletion 순서로 단 한 번도 분기 없이 흐른다.RequestMappingHandlerAdapter는HandlerMethodArgumentResolver와HandlerMethodReturnValueHandler라는 두 개의 합성기를 통해 메서드 시그니처의 자유도를 만들어 낸다.- 비동기 처리도 같은 doDispatch를 두 번 흐르는 것으로 처리되며, 알고리즘 자체는 바뀌지 않는다.
다음에 @GetMapping 한 줄을 적을 때 떠올려 볼 만한 그림은 단순하지만 정확합니다. 요청은 늘 같은 알고리즘을 거치고, 우리가 작성한 컨트롤러는 그 알고리즘 안에서 단 한 번 호출되는 위임 지점일 뿐입니다. 나머지는 전부 전략 인터페이스의 조합으로 결정됩니다.
참고자료
- Spring Framework Reference — DispatcherServlet: https://docs.spring.io/spring-framework/reference/web/webmvc/mvc-servlet.html
- Spring Framework Reference — Special Bean Types: https://docs.spring.io/spring-framework/reference/web/webmvc/mvc-servlet/special-bean-types.html
- Spring Framework Reference — Context Hierarchy: https://docs.spring.io/spring-framework/reference/web/webmvc/mvc-servlet/context-hierarchy.html
- DispatcherServlet 소스: https://github.com/spring-projects/spring-framework/blob/main/spring-webmvc/src/main/java/org/springframework/web/servlet/DispatcherServlet.java
- DispatcherServlet.properties: https://github.com/spring-projects/spring-framework/blob/main/spring-webmvc/src/main/resources/org/springframework/web/servlet/DispatcherServlet.properties
- RequestMappingHandlerAdapter 소스: https://github.com/spring-projects/spring-framework/blob/main/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/RequestMappingHandlerAdapter.java
- Spring Boot Reference — Web Applications: https://docs.spring.io/spring-boot/reference/web/servlet.html
- Jakarta Servlet 6.0 Specification: https://jakarta.ee/specifications/servlet/6.0/

