CS/Spring

Spring MVC DispatcherServlet — Front Controller 패턴, 초기화와 요청 디스패치

dding-shark 2026. 4. 13. 19:59
728x90

Spring MVC DispatcherServlet — Front Controller 패턴, 초기화와 요청 디스패치


들어가며

톰캣이 HttpServletRequest를 들고 들어오면 누군가 받아서 컨트롤러까지 끌고 가야 한다. 스프링 웹 애플리케이션에서 그 "누군가"가 바로 DispatcherServlet이다. 그런데 이 이름이 주는 무게감 때문에 오해가 자주 생긴다. 사람들은 이걸 스프링의 거대한 웹 엔진이라고 상상한다. 실제로는 그냥 HttpServlet을 상속한 서블릿 한 개다. 서블릿 컨테이너 입장에서는 web.xml이나 ServletRegistrationBean으로 등록된 여러 서블릿 중 하나일 뿐이다. 특별한 점은 단 하나, 이 서블릿이 들어온 요청을 스프링 빈들에게 **위임(dispatch)**한다는 것이다.

그 위임 구조가 바로 GoF의 Front Controller 패턴이고, DispatcherServlet이 존재하는 유일한 이유다. 모든 요청이 이 하나를 통과하기 때문에 인증, 로깅, 예외 처리, 뷰 결정, 메시지 변환 같은 횡단 관심사를 여기 한 곳에서 일관되게 묶어낼 수 있다. 그런데 실무에서 이 메커니즘을 들여다보려 하면 거의 항상 다음 세 지점에서 막힌다.

  • DispatcherServlet이 "서블릿 하나"라는 것을 놓친다 — 특별한 엔진이 아니다. 그래서 WebApplicationInitializerServletRegistrationBean으로 등록된 순간부터가 시작이고, 그 외의 마법은 없다
  • 전략 빈(HandlerMapping, HandlerAdapter, ViewResolver 등) 없이도 동작하는 이유를 모르면 전부 블랙박스가 된다DispatcherServlet.properties의 폴백 기본값 때문이다
  • @EnableWebMvc가 Spring Boot의 자동구성을 꺼버린다는 사실을 모르고 붙였다가 메시지 컨버터, 정적 리소스, 예외 처리가 다 사라진다 — 이건 스위치다, 확장 포인트가 아니다

이 글은 왜 → 구조(3계층 상속) → 초기화 → 요청 디스패치 루프 → 컨텍스트 계층 → Boot 자동구성 → 실무 순으로 Spring Framework 6.x / Spring Boot 3.x / Jakarta EE 10 / Java 17+ 기준으로 정리한다.


목차


1) Front Controller 패턴: 왜 단일 진입점인가

서블릿만 쓰는 세계에서는 URL 하나마다 서블릿 클래스를 하나씩 만들거나, 혹은 거대한 switch를 가진 서블릿 하나가 전부를 처리한다. 전자는 횡단 관심사(인증·로깅·예외처리)를 각 서블릿이 복붙하게 만들고, 후자는 라우팅과 비즈니스 로직이 한 클래스에 엉킨다. Front Controller는 이 둘의 절충이다. **"진입은 하나, 위임은 여러 갈래"**라는 구조를 강제한다. 진입 지점이 하나이기 때문에 공통 관심사를 한 번만 선언하면 되고, 위임이 여러 갈래이기 때문에 핸들러들은 서로 독립적이다.

DispatcherServlet은 이 패턴을 전략(Strategy) 패턴과 조합해 구현했다. "요청을 받는다"라는 틀만 Front Controller로 고정하고, "어떤 핸들러를 고를까", "어떻게 호출할까", "응답을 어떻게 렌더링할까", "예외를 어떻게 응답으로 바꿀까"는 전부 갈아끼울 수 있는 전략 빈으로 분리했다. 스프링에서 DispatcherServlet을 바꾸는 일은 없어도 HandlerMapping이나 ViewResolver를 바꾸거나 추가하는 일은 흔하다는 게 이 설계의 결과다.

1-1) 직접 서블릿 vs Front Controller

