namalzi

← 글 목록

Spring 소스 파헤치기 21: DispatcherServlet — 요청 하나가 응답이 되기까지

Spring · 게시

HTTP 요청 하나가 Spring MVC 안에서 응답이 되기까지를 DispatcherServlet의 소스로 따라갑니다. 서블릿이 켜질 때 자기 컨텍스트와 전략 객체를 마련하는 과정, 그리고 doDispatch가 핸들러를 찾아 부르고 인터셉터와 예외 처리를 끼워 넣는 순서를 Spring을 처음 보는 사람 기준으로 확인합니다.

이 글에서 다루는 것

"Spring 소스 파헤치기" 시리즈의 스물한 번째 글입니다. 20편에서 웹 쪽의 바닥(spring-web 모듈)을 봤습니다. 이번 글은 1편의 지도에서 Phase 5(웹)의 두 번째 단위 U19, Spring MVC의 한가운데에 있는 클래스 DispatcherServlet 입니다. 브라우저가 보낸 요청은 모두 이 클래스 하나를 지나가고, 이 클래스가 "누가 이 요청을 처리할지"를 정해 일을 나눠 줍니다.

답하려는 질문은 네 가지입니다.

항목 내용
기준 소스 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의 상속 사슬. 맨 위는 서블릿 API의 HttpServlet으로 service가 HTTP 메서드별로 doGet, doPost를 부른다. 그 아래 HttpServletBean은 init에서 서블릿 init-param을 자기 프로퍼티에 넣는다. FrameworkServlet은 initServletBean에서 자기 컨텍스트를 마련하고 processRequest에서 요청마다 스레드 보관소를 세우고 걷는다. 맨 아래 DispatcherServlet은 onRefresh에서 전략 객체를 모으고 doService와 doDispatch에서 요청을 핸들러에게 보낸다. 위에서 아래로 물려받습니다. 회색은 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행) 아래층이 흐름 자체를 바꾸지 못하게 해 두었습니다.

헷갈리기 쉬운 곳

2. 켜질 때: 서블릿이 자기 컨텍스트를 마련합니다

서블릿 컨테이너가 init()을 부르면 세 층을 차례로 타고 내려옵니다.

서블릿 초기화의 흐름. 서블릿 컨테이너가 init을 부르면 HttpServletBean.init이 init-param을 프로퍼티로 넣고 FrameworkServlet.initServletBean을 부른다. initWebApplicationContext는 컨텍스트가 이미 있는지 본다. 생성자로 받았고 아직 refresh 전이면 configureAndRefreshWebApplicationContext가 리스너를 달고 refresh를 부르고, 없으면 createWebApplicationContext로 새로 만든 뒤 같은 길로 간다. refresh의 마지막 단계에서 ContextRefreshedEvent가 나오고 그것을 받아 onRefresh가 불린다. 이미 refresh된 컨텍스트면 이벤트가 오지 않으므로 onRefresh를 직접 부른다. onRefresh는 DispatcherServlet.initStrategies로 전략 객체 여덟 가지를 모은다. 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행

위에서부터 읽습니다.

  1. 루트 컨텍스트를 찾아 둡니다. rootContext는 서블릿보다 먼저 떠 있을 수 있는, 애플리케이션 전체가 함께 쓰는 컨텍스트입니다. 없으면 null입니다. 이것을 누가 띄우는지는 20편에 있습니다.
  2. 생성자로 받은 컨텍스트가 있으면 그것을 씁니다. new DispatcherServlet(context)로 만든 경우입니다(DispatcherServlet 366–369행). 아직 refresh 전이면 부모가 없을 때 루트 컨텍스트를 부모로 달고, configureAndRefreshWebApplicationContext()로 refresh까지 합니다.
  3. 없으면 서블릿 컨텍스트 속성에서 찾아봅니다. contextAttribute를 설정했을 때만 해당합니다(615–626행).
  4. 그래도 없으면 새로 만듭니다. createWebApplicationContext(rootContext)가 컨텍스트 클래스의 객체를 만들고 루트 컨텍스트를 부모로 단 뒤, 2번과 같은 configureAndRefreshWebApplicationContext()를 부릅니다(643–663행). 따로 정하지 않았을 때의 컨텍스트 클래스는 XmlWebApplicationContext입니다(155행).
  5. 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가 둘입니다

