이 글에서 다루는 것
"Spring 소스 파헤치기" 시리즈의 스물한 번째 글입니다. 20편에서 웹 쪽의 바닥(spring-web 모듈)을 봤습니다. 이번 글은 1편의 지도에서 Phase 5(웹)의 두 번째 단위 U19, Spring MVC의 한가운데에 있는 클래스 DispatcherServlet 입니다. 브라우저가 보낸 요청은 모두 이 클래스 하나를 지나가고, 이 클래스가 "누가 이 요청을 처리할지"를 정해 일을 나눠 줍니다.
답하려는 질문은 네 가지입니다.
DispatcherServlet은 무엇을 물려받았고, 물려받은 층마다 무슨 일을 맡는가- 서블릿이 켜질 때 Spring 컨테이너는 언제 만들어지고, 요청 처리에 쓰는 부품들은 어디서 오는가
- 요청 하나가 들어오면
doDispatch()안에서 어떤 순서로 무엇이 불리는가 - 처리할 핸들러가 없거나 도중에 예외가 나면 그 순서가 어떻게 달라지는가
| 항목 | 내용 |
|---|---|
| 기준 소스 | Spring Framework 7.1.0-SNAPSHOT |
| 커밋 | f447f3c310 (2026-10-01, main 브랜치) |
| 주로 읽는 모듈 | spring-webmvc (org.springframework.web.servlet 패키지) |
| 주로 읽는 클래스 | HttpServletBean, FrameworkServlet, DispatcherServlet, HandlerExecutionChain |
글에 나오는 줄 번호와 세부 동작은 모두 이 커밋 기준입니다. 다른 버전에서는 줄 번호가 다르고 세부 내용도 달라질 수 있습니다. 같은 커밋의 소스를 받는 방법은 1편에 있습니다.
이 글은 큰 줄기만 봅니다. DispatcherServlet은 일을 직접 하지 않고 여러 부품에게 시키는 클래스라서, 부품 하나하나의 속까지 들어가면 글이 끝나지 않습니다. 여기서는 누가 언제 불리는지까지만 보고, 핸들러를 찾고 부르는 부품의 속은 22편, 예외 처리·인터셉터·뷰의 속은 23편으로 넘깁니다.
먼저 알아 둘 말
서블릿(Servlet)은 Java로 웹 요청을 처리하는 표준 규격입니다. Tomcat 같은 서블릿 컨테이너가 HTTP 요청을 받아 HttpServletRequest(요청)와 HttpServletResponse(응답) 객체를 만들고, 등록된 서블릿의 service() 메서드를 부릅니다. 서블릿을 처음 쓸 때는 init()을 한 번, 내릴 때는 destroy()를 한 번 부릅니다. 이 규격의 클래스들(jakarta.servlet 패키지)은 Spring의 것이 아니라서 이 저장소에는 소스가 없습니다.
프런트 컨트롤러(front controller)는 모든 요청을 한 곳에서 먼저 받아 알맞은 처리자에게 넘기는 구조입니다. URL마다 서블릿을 하나씩 만드는 대신 서블릿 하나가 전부 받습니다. DispatcherServlet이 그 하나입니다. dispatch는 "보내다, 배정하다"라는 뜻입니다.
핸들러(handler)는 요청을 실제로 처리하는 객체입니다. 우리가 쓰는 컨트롤러의 메서드가 대표적입니다. DispatcherServlet의 소스에서 핸들러의 타입은 그냥 Object입니다. 어떤 모양의 객체든 핸들러가 될 수 있게 하려는 것이고, 그래서 아래의 두 부품이 필요해집니다.
HandlerMapping 은 요청을 보고 "이 요청은 이 핸들러가 맡는다"를 알려 주는 부품이고, HandlerAdapter 는 그 핸들러를 실제로 부를 줄 아는 부품입니다. 핸들러의 모양이 제각각이니 부르는 방법을 아는 쪽을 따로 둔 것입니다. 두 부품의 속은 22편의 주제입니다.
인터셉터(HandlerInterceptor)는 핸들러의 앞뒤에 끼어드는 객체입니다. 메서드가 셋입니다. 핸들러 전에 불리는 preHandle, 핸들러 뒤에 불리는 postHandle, 모든 것이 끝난 뒤에 불리는 afterCompletion입니다(HandlerInterceptor 102행, 129행, 154행).
전략 객체(strategy)는 "이 일은 이 객체에게 맡긴다"고 갈아 끼울 수 있게 떼어 놓은 부품을 말합니다. 위의 HandlerMapping과 HandlerAdapter가 전략 객체이고, DispatcherServlet의 소스 주석도 이들을 strategy라고 부릅니다(438행).
WebApplicationContext 는 웹 환경용 Spring 컨테이너입니다. 컨테이너와 refresh()는 7편, 웹용 컨텍스트가 무엇을 더 갖는지는 20편에 있습니다.
1. 네 층의 상속 — 층마다 맡은 일이 다릅니다
DispatcherServlet의 선언부터 위로 따라 올라가면 네 층이 나옵니다.
DispatcherServlet extends FrameworkServlet(157행)FrameworkServlet extends HttpServletBean(142행)HttpServletBean extends HttpServlet(82행)HttpServlet— 서블릿 규격의 클래스
위에서 아래로 물려받습니다. 회색은 Spring 바깥(서블릿 규격)의 클래스, 파란색이 이 글의 주인공입니다.
각 층은 위층이 비워 둔 메서드를 채우고, 아래층을 위한 빈 메서드를 다시 남깁니다. 표로 보면 이렇습니다.
| 층 | 켜질 때 하는 일 | 요청이 올 때 하는 일 | 아래층에 남긴 빈자리 |
|---|---|---|---|
HttpServlet |
init() 호출의 출발점 |
service()가 HTTP 메서드를 보고 doGet()·doPost() 등을 부른다 |
init(), doGet() 등 |
HttpServletBean |
init(): 서블릿 설정값(init-param)을 자기 프로퍼티에 넣는다 |
— | initServletBean() |
FrameworkServlet |
initServletBean(): 자기 WebApplicationContext를 마련한다 |
doGet() 등을 전부 processRequest()로 모은다 |
onRefresh(), doService() |
DispatcherServlet |
onRefresh(): 전략 객체를 모은다 |
doService() → doDispatch(): 핸들러를 찾아 부른다 |
— |
맨 위 HttpServlet의 동작은 서블릿 규격의 것이라 이 글에서 소스로 확인한 것은 아닙니다. 다만 FrameworkServlet의 doGet() 주석이 "HttpServlet의 기본 doHead 구현도 이 메서드를 부른다"고 적어 둔 것(880–885행)처럼, Spring 쪽 소스는 그 동작을 전제로 쓰여 있습니다.
위층이 흐름을 쥐고 아래층은 빈자리만 채우는 이 구조는 7편의 refresh()에서 본 템플릿 메서드 패턴입니다. FrameworkServlet은 initServletBean()과 processRequest()에 final을 붙여(518행, 983행) 아래층이 흐름 자체를 바꾸지 못하게 해 두었습니다.
헷갈리기 쉬운 곳
DispatcherServlet은 Spring 빈이기 전에 서블릿입니다. 만들고init()을 부르는 쪽은 Spring 컨테이너가 아니라 서블릿 컨테이너입니다. 그래서 순서가 뒤집혀 있습니다. 컨테이너가 서블릿을 만드는 것이 아니라, 서블릿이 켜지면서 자기가 쓸 Spring 컨테이너를 마련합니다(2절).doGet()과doPost()가 하는 일이 같습니다. 서블릿을 직접 짤 때는 두 메서드에 서로 다른 코드를 넣지만,FrameworkServlet은doGet,doPost,doPut,doDelete,doPatch를 전부processRequest(request, response)한 줄로 채웠습니다(886–936행). HTTP 메서드를 구분하는 일은 한참 뒤, 핸들러를 고르는 쪽이 합니다.
2. 켜질 때: 서블릿이 자기 컨텍스트를 마련합니다
서블릿 컨테이너가 init()을 부르면 세 층을 차례로 타고 내려옵니다.
init()에서 initStrategies()까지. 파란 상자가 "refresh가 끝났다는 소식"이 서블릿에게 돌아오는 길입니다.
HttpServletBean.init() — 설정값을 프로퍼티로
@Override
public final void init() throws ServletException {
// Set bean properties from init parameters.
PropertyValues pvs = new ServletConfigPropertyValues(getServletConfig(), this.requiredProperties);
// ...
// Let subclasses do whatever initialization they like.
initServletBean();
}
spring-webmvc/src/main/java/org/springframework/web/servlet/HttpServletBean.java 149–172행
줄인 자리에는 web.xml 등에 적은 서블릿 설정값(init-param)을 setXxx() 메서드로 넣어 주는 코드가 있습니다. contextConfigLocation이라는 설정값이 있으면 setContextConfigLocation()이 불리는 식입니다. 문자열을 프로퍼티에 넣어 주는 도구 BeanWrapper는 14편에서 봤습니다. 그러고 나서 아래층을 위한 빈자리 initServletBean()을 부릅니다. 이 메서드는 HttpServletBean에서는 비어 있습니다(192–193행).
FrameworkServlet.initWebApplicationContext() — 컨텍스트를 구하는 세 갈래
FrameworkServlet이 채운 initServletBean()은 로그를 남기는 것 말고는 initWebApplicationContext()를 부르고, 이어서 하위 클래스용 빈자리 initFrameworkServlet()(807–808행, 비어 있습니다)을 부르는 일이 전부입니다(518–545행). 본론은 여기입니다.
protected WebApplicationContext initWebApplicationContext() {
WebApplicationContext rootContext =
WebApplicationContextUtils.getWebApplicationContext(getServletContext());
WebApplicationContext wac = null;
if (this.webApplicationContext != null) {
// A context instance was injected at construction time -> use it
wac = this.webApplicationContext;
if (wac instanceof ConfigurableWebApplicationContext cwac && !cwac.isActive()) {
// ...
configureAndRefreshWebApplicationContext(cwac);
}
}
if (wac == null) {
// ...
wac = findWebApplicationContext();
}
if (wac == null) {
// ...
wac = createWebApplicationContext(rootContext);
}
if (!this.refreshEventReceived) {
// Either the context is not a ConfigurableApplicationContext with refresh
// support or the context injected at construction time had already been
// refreshed -> trigger initial onRefresh manually here.
synchronized (this.onRefreshMonitor) {
onRefresh(wac);
}
}
// ...
return wac;
}
spring-webmvc/src/main/java/org/springframework/web/servlet/FrameworkServlet.java 556–603행
위에서부터 읽습니다.
- 루트 컨텍스트를 찾아 둡니다.
rootContext는 서블릿보다 먼저 떠 있을 수 있는, 애플리케이션 전체가 함께 쓰는 컨텍스트입니다. 없으면null입니다. 이것을 누가 띄우는지는 20편에 있습니다. - 생성자로 받은 컨텍스트가 있으면 그것을 씁니다.
new DispatcherServlet(context)로 만든 경우입니다(DispatcherServlet366–369행). 아직 refresh 전이면 부모가 없을 때 루트 컨텍스트를 부모로 달고,configureAndRefreshWebApplicationContext()로 refresh까지 합니다. - 없으면 서블릿 컨텍스트 속성에서 찾아봅니다.
contextAttribute를 설정했을 때만 해당합니다(615–626행). - 그래도 없으면 새로 만듭니다.
createWebApplicationContext(rootContext)가 컨텍스트 클래스의 객체를 만들고 루트 컨텍스트를 부모로 단 뒤, 2번과 같은configureAndRefreshWebApplicationContext()를 부릅니다(643–663행). 따로 정하지 않았을 때의 컨텍스트 클래스는XmlWebApplicationContext입니다(155행). onRefresh(wac)를 직접 부릅니다 — 아직 불린 적이 없을 때만. 이 줄이 왜 있는지는 바로 아래에서 봅니다.
어느 갈래로 오든 서블릿은 자기 컨텍스트를 하나 갖게 되고, 루트 컨텍스트가 있으면 그것이 부모가 됩니다. 한 웹 애플리케이션에 DispatcherServlet을 여러 개 둘 수 있고 서블릿마다 자기 컨텍스트를 가지며, 루트 컨텍스트만 함께 쓴다고 클래스 주석이 적어 두었습니다(136–139행).
refresh가 끝났다는 소식은 이벤트로 돌아옵니다
configureAndRefreshWebApplicationContext()에서 눈여겨볼 줄은 둘입니다.
protected void configureAndRefreshWebApplicationContext(ConfigurableWebApplicationContext wac) {
// ...
wac.setServletContext(getServletContext());
wac.setServletConfig(getServletConfig());
wac.setNamespace(getNamespace());
wac.addApplicationListener(new SourceFilteringListener(wac, new ContextRefreshListener()));
// ...
postProcessWebApplicationContext(wac);
applyInitializers(wac);
wac.refresh();
}
spring-webmvc/src/main/java/org/springframework/web/servlet/FrameworkServlet.java 665–695행
마지막 줄 wac.refresh()가 7편에서 본 12단계를 돌립니다. 이때 컨트롤러를 비롯한 빈이 전부 만들어집니다. 그리고 그보다 앞의 addApplicationListener(...)가 이 컨텍스트에 리스너를 하나 달아 둡니다. refresh()의 마지막 단계는 ContextRefreshedEvent를 보내는데(AbstractApplicationContext 1017행, 이벤트가 전달되는 길은 12편), 방금 단 리스너 ContextRefreshListener가 그것을 받아 서블릿의 onApplicationEvent()로 넘깁니다(1179–1185행).
public void onApplicationEvent(ContextRefreshedEvent event) {
this.refreshEventReceived = true;
synchronized (this.onRefreshMonitor) {
onRefresh(event.getApplicationContext());
}
}
spring-webmvc/src/main/java/org/springframework/web/servlet/FrameworkServlet.java 830–835행
refreshEventReceived를 true로 바꾸고 onRefresh()를 부릅니다. 앞의 5번, initWebApplicationContext()의 if (!this.refreshEventReceived)는 이 깃발을 보는 것입니다. 이벤트를 받아 이미 onRefresh()가 불렸으면 건너뛰고, 이벤트가 오지 않았으면(건네받은 컨텍스트가 이미 refresh되어 있었던 경우 등) 직접 부릅니다.
왜 refresh() 바로 다음 줄에서 onRefresh()를 부르지 않고 이벤트를 거칠까요. 컨텍스트는 나중에 다시 refresh될 수 있기 때문입니다. FrameworkServlet에는 자기 컨텍스트를 다시 refresh하는 refresh() 메서드도 있습니다(816–822행). 컨텍스트가 다시 refresh되면 빈이 새로 만들어지므로, 서블릿이 들고 있던 부품도 새로 모아야 합니다. 이벤트에 걸어 두면 누가 언제 refresh하든 onRefresh()가 따라 불립니다.
리스너를 감싼 SourceFilteringListener는 이벤트의 출처가 이 컨텍스트일 때만 넘겨줍니다(SourceFilteringListener 71–75행). 12편에서 봤듯이 이벤트는 부모 컨텍스트로도 전달되기 때문에, 출처를 가리지 않으면 다른 컨텍스트의 refresh에도 반응하게 됩니다.
헷갈리기 쉬운 곳: onRefresh가 둘입니다
- 7편의 9단계
AbstractApplicationContext.onRefresh()는 컨텍스트의 빈자리로, 싱글톤이 만들어지기 전에 불립니다. - 이 글의
FrameworkServlet.onRefresh(ApplicationContext)는 서블릿의 빈자리로, refresh가 다 끝난 뒤에 불립니다(844–846행).
이름만 같고 주인도 시점도 다릅니다. 뒤의 것은 빈이 다 만들어진 뒤에 불려야 합니다. 컨텍스트에서 빈을 찾아 모으는 일을 하기 때문입니다(3절).
예제 1: onRefresh가 불리는 두 길
두 갈래를 눈으로 확인해 봅니다. onRefresh()가 불릴 때 호출 스택에 onApplicationEvent가 있는지 찍는 DispatcherServlet 하위 클래스를 만들고, 한 번은 refresh하지 않은 컨텍스트를, 한 번은 이미 refresh한 컨텍스트를 건넵니다. 서블릿 컨테이너 없이 돌리려고 spring-test의 가짜 객체 MockServletConfig·MockServletContext를 썼습니다(spring-test는 26편에서 읽습니다). 아래는 ex/u19/ServletInitExample.java에서 import와 바깥 클래스 선언만 뺀 것입니다.
// 이 글을 위해 쓴 예제입니다 (Spring의 소스가 아닙니다)
/** onRefresh 가 불릴 때 누가 불렀는지 찍는 DispatcherServlet. */
static class TracingServlet extends DispatcherServlet {
TracingServlet(AnnotationConfigWebApplicationContext context) {
super(context);
}
@Override
protected void onRefresh(ApplicationContext context) {
boolean viaEvent = StackWalker.getInstance().walk(frames ->
frames.anyMatch(frame -> frame.getMethodName().equals("onApplicationEvent")));
System.out.println(" onRefresh called "
+ (viaEvent ? "via ContextRefreshedEvent" : "directly by initWebApplicationContext"));
super.onRefresh(context);
}
}
public static void main(String[] args) throws Exception {
MockServletContext servletContext = new MockServletContext();
System.out.println("[1] context not refreshed yet");
AnnotationConfigWebApplicationContext fresh = new AnnotationConfigWebApplicationContext();
TracingServlet first = new TracingServlet(fresh);
System.out.println(" before init: isActive=" + fresh.isActive()
+ ", getHandlerMappings()=" + first.getHandlerMappings());
first.init(new MockServletConfig(servletContext, "first"));
System.out.println(" after init: isActive=" + fresh.isActive()
+ ", handlerMappings=" + first.getHandlerMappings().size());
System.out.println(" context id=" + fresh.getId());
System.out.println(" ServletContext attribute=" + first.getServletContextAttributeName());
System.out.println("[2] context already refreshed");
AnnotationConfigWebApplicationContext refreshed = new AnnotationConfigWebApplicationContext();
refreshed.setServletContext(servletContext);
refreshed.refresh();
TracingServlet second = new TracingServlet(refreshed);
second.init(new MockServletConfig(servletContext, "second"));
System.out.println(" after init: handlerMappings=" + second.getHandlerMappings().size());
first.destroy();
second.destroy();
System.out.println("[3] after destroy: first context isActive=" + fresh.isActive()
+ ", second context isActive=" + refreshed.isActive());
refreshed.close();
}
출력은 이렇습니다.
[1] context not refreshed yet
before init: isActive=false, getHandlerMappings()=null
onRefresh called via ContextRefreshedEvent
after init: isActive=true, handlerMappings=3
context id=org.springframework.web.context.WebApplicationContext:/first
ServletContext attribute=org.springframework.web.servlet.FrameworkServlet.CONTEXT.first
[2] context already refreshed
onRefresh called directly by initWebApplicationContext
after init: handlerMappings=3
[3] after destroy: first context isActive=false, second context isActive=false
이 예제는 릴리스 버전인 Spring Framework 7.0.9로 실행해 위 결과를 확인했습니다(이 시리즈가 읽는 7.1.0-SNAPSHOT을 직접 빌드해 돌린 것은 아닙니다). 출력에서 확인되는 것은 이렇습니다.
- [1]에서는
init()이 컨텍스트를 refresh했고,onRefresh()는 이벤트를 받아 불렸습니다.init()전에는 컨텍스트가 꺼져 있고getHandlerMappings()도null입니다. - 컨텍스트의 id와 서블릿 컨텍스트 속성 이름에 서블릿 이름
first가 들어갑니다. id는configureAndRefreshWebApplicationContext()가 붙이고(666–677행), 속성은initWebApplicationContext()의 끝(위에서 줄인publishContext부분, 596–600행)이FrameworkServlet.CONTEXT.+ 서블릿 이름으로 올립니다(789–791행). - [2]에서는 이벤트가 오지 않아
initWebApplicationContext()가 직접 불렀습니다. 컨텍스트가 서블릿을 만나기 전에 이미 refresh를 마쳤으니 리스너를 달 기회가 없었습니다. destroy()는 두 컨텍스트를 모두 닫았습니다.destroy()는 컨텍스트가setApplicationContext()로 주입된 경우에만 닫지 않고 남겨 둡니다(852–860행). 생성자로 건넨 컨텍스트는webApplicationContextInjected가false인 채라서(289–291행, 505–510행) 닫힙니다.
3. 전략 객체 모으기 — initStrategies()
DispatcherServlet이 채운 onRefresh()는 initStrategies(context) 한 줄입니다(433–435행).
protected void initStrategies(ApplicationContext context) {
initMultipartResolver(context);
initLocaleResolver(context);
initHandlerMappings(context);
initHandlerAdapters(context);
initHandlerExceptionResolvers(context);
initRequestToViewNameTranslator(context);
initViewResolvers(context);
initFlashMapManager(context);
}
spring-webmvc/src/main/java/org/springframework/web/servlet/DispatcherServlet.java 441–450행
여덟 줄이 여덟 가지 부품을 하나씩 마련해 서블릿의 필드에 넣습니다(279–301행).
| 부품 | 하는 일 | 개수 | 찾는 방법 | 자세한 글 |
|---|---|---|---|---|
MultipartResolver |
파일 업로드 요청을 풀어 준다 | 하나 | 이름 multipartResolver |
— |
LocaleResolver |
이 요청의 언어·지역을 정한다 | 하나 | 이름 localeResolver |
— |
HandlerMapping |
요청 → 핸들러 | 여럿 | 타입 | 22편 |
HandlerAdapter |
핸들러를 부른다 | 여럿 | 타입 | 22편 |
HandlerExceptionResolver |
예외 → 응답 | 여럿 | 타입 | 23편 |
RequestToViewNameTranslator |
뷰 이름이 없을 때 요청에서 이름을 지어 낸다 | 하나 | 이름 viewNameTranslator |
— |
ViewResolver |
뷰 이름 → 뷰 객체 | 여럿 | 타입 | 23편 |
FlashMapManager |
리다이렉트 너머로 값을 잠깐 넘긴다 | 하나 | 이름 flashMapManager |
— |
"이름"으로 찾는 넷은 context.getBean("이름", 타입)으로, "타입"으로 찾는 넷은 그 타입의 빈을 전부 모읍니다. 이름은 클래스 맨 위에 상수로 있습니다(160–201행). 여럿을 모으는 쪽의 모양은 넷이 같으니 HandlerMapping 하나만 봅니다.
private void initHandlerMappings(ApplicationContext context) {
this.handlerMappings = null;
if (this.detectAllHandlerMappings) {
// Find all HandlerMappings in the ApplicationContext, including ancestor contexts.
Map<String, HandlerMapping> matchingBeans =
BeanFactoryUtils.beansOfTypeIncludingAncestors(context, HandlerMapping.class, true, false);
if (!matchingBeans.isEmpty()) {
this.handlerMappings = new ArrayList<>(matchingBeans.values());
// We keep HandlerMappings in sorted order.
AnnotationAwareOrderComparator.sort(this.handlerMappings);
}
}
// ...
// Ensure we have at least one HandlerMapping, by registering
// a default HandlerMapping if no other mappings are found.
if (this.handlerMappings == null) {
this.handlerMappings = getDefaultStrategies(context, HandlerMapping.class);
// ...
}
// ...
}
spring-webmvc/src/main/java/org/springframework/web/servlet/DispatcherServlet.java 506–545행
beansOfTypeIncludingAncestors(...)로 이 컨텍스트와 부모 컨텍스트들에서HandlerMapping타입의 빈을 전부 찾습니다. 루트 컨텍스트에 둔 것도 잡힙니다.- 찾았으면 리스트에 담고 순서대로 정렬합니다.
@Order나Ordered로 정한 순서입니다. 이 순서가 5절에서 "누구에게 먼저 묻느냐"가 됩니다. - 하나도 없으면
getDefaultStrategies()로 기본값을 가져옵니다.
빈이 없을 때의 기본값 — DispatcherServlet.properties
기본값은 코드가 아니라 파일에 적혀 있습니다. DispatcherServlet 클래스와 같은 패키지에 있는 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
spring-webmvc/src/main/resources/org/springframework/web/servlet/DispatcherServlet.properties 5–25행
왼쪽이 전략 인터페이스의 이름, 오른쪽이 구현 클래스 이름들입니다. getDefaultStrategies()는 이 파일을 한 번 읽어 static 필드에 담아 두고(767–778행), 인터페이스 이름으로 값을 찾아 쉼표로 자른 뒤, 클래스마다 createDefaultStrategy()를 불러 객체를 만듭니다(780–803행). createDefaultStrategy()는 컨텍스트의 빈 팩토리에게 createBean(clazz)를 시킵니다(819–821행). 4편에서 본 인스턴스화·주입·초기화를 다 거치게 하려는 것입니다. 다만 이렇게 만든 객체는 컨텍스트에 빈으로 등록되지는 않습니다(예제 2에서 확인합니다).
노란 상자는 값이 보관되는 곳입니다. MultipartResolver만 기본값이 없습니다.
예제 2: 기본 전략 목록, 그리고 빈 하나를 등록했을 때
빈을 하나도 등록하지 않은 컨텍스트와 HandlerMapping 빈을 하나 등록한 컨텍스트로 서블릿을 각각 켜고 getHandlerMappings()(734–736행)를 찍습니다. 전체 코드는 ex/u19/DefaultStrategiesExample.java이고, 아래는 설정 클래스 둘과 찍는 메서드입니다.
// 이 글을 위해 쓴 예제입니다 (Spring의 소스가 아닙니다)
@Configuration
static class EmptyConfig {
}
@Configuration
static class OneMappingConfig {
@Bean
BeanNameUrlHandlerMapping myMapping() {
return new BeanNameUrlHandlerMapping();
}
}
static void show(String title, Class<?> config) throws Exception {
AnnotationConfigWebApplicationContext context = new AnnotationConfigWebApplicationContext();
context.register(config);
DispatcherServlet servlet = new DispatcherServlet(context);
servlet.init(new MockServletConfig(new MockServletContext(), "dispatcher"));
System.out.println("== " + title);
System.out.println(" HandlerMapping beans in the context: "
+ context.getBeanNamesForType(HandlerMapping.class).length);
System.out.println(" HandlerMappings the servlet uses:");
for (HandlerMapping mapping : servlet.getHandlerMappings()) {
System.out.println(" - " + mapping.getClass().getSimpleName());
}
System.out.println(" MultipartResolver: " + servlet.getMultipartResolver());
servlet.destroy();
}
== no strategy beans
HandlerMapping beans in the context: 0
HandlerMappings the servlet uses:
- BeanNameUrlHandlerMapping
- RequestMappingHandlerMapping
- RouterFunctionMapping
MultipartResolver: null
== one HandlerMapping bean
HandlerMapping beans in the context: 1
HandlerMappings the servlet uses:
- BeanNameUrlHandlerMapping
MultipartResolver: null
이 예제도 Spring Framework 7.0.9로 실행했습니다(7.1.0-SNAPSHOT을 직접 빌드해 돌린 것은 아닙니다).
- 빈이 없으면 properties 파일의 세 개가 그 순서대로 들어옵니다. 컨텍스트의
HandlerMapping빈은 0개인데 서블릿은 셋을 쓰고 있습니다. 기본 전략 객체가 빈으로 등록되지 않는다는 것이 여기서 보입니다. - 빈을 하나 등록하면 기본값 셋이 통째로 빠집니다. 위 코드의
if (this.handlerMappings == null)은 "하나도 못 찾았을 때만"입니다. 직접 등록한 것에 기본값을 더해 주지 않습니다. MultipartResolver는null입니다.initMultipartResolver()는 빈이 없으면 기본값을 찾지 않고null로 둡니다(467–473행).
헷갈리기 쉬운 곳
- "하나 더했더니
@RequestMapping이 안 먹는다." 예제 2의 2번이 원인입니다.HandlerMapping이나HandlerAdapter빈을 직접 하나 등록하면@RequestMapping을 처리하는 기본 구현(RequestMappingHandlerMapping,RequestMappingHandlerAdapter)이 함께 빠집니다. 클래스 주석도 그런 경우에는 그 둘도 직접 등록하라고 굵은 글씨로 적어 두었습니다(128–134행). - 메서드 주석이 properties 파일과 다릅니다.
initHandlerMappings()의 주석은 기본값이BeanNameUrlHandlerMapping이라고 하고(503–504행),initHandlerExceptionResolvers()의 주석은 기본값이 없다고 하고(588–589행),initFlashMapManager()의 주석은DefaultFlashMapManager라고 합니다(692–693행). 실제로 쓰이는 값은 위 properties 파일의 것(매핑 셋, 예외 리졸버 셋,SessionFlashMapManager)입니다. 주석과 코드가 다르면 코드를 따라야 합니다. - 이 파일은 고쳐 쓰라고 있는 것이 아닙니다. 파일 머리에 "애플리케이션 개발자가 바꾸라고 둔 것이 아니다"라고 적혀 있습니다(1–3행). 바꾸고 싶으면 빈을 등록합니다. 실제 프로젝트에서는
@EnableWebMvc나 Spring Boot가 이 부품들을 빈으로 등록해 주는 것이 보통인데, 그 설정의 속은 이 글에서 다루지 않습니다(Spring Boot는 이 저장소와 별개의 프로젝트라서, Spring Boot 쪽은 소스로 확인한 내용이 아닙니다).
4. 요청이 올 때 ①: doDispatch 앞에서 일어나는 일
이제 요청입니다. 서블릿 컨테이너가 service()를 부르면 doDispatch()에 닿기 전에 두 층을 지납니다.
노란 상자는 "이번 요청에 관한 것을 어딘가에 올려 두는" 자리입니다. 올린 것은 반드시 걷습니다.
FrameworkServlet.processRequest() — 스레드 보관소를 세우고 걷습니다
FrameworkServlet.service()는 요청의 HTTP 메서드가 표준 여덟 가지(DELETE, HEAD, GET, OPTIONS, PATCH, POST, PUT, TRACE — 172–173행)에 들면 위층 HttpServlet.service()로 넘기고, 아니면 곧바로 processRequest()를 부릅니다(868–877행). 위층으로 넘긴 것도 1절에서 봤듯이 doGet() 등을 거쳐 결국 processRequest()로 옵니다.
protected final void processRequest(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
// ...
LocaleContext previousLocaleContext = LocaleContextHolder.getLocaleContext();
LocaleContext localeContext = buildLocaleContext(request);
RequestAttributes previousAttributes = RequestContextHolder.getRequestAttributes();
ServletRequestAttributes requestAttributes = buildRequestAttributes(request, response, previousAttributes);
// ...
initContextHolders(request, localeContext, requestAttributes);
try {
doService(request, response);
}
// ...
finally {
resetContextHolders(request, previousLocaleContext, previousAttributes);
if (requestAttributes != null) {
requestAttributes.requestCompleted();
}
logResult(request, response, failureCause, asyncManager);
publishRequestHandledEvent(request, response, startTime, failureCause);
}
}
spring-webmvc/src/main/java/org/springframework/web/servlet/FrameworkServlet.java 983–1020행
가운데 doService(request, response)가 아래층의 빈자리이고, 그 앞뒤가 이 층의 일입니다.
- 앞:
LocaleContextHolder와RequestContextHolder에 이번 요청의 것을 올립니다(initContextHolders, 1055–1064행). 둘 다ThreadLocal, 곧 스레드마다 따로 갖는 보관소입니다(LocaleContextHolder48–52행,RequestContextHolder50–54행). 서블릿은 요청 하나를 스레드 하나가 처리하므로, 여기에 올려 두면 그 요청을 처리하는 코드는 어디서든 인자로 넘겨받지 않고도 "지금 요청"과 "지금 로케일"을 꺼낼 수 있습니다. - 뒤(
finally): 두 보관소를 들어오기 전의 값으로 되돌립니다(resetContextHolders, 1066–1071행). 요청이 성공하든 예외가 나든 실행됩니다. 스레드는 다음 요청에 다시 쓰이기 때문에, 걷지 않으면 앞 요청의 정보가 다음 요청에 섞여 들어갑니다. 그다음 요청이 끝났음을 알리고(requestCompleted()),ServletRequestHandledEvent를 컨텍스트로 보냅니다(1127–1140행).
줄인 자리의 catch는 doService()가 던진 예외를 그대로, 또는 ServletException("Request processing failed: …")으로 감싸 다시 던집니다(1003–1010행). 예제 3에서 이 메시지를 보게 됩니다.
DispatcherServlet.doService() — 요청 속성에 올려 두는 것
protected void doService(HttpServletRequest request, HttpServletResponse response) throws Exception {
// ...
// Make framework objects available to handlers and view objects.
request.setAttribute(WEB_APPLICATION_CONTEXT_ATTRIBUTE, getWebApplicationContext());
request.setAttribute(LOCALE_RESOLVER_ATTRIBUTE, this.localeResolver);
// ...
try {
doDispatch(request, response);
}
finally {
// ...
}
}
spring-webmvc/src/main/java/org/springframework/web/servlet/DispatcherServlet.java 829–879행
doService()는 doDispatch()를 부르기 전에 요청 속성(request.setAttribute)에 몇 가지를 올립니다. 요청 속성은 요청 객체에 딸린 이름-값 보관소로, 그 요청을 받는 코드 어디서나 꺼낼 수 있습니다.
| 속성 이름(상수) | 올리는 것 | 위치 |
|---|---|---|
WEB_APPLICATION_CONTEXT_ATTRIBUTE |
이 서블릿의 컨텍스트 | 847행 |
LOCALE_RESOLVER_ATTRIBUTE |
LocaleResolver |
848행 |
INPUT_FLASH_MAP_ATTRIBUTE |
앞 요청이 넘겨 준 값(있을 때) | 853행 |
OUTPUT_FLASH_MAP_ATTRIBUTE |
다음 요청으로 넘길 값을 담을 빈 FlashMap |
855행 |
FLASH_MAP_MANAGER_ATTRIBUTE |
FlashMapManager |
856행 |
상수의 값은 DispatcherServlet 클래스 이름에 .CONTEXT 같은 꼬리를 붙인 문자열입니다(208–241행). 이렇게 올려 두는 이유는 주석대로 "프레임워크 객체를 핸들러와 뷰가 쓸 수 있게" 하려는 것입니다(846행). 핸들러나 뷰는 DispatcherServlet 객체를 모르지만 요청 객체는 받으므로, 요청에 실어 보내는 것입니다. 예를 들어 RequestContextUtils.findWebApplicationContext()는 이 속성부터 봅니다(RequestContextUtils 83–97행).
헷갈리기 쉬운 곳: 보관소가 두 종류입니다
- 스레드 보관소(
RequestContextHolder,LocaleContextHolder)는FrameworkServlet이 세우고 걷습니다. 요청 객체가 손에 없는 코드를 위한 것입니다. - 요청 속성은
DispatcherServlet이 올립니다. 요청 객체가 손에 있는 코드를 위한 것입니다.
스레드 보관소는 그 스레드에서만 보입니다. 핸들러가 다른 스레드로 일을 넘기면 거기서는 비어 있습니다(threadContextInheritable의 기본값이 false입니다 — 205행).
5. 요청이 올 때 ②: doDispatch() 한 번의 흐름
이 글의 중심입니다. doDispatch()는 70줄이고(935–1004행), 뼈대는 이렇습니다.
protected void doDispatch(HttpServletRequest request, HttpServletResponse response) throws Exception {
// ...
try {
ModelAndView mv = null;
Exception dispatchException = null;
try {
processedRequest = checkMultipart(request);
multipartRequestParsed = (processedRequest != request);
// Determine handler for the current request.
mappedHandler = getHandler(processedRequest);
if (mappedHandler == null) {
noHandlerFound(processedRequest, response);
return;
}
if (!mappedHandler.applyPreHandle(processedRequest, response)) {
return;
}
// Determine handler adapter and invoke the handler.
HandlerAdapter ha = getHandlerAdapter(mappedHandler.getHandler());
mv = ha.handle(processedRequest, response, mappedHandler.getHandler());
if (asyncManager.isConcurrentHandlingStarted()) {
return;
}
applyDefaultViewName(processedRequest, mv);
mappedHandler.applyPostHandle(processedRequest, response, mv);
}
catch (Exception ex) {
dispatchException = ex;
}
// ...
processDispatchResult(processedRequest, response, mappedHandler, mv, dispatchException);
}
catch (Exception ex) {
triggerAfterCompletion(processedRequest, response, mappedHandler, ex);
}
// ...
finally {
// ...
}
}
spring-webmvc/src/main/java/org/springframework/web/servlet/DispatcherServlet.java 935–1004행
파란 상자가 인터셉터가 불리는 세 자리, 빨간 상자가 핸들러에 닿지 못하고 끝나는 두 자리입니다.
정상적인 요청은 일곱 걸음입니다.
| 걸음 | 코드 | 하는 일 | 속을 다루는 글 |
|---|---|---|---|
| ① | checkMultipart(request) |
파일 업로드 요청이면 풀어 둔다 | — |
| ② | getHandler(processedRequest) |
핸들러와 인터셉터 묶음을 받는다 | 22편 |
| ③ | mappedHandler.applyPreHandle(...) |
인터셉터 preHandle |
23편 |
| ④ | getHandlerAdapter(...) 다음 ha.handle(...) |
핸들러를 부른다. ModelAndView가 돌아온다 |
22편 |
| ⑤ | mappedHandler.applyPostHandle(...) |
인터셉터 postHandle |
23편 |
| ⑥ | processDispatchResult(...) |
예외가 있으면 처리하고, 뷰가 있으면 그린다 | 23편 |
| ⑦ | mappedHandler.triggerAfterCompletion(...) |
인터셉터 afterCompletion (⑥의 끝에서 불린다) |
23편 |
ModelAndView 는 핸들러의 결과를 담는 그릇으로, "어떤 화면(뷰)을 어떤 데이터(모델)로 그릴지"를 뜻합니다. 핸들러가 응답을 직접 다 써 버렸으면 null이 돌아오고, 그러면 그릴 화면이 없습니다. JSON을 돌려주는 요즘의 컨트롤러가 대개 이 경우입니다.
②와 ④: 차례로 물어서 처음 답하는 쪽을 씁니다
protected @Nullable HandlerExecutionChain getHandler(HttpServletRequest request) throws Exception {
if (this.handlerMappings != null) {
for (HandlerMapping mapping : this.handlerMappings) {
HandlerExecutionChain handler = mapping.getHandler(request);
if (handler != null) {
return handler;
}
}
}
return null;
}
// ...
protected HandlerAdapter getHandlerAdapter(Object handler) throws ServletException {
if (this.handlerAdapters != null) {
for (HandlerAdapter adapter : this.handlerAdapters) {
if (adapter.supports(handler)) {
return adapter;
}
}
}
throw new ServletException("No adapter for handler [" + handler +
"]: The DispatcherServlet configuration needs to include a HandlerAdapter that supports this handler");
}
spring-webmvc/src/main/java/org/springframework/web/servlet/DispatcherServlet.java 1154–1195행
두 메서드의 모양이 같습니다. 3절에서 모아 둔 리스트를 앞에서부터 돌면서 처음으로 답하는 것을 씁니다. 3절에서 리스트를 정렬해 둔 이유가 이것입니다.
getHandler()는HandlerMapping마다 "이 요청을 맡을 핸들러가 있습니까"(mapping.getHandler(request)—HandlerMapping187행)를 묻고,null이 아닌 첫 답을 돌려줍니다.getHandlerAdapter()는HandlerAdapter마다 "이 핸들러를 부를 줄 압니까"(adapter.supports(handler)—HandlerAdapter62행)를 묻고, 처음으로true라고 한 것을 돌려줍니다. 끝까지 없으면ServletException입니다.
getHandler()가 돌려주는 것은 핸들러 자체가 아니라 HandlerExecutionChain 입니다. 핸들러 하나와 그 요청에 적용할 인터셉터 목록을 묶은 객체입니다(HandlerExecutionChain 44–48행). 그래서 doDispatch()는 인터셉터를 직접 다루지 않고 이 묶음에게 applyPreHandle, applyPostHandle, triggerAfterCompletion을 시킵니다.
③⑤⑦: 인터셉터의 순서
boolean applyPreHandle(HttpServletRequest request, HttpServletResponse response) throws Exception {
for (int i = 0; i < this.interceptorList.size(); i++) {
HandlerInterceptor interceptor = this.interceptorList.get(i);
if (!interceptor.preHandle(request, response, this.handler)) {
triggerAfterCompletion(request, response, null);
return false;
}
this.interceptorIndex = i;
}
return true;
}
// ...
void triggerAfterCompletion(HttpServletRequest request, HttpServletResponse response, @Nullable Exception ex) {
for (int i = this.interceptorIndex; i >= 0; i--) {
HandlerInterceptor interceptor = this.interceptorList.get(i);
try {
interceptor.afterCompletion(request, response, this.handler, ex);
}
catch (Throwable ex2) {
logger.error("HandlerInterceptor.afterCompletion threw exception in interceptor [" + interceptor + "]", ex2);
}
}
}
spring-webmvc/src/main/java/org/springframework/web/servlet/HandlerExecutionChain.java 142–181행
preHandle은 앞에서부터(i = 0부터) 부릅니다. 하나가true를 돌려줄 때마다interceptorIndex에 그 번호를 적어 둡니다.postHandle은 뒤에서부터 부릅니다(157–164행).afterCompletion도 뒤에서부터, 그런데interceptorIndex부터 시작합니다.preHandle을 통과한 인터셉터만 불린다는 뜻입니다.
preHandle이 false를 돌려주면 그 자리에서 triggerAfterCompletion을 부르고 false를 돌려주며, doDispatch()는 그것을 보고 바로 return합니다(957–959행). 핸들러는 불리지 않습니다. 주석대로 "이 인터셉터가 응답을 이미 스스로 처리했다고 본다"는 것입니다(138–140행). 로그인하지 않은 요청을 인터셉터가 막는 것이 이 길입니다.
preHandle은 앞에서부터, 나머지 둘은 뒤에서부터입니다. afterCompletion은 preHandle을 통과한 인터셉터만 받습니다. 순서는 아래 예제 3의 실제 출력에서 가져왔습니다.
⑥: processDispatchResult() — 예외와 뷰가 만나는 곳
doDispatch()의 안쪽 try는 핸들러를 찾고 부르는 동안 난 예외를 던지지 않고 변수에 담아 둡니다(dispatchException = ex, 972–974행). 그리고 정상 결과(mv)와 예외를 함께 processDispatchResult()에 넘깁니다.
private void processDispatchResult(HttpServletRequest request, HttpServletResponse response,
@Nullable HandlerExecutionChain mappedHandler, @Nullable ModelAndView mv,
@Nullable Exception exception) throws Exception {
// ...
if (exception != null) {
if (exception instanceof ModelAndViewDefiningException mavDefiningException) {
logger.debug("ModelAndViewDefiningException encountered", exception);
mv = mavDefiningException.getModelAndView();
}
else {
Object handler = (mappedHandler != null ? mappedHandler.getHandler() : null);
mv = processHandlerException(request, response, handler, exception);
errorView = (mv != null);
}
}
// Did the handler return a view to render?
if (mv != null && !mv.wasCleared()) {
render(mv, request, response);
// ...
}
// ...
if (mappedHandler != null) {
// Exception (if any) is already handled..
mappedHandler.triggerAfterCompletion(request, response, null);
}
}
spring-webmvc/src/main/java/org/springframework/web/servlet/DispatcherServlet.java 1022–1062행
- 예외가 있으면
processHandlerException()이 3절에서 모아 둔HandlerExceptionResolver들에게 차례로 묻습니다(1225–1232행). 누군가 답하면 그 답이mv가 되고, 아무도 답하지 않으면 예외를 다시 던집니다(1255행). - 그릴 것이 있으면
render()가 뷰 이름을ViewResolver들에게 물어 뷰 객체로 바꾸고 그 뷰에게 그리게 합니다(1267–1313행). - 마지막에
afterCompletion을 부릅니다. 예외는 이미 처리됐으므로 인자는null입니다.
1번에서 다시 던져진 예외는 doDispatch()의 바깥 catch가 받아 triggerAfterCompletion(…, ex)를 부릅니다. 이번에는 예외를 인자로 넘기고, 끝에서 그 예외를 또 던집니다(1357–1364행). 어느 길로 가든 preHandle을 통과한 인터셉터의 afterCompletion은 불립니다.
예외가 리졸버에서 처리되면 정상 길과 합쳐지고, 처리되지 않으면 afterCompletion에 예외를 실어 밖으로 나갑니다. 회색 칸은 아래 예제 3의 실제 결과입니다.
핸들러가 없을 때 — noHandlerFound()
②에서 아무 HandlerMapping도 답하지 않으면 mappedHandler가 null입니다.
protected void noHandlerFound(HttpServletRequest request, HttpServletResponse response) throws Exception {
if (pageNotFoundLogger.isWarnEnabled()) {
pageNotFoundLogger.warn("No mapping for " + request.getMethod() + " " + getRequestUri(request));
}
throw new NoHandlerFoundException(request.getMethod(), getRequestUri(request),
new ServletServerHttpRequest(request).getHeaders());
}
spring-webmvc/src/main/java/org/springframework/web/servlet/DispatcherServlet.java 1172–1178행
경고 로그를 남기고 NoHandlerFoundException을 던집니다. 이 예외도 안쪽 try 안에서 났으므로 dispatchException에 담겨 processDispatchResult()로 갑니다. 곧 "핸들러 없음"도 다른 예외와 똑같이 HandlerExceptionResolver가 처리합니다. NoHandlerFoundException은 자기 상태 코드가 404라고 알고 있고(NoHandlerFoundException 65행), 기본 리졸버 가운데 DefaultHandlerExceptionResolver가 그 코드로 response.sendError()를 부릅니다(221–232행, 556–577행). 이때는 mappedHandler가 없으니 인터셉터는 하나도 불리지 않습니다.
비동기 요청일 때 일찍 빠지는 자리
④ 바로 다음에 이 줄이 있습니다.
if (asyncManager.isConcurrentHandlingStarted()) {
return;
}
spring-webmvc/src/main/java/org/springframework/web/servlet/DispatcherServlet.java 965–967행
핸들러가 "결과는 다른 스레드에서 나중에 준다"고 하고 돌아온 경우입니다(isConcurrentHandlingStarted() — WebAsyncManager 145–147행). 아직 결과가 없으니 postHandle도 뷰 그리기도 afterCompletion도 하지 않고 빠집니다. 대신 finally에서 applyAfterConcurrentHandlingStarted()를 부르는데, 주석이 "postHandle과 afterCompletion 대신"이라고 적어 두었습니다(990–996행). 결과가 준비되면 요청이 DispatcherServlet으로 한 번 더 들어와 나머지를 마칩니다. 그 두 번째 길은 이 글에서 다루지 않습니다.
예제 3: 인터셉터에 로그를 넣어 순서 보기
인터셉터 셋(A, B, C)을 건 BeanNameUrlHandlerMapping과 핸들러 셋을 등록하고, 네 가지 요청을 servlet.service()로 직접 넣어 봅니다. 핸들러는 가장 단순한 모양인 HttpRequestHandler(요청과 응답을 받는 메서드 하나)로 썼습니다. BeanNameUrlHandlerMapping은 빈 이름이 /로 시작하면 그것을 URL로 삼습니다(BeanNameUrlHandlerMapping 54–66행). 인터셉터 B는 /blocked 요청에서만 preHandle이 false를 돌려줍니다. 전체 코드는 ex/u19/DispatchOrderExample.java이고, 아래는 설정 클래스와 main입니다(세 메서드에서 이름을 찍기만 하는 LogInterceptor는 뺐습니다).
// 이 글을 위해 쓴 예제입니다 (Spring의 소스가 아닙니다)
@Configuration
static class Config {
// 빈 이름이 "/" 로 시작하면 BeanNameUrlHandlerMapping 이 그 이름을 URL 로 본다
@Bean("/hello")
HttpRequestHandler hello() {
return (request, response) -> {
Object context = request.getAttribute(DispatcherServlet.WEB_APPLICATION_CONTEXT_ATTRIBUTE);
System.out.println(" handler /hello runs");
System.out.println(" context in request attribute: " + (context != null));
System.out.println(" RequestContextHolder is set: "
+ (RequestContextHolder.getRequestAttributes() != null));
response.getWriter().write("hello");
};
}
@Bean("/boom")
HttpRequestHandler boom() {
return (request, response) -> {
System.out.println(" handler /boom runs and throws");
throw new IllegalStateException("boom");
};
}
@Bean("/blocked")
HttpRequestHandler blocked() {
return (request, response) -> System.out.println(" handler /blocked runs (must not appear)");
}
@Bean
BeanNameUrlHandlerMapping handlerMapping() {
BeanNameUrlHandlerMapping mapping = new BeanNameUrlHandlerMapping();
mapping.setInterceptors(new LogInterceptor("A", false), new LogInterceptor("B", true),
new LogInterceptor("C", false));
return mapping;
}
@Bean
ApplicationListener<ServletRequestHandledEvent> handledListener() {
return event -> System.out.println(" [event] ServletRequestHandledEvent url=" + event.getRequestUrl()
+ " status=" + event.getStatusCode() + " failed=" + event.wasFailure());
}
}
public static void main(String[] args) throws Exception {
AnnotationConfigWebApplicationContext context = new AnnotationConfigWebApplicationContext();
context.register(Config.class);
DispatcherServlet servlet = new DispatcherServlet(context);
servlet.init(new MockServletConfig(new MockServletContext(), "dispatcher"));
for (String path : new String[] {"/hello", "/blocked", "/boom", "/nothing"}) {
System.out.println("GET " + path);
MockHttpServletRequest request = new MockHttpServletRequest("GET", path);
MockHttpServletResponse response = new MockHttpServletResponse();
try {
servlet.service(request, response);
System.out.println(" => status=" + response.getStatus() + ", body=\"" + response.getContentAsString()
+ "\", errorMessage=" + response.getErrorMessage());
}
catch (ServletException ex) {
System.out.println(" => exception out of the servlet: " + ex.getMessage());
}
System.out.println(" RequestContextHolder after service: " + RequestContextHolder.getRequestAttributes());
}
servlet.destroy();
}
GET /hello
preHandle A -> true
preHandle B -> true
preHandle C -> true
handler /hello runs
context in request attribute: true
RequestContextHolder is set: true
postHandle C (ModelAndView=null)
postHandle B (ModelAndView=null)
postHandle A (ModelAndView=null)
afterCompletion C (ex=null)
afterCompletion B (ex=null)
afterCompletion A (ex=null)
[event] ServletRequestHandledEvent url=/hello status=200 failed=false
=> status=200, body="hello", errorMessage=null
RequestContextHolder after service: null
GET /blocked
preHandle A -> true
preHandle B -> false
afterCompletion A (ex=null)
[event] ServletRequestHandledEvent url=/blocked status=200 failed=false
=> status=200, body="", errorMessage=null
RequestContextHolder after service: null
GET /boom
preHandle A -> true
preHandle B -> true
preHandle C -> true
handler /boom runs and throws
afterCompletion C (ex=java.lang.IllegalStateException: boom)
afterCompletion B (ex=java.lang.IllegalStateException: boom)
afterCompletion A (ex=java.lang.IllegalStateException: boom)
[event] ServletRequestHandledEvent url=/boom status=200 failed=true
=> exception out of the servlet: Request processing failed: java.lang.IllegalStateException: boom
RequestContextHolder after service: null
GET /nothing
[main] WARN org.springframework.web.servlet.PageNotFound - COMMONS-LOGGING No mapping for GET /nothing
[main] WARN org.springframework.web.servlet.PageNotFound - COMMONS-LOGGING No endpoint GET /nothing.
[event] ServletRequestHandledEvent url=/nothing status=404 failed=false
=> status=404, body="", errorMessage=No endpoint GET /nothing.
RequestContextHolder after service: null
이 예제도 Spring Framework 7.0.9로 실행했습니다(7.1.0-SNAPSHOT을 직접 빌드해 돌린 것은 아닙니다). [main] WARN으로 시작하는 두 줄은 예제가 찍은 것이 아니라 Spring이 남긴 로그입니다. 이 두 줄은 표준 오류로, 나머지 줄은 표준 출력으로 나갑니다. 통로가 서로 다르므로 둘을 한 화면에 섞어 볼 때 두 줄이 끼어드는 자리는 보장되지 않습니다. 위는 실제로 돌려 얻은 배치이고, 환경에 따라 다른 자리에 나올 수 있습니다.
/hello— 정상.preHandle은 A→B→C,postHandle과afterCompletion은 C→B→A입니다. 핸들러 안에서는 요청 속성에 컨텍스트가 있고RequestContextHolder도 채워져 있습니다(4절).ModelAndView가null인 것은HttpRequestHandler용 어댑터가 응답을 핸들러가 직접 썼다고 보고null을 돌려주기 때문입니다(HttpRequestHandlerAdapter47–52행)./blocked— B가 막음. C의preHandle도 핸들러도postHandle도 불리지 않고,afterCompletion은 A만 받습니다.interceptorIndex가 0(A)에서 멈췄기 때문입니다. 응답 상태는 200입니다. 막은 인터셉터가 응답을 직접 써야 하는데 이 예제의 B는 아무것도 쓰지 않았습니다./boom— 핸들러가 예외를 던짐.postHandle은 건너뛰고afterCompletion은 셋 다 예외를 인자로 받습니다. 기본 리졸버 셋 가운데IllegalStateException을 맡는 것이 없어 예외가 다시 던져졌고,processRequest()가 그것을ServletException으로 감싸 서블릿 밖으로 내보냈습니다. 이벤트의failed=true가 그것입니다. 실제 서버에서는 서블릿 컨테이너가 이 예외를 받아 오류 응답을 만듭니다./nothing— 핸들러 없음. 인터셉터가 하나도 불리지 않고 404가 됩니다. 예외가 밖으로 나오지 않은 것은DefaultHandlerExceptionResolver가 처리했기 때문입니다.service()가 끝나면RequestContextHolder는 매번null로 돌아옵니다. 예외가 난/boom에서도 그렇습니다.processRequest()의finally입니다.
헷갈리기 쉬운 곳
postHandle은 예외가 나면 불리지 않습니다.applyPostHandle은 안쪽try의 마지막 줄이라 그 앞에서 예외가 나면 건너뜁니다. "무슨 일이 있어도 정리해야 하는 것"은afterCompletion에 둡니다.afterCompletion의ex가null이라고 예외가 없었던 것은 아닙니다.HandlerExceptionResolver가 예외를 처리했으면afterCompletion은null을 받습니다(1058–1061행). 예제의/boom은 아무도 처리하지 못한 경우라서 예외가 넘어왔습니다.preHandle이false를 돌려준 인터셉터 자신은afterCompletion을 받지 못합니다.interceptorIndex는true를 돌려준 뒤에야 올라갑니다.- 이 커밋의
noHandlerFound()는 항상 예외를 던집니다. 404를 직접 보내는 갈래가 이 메서드에는 없습니다. 예외로 통일해 두었기 때문에 "핸들러 없음"도 다른 오류와 같은 길로 처리할 수 있습니다. PATCH를 다루는 자리는 버전에 따라 다릅니다. 이 커밋에는doPatch()가 있고 주석에@since 7.1이 붙어 있습니다(893–903행). 예제를 돌린 7.0.9의FrameworkServlet에는doPatch()가 없습니다(저장소의v7.0.9태그에서 확인했습니다). 어느 쪽이든processRequest()로 모이는 것은 같습니다.
이 글에서 뺀 것
이름과 위치만 적어 둡니다. 요청 로그를 남기는 logRequest()와 민감한 값을 가리는 enableLoggingRequestDetails(881–922행). include 요청일 때 요청 속성을 찍어 두었다가 되돌리는 부분(832–844행, 1371행부터). 경로를 미리 파싱해 두는 parseRequestPath(859–863행). OPTIONS·TRACE 요청을 넘길지 정하는 doOptions()·doTrace()(944–976행). 멀티파트 요청을 풀고 치우는 checkMultipart()·cleanupMultipart()(1089–1146행). 뷰 이름이 없을 때 이름을 지어 주는 applyDefaultViewName()(1009–1016행). LocaleResolver로 로케일을 정하는 buildLocaleContext()(1072–1080행). 비동기 처리 중에 스레드 보관소를 다시 세우는 RequestBindingInterceptor(1192–1210행). 컨텍스트를 refresh 전에 손보는 ApplicationContextInitializer와 applyInitializers()(740행부터). 그리고 HandlerMapping·HandlerAdapter의 속(22편), HandlerExceptionResolver·인터셉터 등록·ViewResolver의 속(23편)입니다.
정리
| 개념 | 한 줄 요약 |
|---|---|
| 상속 네 층 | HttpServlet → HttpServletBean(설정값) → FrameworkServlet(컨텍스트, 스레드 보관소) → DispatcherServlet(전략, 배정) |
init() |
init-param을 프로퍼티로 넣고 initServletBean() → initWebApplicationContext() |
| 서블릿의 컨텍스트 | 생성자로 받거나, 속성에서 찾거나, 새로 만든다. 루트 컨텍스트가 있으면 부모로 단다 |
onRefresh() |
컨텍스트의 ContextRefreshedEvent를 받아 불린다. 이벤트가 오지 않았으면 직접 부른다 |
initStrategies() |
부품 여덟 가지를 컨텍스트에서 찾아 필드에 둔다. 넷은 이름으로 하나, 넷은 타입으로 여럿 |
DispatcherServlet.properties |
빈이 하나도 없을 때의 기본 구현 목록. 하나라도 등록하면 그 종류의 기본값은 통째로 빠진다 |
processRequest() |
LocaleContextHolder·RequestContextHolder를 세우고 finally에서 되돌린다. 끝에 이벤트 |
doService() |
요청 속성에 컨텍스트, LocaleResolver, FlashMap을 올리고 doDispatch()를 부른다 |
doDispatch() |
getHandler → applyPreHandle → ha.handle → applyPostHandle → processDispatchResult → afterCompletion |
HandlerExecutionChain |
핸들러 하나 + 인터셉터 목록. preHandle은 앞에서부터, 나머지 둘은 뒤에서부터 |
afterCompletion |
preHandle을 통과한 인터셉터만, 예외가 나도 불린다 |
processDispatchResult() |
예외는 HandlerExceptionResolver에게, 뷰는 render()에게. 아무도 예외를 맡지 않으면 다시 던진다 |
| 핸들러 없음 | NoHandlerFoundException을 던져 다른 예외와 같은 길로 처리한다. 기본 결과는 404 |
| 비동기 | 핸들러가 비동기 처리를 시작했으면 postHandle·afterCompletion 없이 일찍 빠진다 |
다음에 볼 것
이번 글에서 DispatcherServlet은 계속 묻기만 했습니다. HandlerMapping에게 "누가 맡습니까", HandlerAdapter에게 "부를 줄 압니까". 다음 글은 그 질문을 받는 쪽입니다. @RequestMapping이 붙은 메서드들이 언제 어떻게 등록되어 요청과 짝지어지는지, 그리고 메서드의 인자(@RequestParam, @RequestBody 등)를 채우고 반환값을 응답으로 바꾸는 일을 RequestMappingHandlerAdapter가 어떻게 하는지를 봅니다.
- 다음 글: Spring 소스 파헤치기 22: 핸들러 매핑과 어댑터 — @RequestMapping은 어떻게 찾고 어떻게 부르나
- 이어서: 23편: 예외 처리, 인터셉터, 뷰 — 요청 처리의 앞뒤와 끝
- 앞 글: 20편: spring-web 기반 — HttpMessageConverter, WebApplicationContext, 필터
- 이 글이 기대는 앞 글: 7편: refresh() 12단계, 12편: 이벤트, Environment, Resource, 2편: 전체 지도 — 모듈과 패키지
- 시리즈의 처음: 1편: AI와 함께 소스를 읽는 방법과 분석 지도
- C 로 짠 웹 서버는 요청 하나를 어떤 단계로 처리하나: Apache httpd 소스 파헤치기 10: 요청 처리 단계 — URL에서 파일과 핸들러까지
- Tomcat 이 DispatcherServlet 의
service()를 부르기까지: Tomcat 소스 파헤치기 9: 필터 체인과 서블릿 호출 — service()가 불리기까지