항목 서블릿 N개 직접 DispatcherServlet (Front Controller)
횡단 관심사 서블릿마다 반복 또는 Filter 한 곳에 위임 + Interceptor/Advice
라우팅 web.xml 매핑 또는 URL 파싱 HandlerMapping이 전담
응답 형식 전환 각 서블릿이 직접 JSON/HTML 변환 MessageConverter / ViewResolver가 전담
예외 처리 try/catch 반복 HandlerExceptionResolver가 일괄
신규 엔드포인트 서블릿 클래스 추가 @RestController 메서드 한 줄

이 표의 오른쪽 컬럼이 요컨대 "DispatcherServlet이 뭘 해주는지"의 요약이다. 나머지 섹션은 전부 이 표의 각 행을 뜯어보는 이야기다.


2) 3계층 상속 구조: HttpServletBean → FrameworkServlet → DispatcherServlet

DispatcherServletHttpServlet을 직접 상속하지 않는다. 중간에 두 개의 추상 클래스가 끼어 있고 각 계층은 책임이 명확히 분리돼 있다. 이 분리를 모른 채 디스패처 초기화 과정을 이해하려 하면 코드가 왔다갔다하는 것처럼 보인다.

클래스 상속 책임
HttpServletBean HttpServlet 서블릿의 init-param을 빈 프로퍼티에 바인딩
FrameworkServlet HttpServletBean WebApplicationContext 생성/보관, 요청별 doService 호출
DispatcherServlet FrameworkServlet 9개 전략 빈 초기화(initStrategies), doDispatch로 요청 디스패치

각 계층은 아래 계층의 훅 메서드를 구현하는 방식으로 연결된다.

2-1) 각 계층이 하는 일

  • HttpServletBean.init(): 서블릿 컨테이너가 호출하는 표준 init()를 오버라이드한다. <init-param>이나 ServletConfig의 파라미터를 읽어 PropertyValues로 묶고 자기 자신(Bean)의 setter에 바인딩한다. 여기서 initServletBean()이라는 템플릿 메서드를 호출한다.
  • FrameworkServlet.initServletBean(): 이 훅에서 WebApplicationContext를 초기화한다. 이미 세팅된 컨텍스트가 있으면 쓰고, 없으면 contextClass(기본 XmlWebApplicationContext 또는 AnnotationConfigWebApplicationContext)로 새로 만든다. 그리고 컨텍스트 리프레시 이후 onRefresh(context)를 호출한다.
  • DispatcherServlet.onRefresh(): 이 훅에서 initStrategies(context)를 호출한다. 여기서 비로소 HandlerMapping, HandlerAdapter 같은 전략 빈들이 컨텍스트에서 찾아져 필드에 자리잡는다.

이 3단 템플릿은 **"서블릿 스펙 적응 → 컨텍스트 생성 → 전략 조립"**이라는 세 단계 변환을 의도적으로 분리한 것이다. 같은 구조를 유지한 채 FrameworkServlet을 재사용해 다른 웹 프레임워크를 만드는 것도 이론상 가능하다.

2-2) 초기화 흐름 시퀀스

14_spring-mvc-dispatcher-servlet-01

이 그림에서 핵심은 "서블릿 스펙의 init() 한 번이 내부적으로 세 개의 훅으로 분기한다"는 것이다. 한 메서드 안에서 다 하지 않고 계층별 책임에 따라 쪼갠 덕분에, 각 훅을 오버라이드해서 초기화 동작을 독립적으로 바꿀 수 있다.


3) initStrategies: 9개 전략 빈의 조립

DispatcherServlet.onRefresh가 호출하는 initStrategies(ApplicationContext)는 9개의 initXxx() 메서드를 순서대로 호출한다. 각 메서드는 해당 타입의 빈을 컨텍스트에서 찾아 필드에 담고, 못 찾으면 DispatcherServlet.properties에서 기본 구현을 읽어 인스턴스화한다. 이 "찾아본다 → 없으면 기본값" 흐름이 디스패처 전체의 설정 모델이다.

3-1) 9개 초기화 메서드 목록

메서드 타입 역할
initMultipartResolver MultipartResolver 파일 업로드 파싱 (없으면 null 허용)
initLocaleResolver LocaleResolver 요청 locale 결정
initThemeResolver ThemeResolver 테마 결정 (deprecated 가까움)
initHandlerMappings List<HandlerMapping> URL → 핸들러 매핑
initHandlerAdapters List<HandlerAdapter> 핸들러 호출 방식
initHandlerExceptionResolvers List<HandlerExceptionResolver> 예외 → 응답 변환
initRequestToViewNameTranslator RequestToViewNameTranslator 뷰 이름 자동 추론
initViewResolvers List<ViewResolver> 뷰 이름 → View 객체
initFlashMapManager FlashMapManager 리다이렉트 간 flash 속성