이름만 같고 주인도 시점도 다릅니다. 뒤의 것은 빈이 다 만들어진 뒤에 불려야 합니다. 컨텍스트에서 빈을 찾아 모으는 일을 하기 때문입니다(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. [1]에서는 init()이 컨텍스트를 refresh했고, onRefresh()는 이벤트를 받아 불렸습니다. init() 전에는 컨텍스트가 꺼져 있고 getHandlerMappings()도 null입니다.
  2. 컨텍스트의 id와 서블릿 컨텍스트 속성 이름에 서블릿 이름 first가 들어갑니다. id는 configureAndRefreshWebApplicationContext()가 붙이고(666–677행), 속성은 initWebApplicationContext()의 끝(위에서 줄인 publishContext 부분, 596–600행)이 FrameworkServlet.CONTEXT. + 서블릿 이름으로 올립니다(789–791행).
  3. [2]에서는 이벤트가 오지 않아 initWebApplicationContext()가 직접 불렀습니다. 컨텍스트가 서블릿을 만나기 전에 이미 refresh를 마쳤으니 리스너를 달 기회가 없었습니다.
  4. 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행

  1. beansOfTypeIncludingAncestors(...)로 이 컨텍스트와 부모 컨텍스트들에서 HandlerMapping 타입의 빈을 전부 찾습니다. 루트 컨텍스트에 둔 것도 잡힙니다.
  2. 찾았으면 리스트에 담고 순서대로 정렬합니다. @Order나 Ordered로 정한 순서입니다. 이 순서가 5절에서 "누구에게 먼저 묻느냐"가 됩니다.
  3. 하나도 없으면 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인지 본다. 그렇다면 null로 두어 멀티파트 처리가 없고, 아니라면 DispatcherServlet.properties에서 인터페이스 이름에 해당하는 구현 클래스 이름들을 읽어 createBean으로 만든다. 이렇게 만든 객체는 빈으로 등록되지는 않는다. 어느 쪽이든 서블릿의 필드에 보관한다. 노란 상자는 값이 보관되는 곳입니다. 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을 직접 빌드해 돌린 것은 아닙니다).

  1. 빈이 없으면 properties 파일의 세 개가 그 순서대로 들어옵니다. 컨텍스트의 HandlerMapping 빈은 0개인데 서블릿은 셋을 쓰고 있습니다. 기본 전략 객체가 빈으로 등록되지 않는다는 것이 여기서 보입니다.
  2. 빈을 하나 등록하면 기본값 셋이 통째로 빠집니다. 위 코드의 if (this.handlerMappings == null)은 "하나도 못 찾았을 때만"입니다. 직접 등록한 것에 기본값을 더해 주지 않습니다.
  3. MultipartResolver는 null입니다. initMultipartResolver()는 빈이 없으면 기본값을 찾지 않고 null로 둡니다(467–473행).

헷갈리기 쉬운 곳

4. 요청이 올 때 ①: doDispatch 앞에서 일어나는 일

이제 요청입니다. 서블릿 컨테이너가 service()를 부르면 doDispatch()에 닿기 전에 두 층을 지납니다.

요청이 doDispatch에 닿기까지의 층. 요청이 도착하면 FrameworkServlet.service가 표준 HTTP 메서드인 경우 HttpServlet.service로 넘기고, doGet, doPost, doPut, doDelete, doPatch는 모두 processRequest 한 줄이다. processRequest는 LocaleContextHolder와 RequestContextHolder에 이번 요청을 올린다. DispatcherServlet.doService는 요청 속성에 컨텍스트, LocaleResolver, FlashMap을 올린다. doDispatch가 핸들러를 찾아 부른다. 끝나면 processRequest의 finally가 두 보관소를 이전 값으로 되돌리고 ServletRequestHandledEvent를 보낸다. 노란 상자는 "이번 요청에 관한 것을 어딘가에 올려 두는" 자리입니다. 올린 것은 반드시 걷습니다.

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)가 아래층의 빈자리이고, 그 앞뒤가 이 층의 일입니다.

