Spring MVC DispatcherServlet — Front Controller 패턴, 초기화와 요청 디스패치
들어가며
톰캣이 HttpServletRequest를 들고 들어오면 누군가 받아서
컨트롤러까지 끌고 가야 한다. 스프링 웹 애플리케이션에서 그 "누군가"가
바로 DispatcherServlet이다. 그런데 이 이름이 주는 무게감
때문에 오해가 자주 생긴다. 사람들은 이걸 스프링의 거대한 웹 엔진이라고
상상한다. 실제로는 그냥 HttpServlet을 상속한 서블릿
한 개다. 서블릿 컨테이너 입장에서는 web.xml이나
ServletRegistrationBean으로 등록된 여러 서블릿 중 하나일
뿐이다. 특별한 점은 단 하나, 이 서블릿이 들어온 요청을 스프링 빈들에게
**위임(dispatch)**한다는 것이다.
그 위임 구조가 바로 GoF의 Front Controller 패턴이고, DispatcherServlet이 존재하는 유일한 이유다. 모든 요청이 이 하나를 통과하기 때문에 인증, 로깅, 예외 처리, 뷰 결정, 메시지 변환 같은 횡단 관심사를 여기 한 곳에서 일관되게 묶어낼 수 있다. 그런데 실무에서 이 메커니즘을 들여다보려 하면 거의 항상 다음 세 지점에서 막힌다.
- DispatcherServlet이 "서블릿 하나"라는 것을 놓친다 —
특별한 엔진이 아니다. 그래서
WebApplicationInitializer나ServletRegistrationBean으로 등록된 순간부터가 시작이고, 그 외의 마법은 없다 - 전략 빈(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 패턴: 왜 단일 진입점인가
- 2) 3계층 상속 구조: HttpServletBean → FrameworkServlet → DispatcherServlet
- 3) initStrategies: 9개 전략 빈의 조립
- 4) DispatcherServlet.properties: 전략 빈의 폴백 기본값
- 5) doDispatch: 요청 처리 루프의 실체
- 6) HandlerMapping → HandlerAdapter: 타입 중립 디스패치
- 7) ModelAndView 분기: ViewResolver vs MessageConverter
- 8) HandlerExceptionResolver: 예외도 결국 응답이다
- 9) WebApplicationContext 계층: 루트/child, 그리고 Boot의 현실
- 10) Spring Boot 3.x 자동구성과 @EnableWebMvc의 스위치
- 11) Jakarta Servlet 6 이행 주의사항
- 12) 실무에서 이렇게 읽고 쓴다
- 13) 한 줄 정리
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
DispatcherServlet은 HttpServlet을 직접
상속하지 않는다. 중간에 두 개의 추상 클래스가 끼어 있고 각 계층은
책임이 명확히 분리돼 있다. 이 분리를 모른 채 디스패처
초기화 과정을 이해하려 하면 코드가 왔다갔다하는 것처럼 보인다.
| 클래스 | 상속 | 책임 |
|---|---|---|
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) 초기화 흐름 시퀀스
이 그림에서 핵심은 "서블릿 스펙의 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은 요청이 올 때마다
doService → doDispatch를 호출한다.
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가 있고 뷰 렌더링이
필요하면 ViewResolver로 View를 만들어
render한다. @RestController처럼
@ResponseBody 계열이면 이 단계에서는 할 일이 거의 없다 —
MessageConverter가 이미 HandlerAdapter 안에서
응답 바디를 써버린 상태이기 때문이다.
5-1) 요청 처리 시퀀스
이 시퀀스의 분기점은 딱 두 개다. preHandle이
true냐, 그리고 ModelAndView가
null이냐. 이 두 불리언이 디스패처 동작의 거의
전부를 결정한다. 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.handle은 ModelAndView를
디스패처에게 돌려준다. 디스패처는 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)를 설정해서 "렌더링할
뷰 없음"을 표시한다. 디스패처 입장에서는 ModelAndView가
null이고, 응답은 이미 끝난 상태다.
| 항목 | View 경로 | @ResponseBody 경로 |
|---|---|---|
| 반환 타입 | String, ModelAndView,
void |
도메인 객체, ResponseEntity<T> |
| 응답 작성 주체 | ViewResolver → View.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 메서드 → ResponseEntity →
MessageConverter → 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)
- 루트
WebApplicationContext—ContextLoaderListener가 생성. 서비스 빈, 리포지토리, 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이 있으면
아래 자동구성들이 켜진다.
DispatcherServletAutoConfiguration—DispatcherServlet을 빈으로 등록하고ServletRegistrationBean으로/매핑WebMvcAutoConfiguration—HandlerMapping,HandlerAdapter,MessageConverter,ViewResolver, 정적 리소스 핸들러,Validator, 콘텐트 네고,DefaultErrorAttributes등 Spring MVC 전체 구성을 자동 등록HttpMessageConvertersAutoConfiguration— Jackson, Gson 등 클래스패스에 따라 컨버터 자동 등록ErrorMvcAutoConfiguration—BasicErrorController,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