이 9개 중 요청 디스패치의 실질 핵심은 네 개다: HandlerMapping, HandlerAdapter, HandlerExceptionResolver, ViewResolver. 나머지는 보조다. 이 네 개가 "어떤 핸들러를, 어떻게 호출해, 실패하면 어떻게 처리하고, 성공하면 어떻게 렌더링할지"를 각각 전담한다.

3-2) "찾는다 → 없으면 기본값" 폴백 로직

initXxx()는 다음 패턴으로 돌아간다.

// 개략 (실제는 detectAllHandlerMappings 플래그 분기가 더 있음)
private void initHandlerMappings(ApplicationContext context) {
    Map<String, HandlerMapping> matching = BeanFactoryUtils
        .beansOfTypeIncludingAncestors(context, HandlerMapping.class, true, false);
    if (!matching.isEmpty()) {
        this.handlerMappings = new ArrayList<>(matching.values());
        AnnotationAwareOrderComparator.sort(this.handlerMappings);
    }
    // 명시 빈이 하나도 없으면 DispatcherServlet.properties의 기본값으로 대체
    if (this.handlerMappings == null) {
        this.handlerMappings = getDefaultStrategies(context, HandlerMapping.class);
    }
}

getDefaultStrategies는 클래스패스의 org/springframework/web/servlet/DispatcherServlet.properties를 Properties로 읽어, 해당 전략 타입에 매핑된 구현 클래스 목록을 BeanUtils.instantiateClass로 인스턴스화한다. 이 단계는 컨텍스트에 빈으로 등록되지 않는다. 디스패처 필드에만 세팅될 뿐, @Autowired로 주입할 수도 없고 BeanPostProcessor도 타지 않는다. 폴백으로 들어온 HandlerAdapter는 프레임워크 내부용 fallback 인스턴스인 셈이다.

이 분리가 "스프링 앱이 @EnableWebMvc나 Boot 자동구성 없이도 일단 뜨는" 이유다. 아무 설정이 없어도 디스패처가 혼자 기본값을 로드해서 돌아간다.


4) DispatcherServlet.properties: 전략 빈의 폴백 기본값

DispatcherServlet.properties는 spring-webmvc JAR의 org/springframework/web/servlet/ 아래에 들어 있다. 파일 내용을 직접 본 적이 없는 사람이 대부분이다. IDE에서 "DispatcherServlet" 클래스를 열고, 같은 디렉터리에 있는 이 파일을 열어보는 것만으로도 전략 빈 기본값 전체를 한눈에 볼 수 있다. 블로그나 문서로 보기보다 직접 열어보는 게 가장 빠르다. 여기서 틀리기 쉽다 — 이 파일은 검색으로 쉽게 안 나오기 때문에 "기본값이 어디서 오는지 모른다"는 말이 자주 나온다.

4-1) 6.x 기준 기본값 요약

전략 타입 폴백 기본 구현
LocaleResolver AcceptHeaderLocaleResolver
ThemeResolver FixedThemeResolver
HandlerMapping BeanNameUrlHandlerMapping, RequestMappingHandlerMapping, RouterFunctionMapping
HandlerAdapter HttpRequestHandlerAdapter, SimpleControllerHandlerAdapter, RequestMappingHandlerAdapter, HandlerFunctionAdapter
HandlerExceptionResolver ExceptionHandlerExceptionResolver, ResponseStatusExceptionResolver, DefaultHandlerExceptionResolver
RequestToViewNameTranslator DefaultRequestToViewNameTranslator
ViewResolver InternalResourceViewResolver
FlashMapManager SessionFlashMapManager

여기서 기억할 사실 세 가지.

첫째, @EnableWebMvc나 Boot 자동구성은 이 기본값 대신 더 잘 조립된 빈들을 컨텍스트에 등록한다. 그래서 폴백 경로를 타지 않고 명시 빈 경로로 간다. Boot 앱에서 HandlerExceptionResolver 목록을 로그로 찍어보면 DispatcherServlet.properties의 3개가 아니라 더 많은 구성이 보이는 이유다.