줄인 자리의 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행).

헷갈리기 쉬운 곳: 보관소가 두 종류입니다

스레드 보관소는 그 스레드에서만 보입니다. 핸들러가 다른 스레드로 일을 넘기면 거기서는 비어 있습니다(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행

doDispatch의 흐름. checkMultipart 다음에 getHandler가 HandlerMapping에게 차례로 묻는다. 아무도 모르면 noHandlerFound가 NoHandlerFoundException을 던지고, 그 예외는 잡혀서 processDispatchResult로 간다. HandlerExecutionChain을 받으면 applyPreHandle이 인터셉터의 preHandle을 앞에서부터 부른다. false가 나오면 거기서 끝나고 통과한 인터셉터만 afterCompletion을 받는다. true면 getHandlerAdapter 다음에 ha.handle이 핸들러를 실행하고 ModelAndView를 돌려준다. 비동기 처리가 시작됐으면 postHandle 없이 일찍 빠진다. 아니면 applyPostHandle이 인터셉터의 postHandle을 뒤에서부터 부르고, processDispatchResult가 예외가 있으면 HandlerExceptionResolver에게, 뷰가 있으면 render에게 넘긴다. 핸들러가 던진 예외도 processDispatchResult로 간다. 마지막으로 triggerAfterCompletion이 인터셉터의 afterCompletion을 뒤에서부터 부른다. 파란 상자가 인터셉터가 불리는 세 자리, 빨간 상자가 핸들러에 닿지 못하고 끝나는 두 자리입니다.

정상적인 요청은 일곱 걸음입니다.

걸음 코드 하는 일 속을 다루는 글
① 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()가 돌려주는 것은 핸들러 자체가 아니라 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이 false를 돌려주면 그 자리에서 triggerAfterCompletion을 부르고 false를 돌려주며, doDispatch()는 그것을 보고 바로 return합니다(957–959행). 핸들러는 불리지 않습니다. 주석대로 "이 인터셉터가 응답을 이미 스스로 처리했다고 본다"는 것입니다(138–140행). 로그인하지 않은 요청을 인터셉터가 막는 것이 이 길입니다.

인터셉터 A, B, C 를 건 예제 3의 세 요청. GET /hello(정상): preHandle 이 A, B, C 순서로 모두 true 여서 interceptorIndex 가 2가 되고, 핸들러 /hello 가 돈 뒤 postHandle 이 C, B, A, afterCompletion 이 C, B, A 순서로 ex=null 을 받는다. GET /blocked(B 가 false): preHandle A 가 true 여서 interceptorIndex 가 0이고, preHandle B 가 false 를 돌려주어 C 와 핸들러와 postHandle 은 건너뛰며, afterCompletion 은 interceptorIndex 부터 거꾸로라 A 만 받는다. GET /boom(핸들러가 예외): preHandle 이 A, B, C 모두 true, 핸들러 /boom 이 IllegalStateException 을 던져 postHandle 은 건너뛰고, afterCompletion 이 C, B, A 순서로 ex=IllegalStateException: boom 을 받는다 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행

  1. 예외가 있으면 processHandlerException()이 3절에서 모아 둔 HandlerExceptionResolver들에게 차례로 묻습니다(1225–1232행). 누군가 답하면 그 답이 mv가 되고, 아무도 답하지 않으면 예외를 다시 던집니다(1255행).
  2. 그릴 것이 있으면 render()가 뷰 이름을 ViewResolver들에게 물어 뷰 객체로 바꾸고 그 뷰에게 그리게 합니다(1267–1313행).
  3. 마지막에 afterCompletion을 부릅니다. 예외는 이미 처리됐으므로 인자는 null입니다.

1번에서 다시 던져진 예외는 doDispatch()의 바깥 catch가 받아 triggerAfterCompletion(…, ex)를 부릅니다. 이번에는 예외를 인자로 넘기고, 끝에서 그 예외를 또 던집니다(1357–1364행). 어느 길로 가든 preHandle을 통과한 인터셉터의 afterCompletion은 불립니다.

doDispatch 의 안쪽 try 는 핸들러를 찾고 부르다 난 예외를 dispatchException 에 담고(972–974행), 핸들러가 없을 때의 NoHandlerFoundException 도 이리로 온다. processDispatchResult(mv, dispatchException) 는 예외가 없으면 mv 가 있을 때 render() 로 ViewResolver 를 거쳐 뷰를 그린다. 예외가 있으면 processHandlerException 이 HandlerExceptionResolver 들에게 차례로 묻고, 누군가 답하면 그 답이 mv 가 되어 같은 render 길로 간다. 그 뒤 triggerAfterCompletion(…, null) 을 부른다 — 예외는 이미 처리됐다. 예제 3의 /nothing 이 이 길로 DefaultHandlerExceptionResolver 가 처리해 404 가 됐다. 아무도 답하지 않으면 예외를 다시 던지고(1255행), 바깥 catch 가 triggerAfterCompletion(…, ex) 으로 afterCompletion 에 예외를 넘긴 뒤 또 던진다(1357–1364행). 예제 3의 /boom 이 IllegalStateException 을 맡는 리졸버가 없어 이 길로 서블릿 밖까지 예외가 나갔다 예외가 리졸버에서 처리되면 정상 길과 합쳐지고, 처리되지 않으면 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이 남긴 로그입니다. 이 두 줄은 표준 오류로, 나머지 줄은 표준 출력으로 나갑니다. 통로가 서로 다르므로 둘을 한 화면에 섞어 볼 때 두 줄이 끼어드는 자리는 보장되지 않습니다. 위는 실제로 돌려 얻은 배치이고, 환경에 따라 다른 자리에 나올 수 있습니다.

  1. /hello — 정상. preHandle은 A→B→C, postHandle과 afterCompletion은 C→B→A입니다. 핸들러 안에서는 요청 속성에 컨텍스트가 있고 RequestContextHolder도 채워져 있습니다(4절). ModelAndView가 null인 것은 HttpRequestHandler용 어댑터가 응답을 핸들러가 직접 썼다고 보고 null을 돌려주기 때문입니다(HttpRequestHandlerAdapter 47–52행).
  2. /blocked — B가 막음. C의 preHandle도 핸들러도 postHandle도 불리지 않고, afterCompletion은 A만 받습니다. interceptorIndex가 0(A)에서 멈췄기 때문입니다. 응답 상태는 200입니다. 막은 인터셉터가 응답을 직접 써야 하는데 이 예제의 B는 아무것도 쓰지 않았습니다.
  3. /boom — 핸들러가 예외를 던짐. postHandle은 건너뛰고 afterCompletion은 셋 다 예외를 인자로 받습니다. 기본 리졸버 셋 가운데 IllegalStateException을 맡는 것이 없어 예외가 다시 던져졌고, processRequest()가 그것을 ServletException으로 감싸 서블릿 밖으로 내보냈습니다. 이벤트의 failed=true가 그것입니다. 실제 서버에서는 서블릿 컨테이너가 이 예외를 받아 오류 응답을 만듭니다.
  4. /nothing — 핸들러 없음. 인터셉터가 하나도 불리지 않고 404가 됩니다. 예외가 밖으로 나오지 않은 것은 DefaultHandlerExceptionResolver가 처리했기 때문입니다.
  5. service()가 끝나면 RequestContextHolder는 매번 null로 돌아옵니다. 예외가 난 /boom에서도 그렇습니다. processRequest()의 finally입니다.

헷갈리기 쉬운 곳

이 글에서 뺀 것

이름과 위치만 적어 둡니다. 요청 로그를 남기는 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가 어떻게 하는지를 봅니다.

다른 글