둘째, 폴백으로 들어온 인스턴스는 빈이 아니기 때문에 @Autowired 주입, @PostConstruct 콜백, BeanPostProcessor 훅을 전혀 못 탄다. 커스텀 로직을 넣고 싶으면 반드시 @Bean으로 직접 등록해야 한다.

셋째, 이 폴백은 "하나라도 있으면 해당 타입 폴백은 일체 적용되지 않는다" 규칙이다. HandlerMapping 빈을 하나만 직접 등록하면 나머지 3개 기본값은 사라진다. 이 규칙을 모르고 HandlerMapping 빈을 하나 추가했다가 @RequestMapping이 전부 안 잡히는 사고가 종종 난다. 추가하고 싶으면 WebMvcConfigurer.addInterceptors 같은 확장 포인트를 쓰거나, 기본 3개를 포함한 전체를 직접 등록해야 한다.


5) doDispatch: 요청 처리 루프의 실체

초기화가 끝나면 DispatcherServlet은 요청이 올 때마다 doServicedoDispatch를 호출한다. doDispatch는 한 요청의 생명 전체를 담당하는 메서드다. 긴 것 같지만 실제 뼈대는 단순하다.

// 개략 (주석·로깅·flash/async 처리 생략)
protected void doDispatch(HttpServletRequest request, HttpServletResponse response) throws Exception {
    HttpServletRequest processedRequest = checkMultipart(request);
    HandlerExecutionChain mappedHandler = getHandler(processedRequest);
    if (mappedHandler == null) { noHandlerFound(processedRequest, response); return; }

    HandlerAdapter ha = getHandlerAdapter(mappedHandler.getHandler());

    if (!mappedHandler.applyPreHandle(processedRequest, response)) return;

    ModelAndView mv;
    Exception dispatchException = null;
    try {
        mv = ha.handle(processedRequest, response, mappedHandler.getHandler());
    } catch (Exception ex) {
        dispatchException = ex;
        mv = null;
    }

    processDispatchResult(processedRequest, response, mappedHandler, mv, dispatchException);
}

processDispatchResult가 마지막 분기다. 예외가 있으면 HandlerExceptionResolver들을 순회해 응답 ModelAndView를 얻고, mv가 있고 뷰 렌더링이 필요하면 ViewResolverView를 만들어 render한다. @RestController처럼 @ResponseBody 계열이면 이 단계에서는 할 일이 거의 없다 — MessageConverter가 이미 HandlerAdapter 안에서 응답 바디를 써버린 상태이기 때문이다.

5-1) 요청 처리 시퀀스

14_spring-mvc-dispatcher-servlet-02

이 시퀀스의 분기점은 딱 두 개다. preHandletrue, 그리고 ModelAndViewnull이냐. 이 두 불리언이 디스패처 동작의 거의 전부를 결정한다. null이면 이미 응답이 쓰인 것이고, null이 아니면 아직 뷰 렌더링이 남은 것이다.

5-2) Interceptor vs Filter 경계

디스패처 루프 안에서 훅이 걸리는 지점은 HandlerInterceptor다. Filter는 디스패처 바깥에서 동작하고 Interceptor는 디스패처 에서 동작한다는 경계가 여기서 나온다. 인증이나 CORS처럼 디스패처 진입 전에 끊어내고 싶은 건 Filter가 맞고, "핸들러가 결정된 뒤에 동작해야 하는" 것은 Interceptor가 맞다. 이 경계를 헷갈려 Filter에서 HandlerMapping의 결과를 쓰려다 막히는 경우가 자주 있다.


6) HandlerMapping → HandlerAdapter: 타입 중립 디스패치

DispatcherServlet 설계에서 가장 영리한 부분이 이 두 전략의 조합이다. HandlerMapping은 "핸들러 객체를 찾아오는 것"까지만 한다. 이때 핸들러의 타입은 뭐든 될 수 있다. @RequestMapping 메서드를 가리키는 HandlerMethod일 수도 있고, 오래된 Controller 인터페이스 구현체일 수도 있고, HttpRequestHandler(정적 리소스) 같은 것일 수도 있으며, Spring 5의 RouterFunction일 수도 있다.

HandlerAdapter는 바로 이 **"타입이 제각각인 핸들러를 동일한 방식으로 호출"**하는 어댑터 패턴이다. HandlerAdapter.supports(handler)true인 첫 어댑터를 찾아 handle(request, response, handler)로 부른다. 어댑터는 내부적으로 타입 캐스팅을 하고 해당 타입에 맞는 호출 프로토콜로 연결한다.

HandlerMapping 찾는 핸들러 타입 짝 HandlerAdapter
RequestMappingHandlerMapping HandlerMethod RequestMappingHandlerAdapter
BeanNameUrlHandlerMapping Controller 인터페이스 빈 SimpleControllerHandlerAdapter
SimpleUrlHandlerMapping HttpRequestHandler (정적 리소스) HttpRequestHandlerAdapter
RouterFunctionMapping RouterFunction HandlerFunctionAdapter

이 테이블이 시사하는 건 하나다. 디스패처는 "핸들러가 메서드인지, 객체인지, 함수인지"를 몰라도 된다. 그 앎은 어댑터에 국한돼 있고, 디스패처는 supports + handle이라는 인터페이스 두 개만 믿는다. 새로운 형태의 핸들러 표현이 필요해도 어댑터 한 쌍만 추가하면 전체가 받아준다. 이게 Strategy + Adapter 조합의 값이다.


7) ModelAndView 분기: ViewResolver vs MessageConverter

전통적 Spring MVC와 REST API 사이의 분기가 이 지점에서 갈라진다. 같은 DispatcherServlet이지만 응답이 만들어지는 경로는 둘이다.

7-1) View 기반 경로

@Controller의 메서드가 String(뷰 이름)을 반환하거나 ModelAndView를 반환하면, HandlerAdapter.handleModelAndView를 디스패처에게 돌려준다. 디스패처는 processDispatchResult에서 ViewResolver들을 순서대로 돌려 뷰 이름을 실제 View 객체로 풀고, view.render(model, request, response)를 호출한다. 이 render 안에서 JSP forward, Thymeleaf 렌더링, JSON View 직렬화 등이 일어난다.

7-2) @ResponseBody 경로 (MessageConverter)

@RestController@ResponseBody가 붙은 경로는 반환값이 User, List<Order> 같은 도메인 객체다. 이 경우 RequestMappingHandlerAdapter 내부의 **RequestResponseBodyMethodProcessor**가 HttpMessageConverter 목록을 순회하면서, 요청의 Accept 헤더와 반환 타입에 맞는 컨버터를 골라 응답 바디에 바로 써버린다. 그리고 mavContainer.setRequestHandled(true)를 설정해서 "렌더링할 뷰 없음"을 표시한다. 디스패처 입장에서는 ModelAndViewnull이고, 응답은 이미 끝난 상태다.

항목 View 경로 @ResponseBody 경로
반환 타입 String, ModelAndView, void 도메인 객체, ResponseEntity<T>
응답 작성 주체 ViewResolverView.render HttpMessageConverter
ModelAndView non-null null
Content Negotiation ContentNegotiatingViewResolver Accept 헤더 + MediaType 매칭
주 시나리오 서버 렌더 HTML REST API, JSON/XML

Boot에서 Jackson 하나 넣으면 MappingJackson2HttpMessageConverter가 자동으로 컨버터 목록에 끼어들어, @RestController가 바로 JSON을 내뱉게 되는 게 이 흐름이다. 별도 설정 없이 된다고 신기할 것이 아니라, 자동구성이 컨버터 빈을 자동 등록하기 때문이다.


8) HandlerExceptionResolver: 예외도 결국 응답이다

핸들러 호출이나 인터셉터에서 예외가 터지면, 디스패처는 processDispatchResult에서 HandlerExceptionResolver 목록을 순회한다. 각 리졸버는 "내가 이 예외를 처리할 수 있다"고 판단하면 ModelAndView를 돌려준다. 예외 처리 결과도 결국은 ModelAndView 한 덩어리라는 설계가 여기서 보인다. 예외 응답도 결국 응답이므로, 성공 응답과 같은 렌더링 파이프라인을 탄다.

Resolver 처리 대상
ExceptionHandlerExceptionResolver @ExceptionHandler 메서드 (컨트롤러 내부 + @ControllerAdvice)
ResponseStatusExceptionResolver @ResponseStatus 붙은 예외
DefaultHandlerExceptionResolver 프레임워크 표준 예외 (HttpRequestMethodNotSupportedException 등)

@ControllerAdvice + @ExceptionHandler가 실무의 1번 카드인 이유가 이것이다. ExceptionHandlerExceptionResolver가 기본으로 제일 앞에 놓여 있고, @ControllerAdvice로 전역 예외 핸들러를 등록하면 모든 컨트롤러의 예외가 그쪽으로 모인다. 반환 타입은 ResponseEntity<ErrorResponse>로 두면 MessageConverter가 JSON으로 직렬화해준다. 즉 **"예외 → @ExceptionHandler 메서드 → ResponseEntityMessageConverter → JSON"**이 REST API의 표준 에러 경로다.

여기서 틀리기 쉬운 지점 — HandlerExceptionResolver가 처리할 수 있는 예외는 핸들러 실행 중 발생한 예외뿐이다. Filter에서 던진 예외, 서블릿 컨테이너가 잡은 500은 이 체인을 타지 않는다. Filter 단의 예외까지 포맷을 맞추고 싶으면 Boot의 ErrorController(기본 BasicErrorController)나 별도 ErrorWebExceptionHandler를 봐야 한다. 이 경계도 §5-2 Interceptor vs Filter 경계와 같은 맥락이다.


9) WebApplicationContext 계층: 루트/child, 그리고 Boot의 현실

FrameworkServlet이 만드는 WebApplicationContext는 전통적으로 부모-자식 두 레벨 구조를 가진다.

여기서 Boot 맥락과 전통 맥락을 반드시 구분해야 한다. Boot 앱을 쓰는 대부분의 독자에게는 이 계층이 사실상 하나로 합쳐져 있다. "루트 컨텍스트에 서비스, 디스패처 컨텍스트에 컨트롤러"라는 전통 구조를 Boot 앱 코드에서 찾으려 해봐야 없다.

9-1) 전통 구조 (web.xml / WebApplicationInitializer)

  • 루트 WebApplicationContextContextLoaderListener가 생성. 서비스 빈, 리포지토리, DataSource, Transaction 같은 공유 인프라.
  • 서블릿 WebApplicationContext — 각 DispatcherServlet이 생성. 해당 디스패처 전용 컨트롤러, 뷰 리졸버, 핸들러 매핑.
  • 부모-자식 관계 — 서블릿 컨텍스트가 루트를 부모로 본다. 자식이 부모 빈은 쓸 수 있지만 반대는 안 된다. 디스패처를 여러 개 띄우면 각 서블릿 컨텍스트는 형제 관계이고, 루트만 공유한다.

이 모델이 값을 발휘하는 경우는 한 앱에 디스패처 서블릿을 여러 개 띄울 때다. 예컨대 "공개 API용 디스패처"와 "관리자용 디스패처"를 각각 만들어 서로 다른 컨트롤러 빈 집합을 쓰면서도 서비스 계층은 공유하는 구조.

9-2) Boot의 단순화

Boot는 SpringApplication이 만드는 **단일 ApplicationContext**가 루트 컨텍스트 역할을 겸한다. DispatcherServletAutoConfiguration이 디스패처를 ServletRegistrationBean으로 등록할 때, 별도 자식 컨텍스트를 만들지 않고 루트(=메인) 컨텍스트를 그대로 서블릿 컨텍스트로 주입한다. 그래서 Boot 앱의 컨트롤러, 서비스, 리포지토리는 전부 같은 컨텍스트에 산다.

이 말의 함의는 크다. Boot 환경에서 "루트 빈은 이거고, 디스패처 빈은 이거고"라고 나누는 설계 문서를 봐도 실제 런타임에는 그 구분이 없다. 디버깅할 때 ApplicationContext.getBean으로 어떤 빈이든 찾을 수 있다는 뜻이기도 하고, 반대로 전통 구조에서 "부모 컨텍스트에서 자식 컨텍스트의 빈 찾기"로 고생하던 문제가 Boot에는 애초에 없다는 뜻이기도 하다.

Boot에서 진짜로 디스패처를 여러 개 띄우고 싶으면 ServletRegistrationBean을 직접 정의해 서로 다른 URL 매핑을 가진 디스패처를 등록할 수는 있지만, 기본 설계는 단일 디스패처 + 단일 컨텍스트다. 실무에서는 이 기본을 벗어날 일이 거의 없다.


10) Spring Boot 3.x 자동구성과 @EnableWebMvc의 스위치

Boot 3.x가 클래스패스에 spring-boot-starter-web이 있으면 아래 자동구성들이 켜진다.

  • DispatcherServletAutoConfigurationDispatcherServlet을 빈으로 등록하고 ServletRegistrationBean으로 / 매핑
  • WebMvcAutoConfigurationHandlerMapping, HandlerAdapter, MessageConverter, ViewResolver, 정적 리소스 핸들러, Validator, 콘텐트 네고, DefaultErrorAttributesSpring MVC 전체 구성을 자동 등록
  • HttpMessageConvertersAutoConfiguration — Jackson, Gson 등 클래스패스에 따라 컨버터 자동 등록
  • ErrorMvcAutoConfigurationBasicErrorController, DefaultErrorViewResolver

여기서 결정적 함정이 있다. @EnableWebMvc@Configuration 클래스에 붙이면 WebMvcAutoConfiguration의 대부분이 꺼진다. 이유는 단순하다. WebMvcAutoConfiguration@ConditionalOnMissingBean(WebMvcConfigurationSupport.class)라는 조건을 달고 있고, @EnableWebMvc는 내부적으로 DelegatingWebMvcConfiguration(= WebMvcConfigurationSupport 확장)을 임포트한다. 즉 @EnableWebMvc를 붙이는 순간 WebMvcConfigurationSupport 빈이 생기고, 그 조건 때문에 자동구성이 통째로 비활성화된다.

10-1) @EnableWebMvc를 붙이면 사라지는 것들

  • Boot가 기본으로 주던 MessageConverter 체인 (JSON 자동 매핑 깨짐 가능)
  • 정적 리소스 핸들러 (/static, /public 서빙)
  • 기본 ErrorMvcAutoConfiguration과의 조합
  • Validator, 콘텐트 네고, DefaultServletHandlerConfigurer의 기본 설정

사람들은 종종 "MVC 설정을 좀 커스텀하려고" @EnableWebMvc를 붙이는데, 그 순간 위 항목들을 전부 직접 재구성해야 한다. @EnableWebMvc는 "커스텀하고 싶어요"의 진입점이 아니라 **"Boot 자동구성을 전부 끄고 바닥부터 내가 다 하겠다"**의 스위치다. 이 두 표현은 전혀 다르다.

10-2) 대신 쓸 것: WebMvcConfigurer

Boot 앱에서 MVC 동작을 조정하고 싶으면 @EnableWebMvc 대신 WebMvcConfigurer 인터페이스를 구현한 @Configuration 빈을 등록하면 된다. 자동구성은 그대로 두고, WebMvcConfigurer의 훅 메서드들이 자동구성된 MVC 설정에 덧붙여서 반영된다.

@Configuration
public class MvcConfig implements WebMvcConfigurer {

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(new AuthInterceptor())
                .addPathPatterns("/api/**");
    }

    @Override
    public void configureMessageConverters(List<HttpMessageConverter<?>> converters) {
        // 덧붙이기. 지우는 게 아니다.
    }

    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/api/**").allowedOrigins("https://app.example.com");
    }
}

이 방식은 자동구성과 평화롭게 공존한다. 99%의 Boot 앱은 이걸로 충분하다. @EnableWebMvc가 필요한 경우는 Boot를 거부하고 직접 MVC를 구성하는 특수 상황 뿐이다.


11) Jakarta Servlet 6 이행 주의사항

Spring Framework 6 / Boot 3은 javax.servlet.*을 완전히 버리고 **jakarta.servlet.***으로 이행했다. Java EE가 Jakarta EE로 넘어가면서 패키지 네임스페이스가 변경된 결과다. 이행이 필요한 지점은 세 군데다.

첫째, import 전면 치환. HttpServletRequest, HttpServletResponse, Filter, ServletRegistration 같은 타입의 import를 전부 jakarta.servlet.*으로 바꾼다. IDE의 패키지 마이그레이션 도구나 OpenRewrite로 일괄 변경한다.

둘째, 서블릿 컨테이너 요구 버전 상승. Tomcat 10+, Jetty 11+, Undertow 2.3+가 필요하다. Tomcat 9와 10은 메이저 호환이 안 된다 — Tomcat 9는 javax, Tomcat 10은 jakarta이므로 배포 환경 업그레이드가 필수다.

셋째, 서드파티 라이브러리가 javax.servlet을 참조하는 경우. 오래된 서블릿 API 의존 라이브러리는 Jakarta 호환 버전으로 업그레이드하거나 대체해야 한다. 런타임에 NoClassDefFoundError: javax/servlet/...이 터지면 대부분 이 경우다.

이 세 가지를 통과하면 DispatcherServlet 자체의 동작은 5.x 시절과 거의 같다. 내부 구조와 전략 빈 개념은 Jakarta 전환의 영향을 받지 않는다.


12) 실무에서 이렇게 읽고 쓴다

  • "DispatcherServlet이 뭘 한다"를 설명할 때는 항상 Front Controller + Strategy. 이 두 패턴 이름만 기억해도 전략 빈 구조가 자연스럽게 따라 나온다. 구조를 잊었을 때는 §2-2 초기화 시퀀스§5-1 요청 처리 시퀀스 두 그림을 보면 된다.
  • DispatcherServlet.properties는 검색하지 말고 IDE로 열어라. spring-webmvc jar 안 org/springframework/web/servlet/에 있다. 기본값이 어디서 오는지는 이 파일 한 번 보는 게 문서 10페이지보다 빠르다.
  • @EnableWebMvc는 Boot 앱에서 붙이지 않는다. 붙는 순간 자동구성이 꺼진다. MVC 조정이 필요하면 WebMvcConfigurer 빈을 등록한다. 이 규칙을 팀 문서에 고정해두면 이상한 버그의 절반은 줄어든다.
  • 디버깅 포인트: 핸들러가 안 잡히면 ① HandlerMapping 빈 목록을 로그로 찍어 본다 ② RequestMappingHandlerMapping.getHandlerMethods() 결과를 본다 ③ 중복 매핑 충돌 여부를 확인한다. 디스패처 레벨의 로깅은 logging.level.org.springframework.web=DEBUG로 켠다.
  • 예외가 JSON으로 안 나오면 @ControllerAdvice@ResponseBody 또는 @RestControllerAdvice가 붙어 있는지 확인한다. @Controller로 두면 뷰 경로로 빠져 렌더링 대상을 찾다 실패한다.
  • Boot 앱의 컨텍스트 계층: 한 덩어리라고 생각해라. 루트/자식 구분은 전통 WAR 배포에서만 의미가 있다. 디스패처를 여러 개 띄워야 하는 특수 요구가 없다면, ApplicationContext 하나에 전부 산다고 가정해도 99% 맞다.
  • Interceptor vs Filter 선택: 인증, CORS, 전역 요청 로깅처럼 디스패처 진입 전에 끊거나 응답을 랩핑해야 하면 Filter. 핸들러가 결정된 뒤 동작해야 하거나 컨트롤러 HandlerMethod에 접근해야 하면 Interceptor. 이 경계가 헷갈리면 대부분 Filter가 정답이다.
  • 핸들러 타입을 바꾸고 싶을 때(예: 함수형 엔드포인트 추가): HandlerMapping + HandlerAdapter 짝을 추가하는 설계를 떠올린다. 기존 @RequestMapping을 건드릴 필요가 없다. Strategy 패턴이 주는 확장성이 여기서 체감된다.

13) 한 줄 정리

DispatcherServlet은 특별한 엔진이 아니라 Front Controller 패턴을 구현한 서블릿 하나다. 실제 라우팅·호출·뷰·예외 처리는 전부 전략 빈으로 뽑혀 있고, 명시 빈이 없으면 DispatcherServlet.properties가 폴백으로 채운다. Boot 환경에서는 @EnableWebMvc를 붙이는 순간 자동구성이 꺼지므로 WebMvcConfigurer로 덧붙이는 쪽이 맞다.


태그: Spring MVC, DispatcherServlet, Front Controller, HandlerMapping, HandlerAdapter, WebMvcConfigurer, Spring Boot 3, Jakarta Servlet 6

728x90