namalzi

← 글 목록

Spring 소스 파헤치기 23: 예외 처리, 인터셉터, 뷰 — 요청 처리의 앞뒤와 끝

Spring · 게시

컨트롤러 메서드의 앞뒤에 끼어드는 HandlerInterceptor가 어떻게 줄을 서고 왜 preHandle을 통과한 것만 afterCompletion을 받는지, 컨트롤러가 던진 예외가 어떤 순서로 HandlerExceptionResolver를 거쳐 @ExceptionHandler 메서드에 닿는지, 그리고 뷰 이름 하나가 ViewResolver와 View를 거쳐 JSP로 forward되기까지를 실제 소스와 예제 출력으로 확인합니다.

이 글에서 다루는 것

"Spring 소스 파헤치기" 시리즈의 스물세 번째 글입니다. 21편에서 요청 하나가 DispatcherServlet.doDispatch()를 지나가는 전체 순서를 봤고, 22편에서 그 한가운데, 곧 @RequestMapping 메서드를 찾아 인자를 채우고 부르는 과정을 봤습니다. 이번 글은 그 가운데를 둘러싼 것들을 봅니다. 컨트롤러 메서드 앞뒤에 끼어드는 인터셉터, 메서드가 예외를 던졌을 때의 처리, 그리고 메서드가 돌려준 뷰 이름이 화면이 되는 마지막 단계입니다. 1편 지도의 Phase 5(웹) 가운데 U21에 해당하고, 이 글로 Spring MVC 쪽(U19–U21)이 끝납니다.

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

항목 내용
기준 소스 Spring Framework 7.1.0-SNAPSHOT
커밋 f447f3c310 (2026-10-01, main 브랜치)
주로 읽는 모듈 spring-webmvc, spring-web (web.method, web.bind.annotation)
주로 읽는 클래스 HandlerExecutionChain, DispatcherServlet, ExceptionHandlerExceptionResolver, ExceptionHandlerMethodResolver, UrlBasedViewResolver, InternalResourceView

글에 나오는 줄 번호와 세부 동작은 모두 이 커밋 기준입니다. 다른 버전에서는 줄 번호가 다르고 세부 내용도 달라질 수 있습니다. 같은 커밋의 소스를 받는 방법은 1편에 있습니다.

이 글은 Spring을 처음 보는 분이 따라올 수 있는 큰 줄기만 골랐습니다. 빼놓은 것은 글 끝의 "이 글에서 뺀 것"에 이름과 위치만 적어 두었습니다.

먼저 알아 둘 말

핸들러(handler)는 요청 하나를 실제로 처리하는 객체입니다. 우리가 흔히 쓰는 것은 @Controller 클래스의 @GetMapping 같은 메서드이고, Spring은 이것을 HandlerMethod라는 객체(컨트롤러 빈 + 메서드)로 감싸서 다룹니다. 핸들러를 찾고 부르는 과정은 22편에 있습니다.

DispatcherServlet 은 모든 요청을 맨 앞에서 받아 핸들러를 찾고, 부르고, 결과를 응답으로 만드는 서블릿입니다. 그 일의 순서가 doDispatch()라는 메서드 하나에 들어 있습니다. 자세한 흐름은 21편에서 봤습니다.

인터셉터(HandlerInterceptor)는 핸들러가 실행되기 전과 후에 끼어드는 객체입니다. 로그인했는지 확인하거나 걸린 시간을 재는 것처럼 여러 컨트롤러에 공통으로 필요한 일을 여기에 둡니다. 이름이 비슷한 것이 둘 더 있어서 헷갈리기 쉬운데 서로 다른 것입니다. 서블릿 필터(Filter)는 DispatcherServlet보다도 바깥에서 요청을 감싸고(20편), AOP의 MethodInterceptor 는 웹과 상관없이 프록시가 가로챈 메서드 호출을 감쌉니다(11편). 차이는 2절의 표에 정리했습니다.

ModelAndView 는 핸들러가 "이 데이터를(모델) 이 화면으로(뷰) 보여 달라"고 DispatcherServlet에게 돌려주는 꾸러미입니다. 뷰 자리에는 보통 뷰 객체가 아니라 "hello" 같은 뷰 이름(문자열)이 들어 있습니다.

뷰(View)는 모델을 받아 응답 본문(주로 HTML)을 써 내는 객체이고, 뷰 리졸버(ViewResolver)는 뷰 이름을 뷰 객체로 바꿔 주는 객체입니다.

forward는 서블릿이 요청을 같은 서버 안의 다른 자원(예를 들어 JSP 파일)에게 넘겨 그쪽이 응답을 쓰게 하는 것입니다. 브라우저는 주소가 바뀐 줄 모릅니다. 브라우저에게 "저 주소로 다시 요청하라"고 답하는 redirect와 다릅니다.

리졸버(resolver)라는 말이 이 글에 여러 번 나옵니다. Spring 소스에서 이 낱말은 "무언가를 받아 답을 찾아 주는 객체"라는 뜻으로 널리 쓰입니다. 예외를 받아 응답을 정하면 HandlerExceptionResolver, 뷰 이름을 받아 뷰를 주면 ViewResolver입니다.

1. 핸들러를 둘러싼 세 가지

21편에서 본 doDispatch()(935–1004행)를 이 글의 관심사만 남겨 다시 그리면 이렇습니다.

doDispatch에서 인터셉터, 예외 처리, 뷰가 놓이는 자리. 핸들러 실행 체인을 얻은 뒤 preHandle을 앞에서 뒤로 부르고, 모두 true면 핸들러를 실행한다. 정상 반환이면 postHandle을 뒤에서 앞으로 부르고, 예외면 processHandlerException이 예외 리졸버 목록에 묻는다. 두 길은 processDispatchResult에서 만나 ModelAndView가 있으면 뷰를 렌더링하고, 마지막에 afterCompletion을 뒤에서 앞으로 부른다. 파란 상자가 인터셉터(2절), 빨간 상자가 예외가 가는 길(3·4절), 초록 상자가 뷰(5절)입니다. 회색 상자는 21편과 22편에서 본 것입니다.

세 가지 모두 DispatcherServlet이 직접 하지 않고 목록에 든 객체들에게 차례로 묻는 모양입니다. 인터셉터 목록, HandlerExceptionResolver 목록, ViewResolver 목록입니다. 그래서 이 글의 절마다 같은 질문 두 개가 되풀이됩니다. "그 목록은 누가 채우는가", 그리고 "목록 안에서 누가 먼저인가"입니다.

2. 인터셉터 — 통과한 것만 정리를 받습니다

메서드 세 개

HandlerInterceptor는 메서드가 셋인 인터페이스이고 셋 다 기본 구현이 있습니다(102–156행).

메서드 언제 불리나 돌려주는 것
preHandle 핸들러를 찾은 뒤, 실행하기 전 true면 계속, false면 여기서 끝(응답은 이 인터셉터가 이미 썼다고 본다)
postHandle 핸들러가 정상적으로 끝난 뒤, 뷰를 그리기 전 없음. ModelAndView를 받아 모델에 값을 더할 수 있다
afterCompletion 뷰까지 그린 뒤. 결과가 어떻든 없음. 자원 정리용

줄은 누가 세우나: HandlerExecutionChain

핸들러 매핑(22편)은 핸들러만 돌려주지 않습니다. 핸들러와 "이 요청에 적용할 인터셉터들"을 묶은 HandlerExecutionChain 을 돌려줍니다. 묶는 곳은 모든 핸들러 매핑의 부모 클래스인 AbstractHandlerMapping입니다.

	protected HandlerExecutionChain getHandlerExecutionChain(Object handler, HttpServletRequest request) {

		HandlerExecutionChain chain = (handler instanceof HandlerExecutionChain handlerExecutionChain ?
				handlerExecutionChain : new HandlerExecutionChain(handler));

		for (HandlerInterceptor interceptor : this.adaptedInterceptors) {
			if (interceptor instanceof MappedInterceptor mappedInterceptor) {
				if (mappedInterceptor.matches(request)) {
					chain.addInterceptor(mappedInterceptor.getInterceptor());
				}
			}
			else {
				chain.addInterceptor(interceptor);
			}
		}
		// ...
		return chain;
	}

spring-webmvc/src/main/java/org/springframework/web/servlet/handler/AbstractHandlerMapping.java 681–706행

눈여겨볼 것이 두 가지입니다.

그러면 adaptedInterceptors는 누가 채울까요. @EnableWebMvc 설정에서는 WebMvcConfigurer.addInterceptors()로 등록한 것들입니다. registry.addInterceptor(x).addPathPatterns("/admin/**")처럼 경로를 붙이면 그 등록이 MappedInterceptor로 포장되고, 붙이지 않으면 인터셉터 그대로 쓰입니다(InterceptorRegistration 198–218행). 목록은 order 값으로 정렬되고(기본값 0, 값이 같으면 등록한 순서가 유지됩니다, InterceptorRegistry 69–83행), 같은 등록부에 Spring 자신이 쓰는 인터셉터 두 개도 함께 들어갑니다(WebMvcConfigurationSupport 304–316행). 컨테이너에 MappedInterceptor 타입의 빈이 따로 있으면 그것도 찾아서 넣습니다(AbstractHandlerMapping 451–455행, 478–481행).

interceptorIndex: 어디까지 통과했는지 적어 두는 번호

체인이 인터셉터를 부르는 코드는 21편 5절에 실려 있으니 여기서는 요점만 되짚습니다(HandlerExecutionChain 142–181행).

인터셉터 A, B, C 가운데 B의 preHandle이 false를 돌려줄 때. A가 true를 돌려주면 interceptorIndex가 0이 되고, B가 false를 돌려주면 triggerAfterCompletion이 interceptorIndex인 0부터 거꾸로 돌아 A의 afterCompletion만 부른다. B의 afterCompletion은 불리지 않고 C는 preHandle조차 불리지 않는다. 노란 상자가 체인이 들고 있는 상태입니다. B는 true를 돌려준 적이 없으므로 번호가 0에서 멈춰 있습니다.

왜 이런 번호가 필요할까요. preHandle에서 무언가를 열고(타이머 시작, 자원 잡기) afterCompletion에서 닫는 인터셉터를 생각하면 됩니다. 열지 않은 것을 닫으라고 부르면 안 되고, 연 것은 반드시 닫게 해 줘야 합니다. 번호 하나로 "여기까지는 열었다"를 기억해 두면 둘 다 지켜집니다. 소스의 설명 주석도 afterCompletion은 그 인터셉터의 preHandle이 성공해 true를 돌려준 경우에만 불린다고 적어 두었습니다(137–138행).

AOP의 어드바이스 체인과 무엇이 다른가

11편에서 본 AOP의 체인도 번호 하나(currentInterceptorIndex)로 줄을 따라갔습니다. 하지만 모양이 다릅니다. AOP의 MethodInterceptor는 메서드 하나(invoke) 안에서 proceed()를 불러 다음 차례를 자기가 감쌉니다. 앞일을 하고, proceed()를 부르고, 돌아오면 뒷일을 합니다. 정리는 자기 try/finally로 하면 되므로 "어디까지 갔는지"를 체인이 따로 기억할 필요가 없습니다.

HandlerInterceptor는 감싸지 않습니다. 앞·뒤·끝이 서로 다른 메서드 세 개로 떨어져 있고, 부르는 쪽(DispatcherServlet)이 평범한 반복문 세 번으로 차례차례 부릅니다. 인터셉터 자신에게는 "내 preHandle과 내 afterCompletion 사이"를 묶어 주는 try/finally가 없으니, 그 짝을 체인이 interceptorIndex로 대신 맞춰 주는 것입니다.

서블릿 Filter MVC HandlerInterceptor AOP MethodInterceptor
어디에 끼어드나 DispatcherServlet 바깥 DispatcherServlet 안, 핸들러 앞뒤 프록시가 가로챈 메서드 호출
모양 doFilter 하나가 다음을 감싼다 메서드 셋을 따로 부른다 invoke 하나가 다음을 감싼다
다음으로 넘기는 법 chain.doFilter() preHandle이 true 반환 invocation.proceed()
핸들러가 누구인지 아나 모른다 안다 (handler 인자) 웹과 무관
설명한 글 20편 이 글 11편

HandlerInterceptor의 설명 주석도 필터와 비교하면서, 필터는 체인으로 넘기는 요청·응답 객체를 바꿔치기할 수 있어 더 강력하고 인터셉터는 핸들러 실행을 막거나 앞뒤 처리를 하는 것만 허용한다고 적고 있습니다(52–57행). 표에서 필터가 핸들러를 "모른다"고 적은 칸은 Spring 소스에서 확인한 것이 아니라 서블릿의 구조에서 나온 설명입니다. 필터는 DispatcherServlet이 핸들러를 고르기 전에 실행되고, doFilter가 받는 것은 요청·응답·체인뿐입니다.

예제 1: B가 막을 때, 그리고 예외가 처리됐을 때

ex/u21/InterceptorOrderExample.java입니다. 이 예제는 릴리스 버전인 Spring Framework 7.0.9로 실행해 아래 출력을 확인했습니다(이 시리즈가 읽는 7.1.0-SNAPSHOT을 직접 빌드해 돌린 것은 아닙니다). 서버를 띄우는 대신 spring-test의 MockMvc로 DispatcherServlet에 가짜 요청을 넣었습니다. 이 글의 세 예제는 모두 실행하면 Hibernate Validator의 버전 안내 두 줄이 함께 찍히는데, 이 글과 관계없어 출력에서 뺐습니다.

// 이 글을 위해 쓴 예제입니다 (Spring의 소스가 아닙니다)
static class Named implements HandlerInterceptor {
	// ... 이름을 받는 생성자

	@Override
	public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
		boolean pass = !this.name.equals(request.getParameter("block"));   // ?block=B 면 B 가 막는다
		System.out.println("  preHandle       " + this.name + " -> " + pass);
		if (!pass) {
			response.setStatus(403);
		}
		return pass;
	}
	// ... postHandle, afterCompletion 은 받은 인자를 찍기만 한다
}

@RestController
static class DemoController {
	@GetMapping("/ok")
	String ok() {
		System.out.println("  handler         ok()");
		return "ok";
	}

	@GetMapping("/boom")
	String boom() {
		System.out.println("  handler         boom() throws");
		throw new IllegalStateException("boom");
	}

	@ExceptionHandler(IllegalStateException.class)
	String onIllegalState(IllegalStateException ex) {
		System.out.println("  @ExceptionHandler onIllegalState");
		return "handled: " + ex.getMessage();
	}
}

// main 에서
MockMvc mockMvc = MockMvcBuilders.standaloneSetup(new DemoController())
		.addInterceptors(new Named("A"), new Named("B"), new Named("C"))
		.build();
1) GET /ok
  preHandle       A -> true
  preHandle       B -> true
  preHandle       C -> true
  handler         ok()
  postHandle      C (mv=null)
  postHandle      B (mv=null)
  postHandle      A (mv=null)
  afterCompletion C (ex=null)
  afterCompletion B (ex=null)
  afterCompletion A (ex=null)
  => status=200, body="ok"
2) GET /ok?block=B
  preHandle       A -> true
  preHandle       B -> false
  afterCompletion A (ex=null)
  => status=403, body=""
3) GET /boom
  preHandle       A -> true
  preHandle       B -> true
  preHandle       C -> true
  handler         boom() throws
  @ExceptionHandler onIllegalState
  afterCompletion C (ex=null)
  afterCompletion B (ex=null)
  afterCompletion A (ex=null)
  => status=200, body="handled: boom"

2번이 그림 그대로입니다. afterCompletion은 A만 받았습니다. 3번에서는 postHandle이 한 번도 불리지 않았고, 예외가 났는데도 afterCompletion의 ex가 null입니다. 아래에서 이유를 봅니다.

헷갈리기 쉬운 곳

3. 예외가 났을 때 — HandlerExceptionResolver 세 개

예외는 던져지지 않고 변수에 담깁니다

핸들러(또는 인터셉터의 preHandle·postHandle, 인자 준비 과정)가 던진 예외를 doDispatch()는 곧바로 밖으로 던지지 않고 dispatchException이라는 변수에 담아(972–974행) processDispatchResult()에 넘깁니다. 거기서 예외가 있으면 processHandlerException()을 부릅니다(1028–1038행). 이 글에서 볼 것은 그 안쪽입니다.

	protected @Nullable ModelAndView processHandlerException(HttpServletRequest request, HttpServletResponse response,
			@Nullable Object handler, Exception ex) throws Exception {
		// ...
		// Check registered HandlerExceptionResolvers...
		ModelAndView exMv = null;
		if (this.handlerExceptionResolvers != null) {
			for (HandlerExceptionResolver resolver : this.handlerExceptionResolvers) {
				exMv = resolver.resolveException(request, response, handler, ex);
				if (exMv != null) {
					break;
				}
			}
		}
		if (exMv != null) {
			if (exMv.isEmpty()) {
				request.setAttribute(EXCEPTION_ATTRIBUTE, ex);
				return null;
			}
			// ...
			WebUtils.exposeErrorRequestAttributes(request, ex, getServletName());
			return exMv;
		}

		throw ex;
	}

spring-webmvc/src/main/java/org/springframework/web/servlet/DispatcherServlet.java 1207–1256행

HandlerExceptionResolver는 메서드가 resolveException 하나뿐인 인터페이스이고(51–52행), 돌려주는 값의 뜻이 세 가지입니다.

돌려준 값 뜻 DispatcherServlet이 하는 일
null 내가 처리할 예외가 아니다 다음 리졸버에게 묻는다
빈 ModelAndView (뷰도 모델도 없음) 처리했고, 응답도 내가 이미 썼다 뷰를 그리지 않고 끝낸다 (return null)
뷰가 있는 ModelAndView 처리했다. 이 오류 화면을 그려 달라 그 뷰를 렌더링한다

끝까지 아무도 답하지 않으면 마지막 줄 throw ex입니다. 이 예외는 doDispatch()의 바깥 catch가 받아 인터셉터의 afterCompletion을 (이번에는 예외와 함께) 부른 뒤 다시 던지고(982–984행, 1357–1364행), FrameworkServlet.processRequest()가 ServletException으로 감싸 서블릿 컨테이너로 내보냅니다(1007–1009행).

목록은 누가 채우나

DispatcherServlet은 켜질 때 컨테이너에서 HandlerExceptionResolver 타입의 빈을 전부 모아 순서대로 정렬합니다. 하나도 없으면 DispatcherServlet.properties에 적힌 기본 세 개를 만듭니다(591–624행, 이 "빈이 있으면 그것, 없으면 기본값" 방식은 21편 3절에서 봤습니다).

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

spring-webmvc/src/main/resources/org/springframework/web/servlet/DispatcherServlet.properties 17–19행

@EnableWebMvc를 쓰면 길이 조금 다릅니다. 설정 클래스 WebMvcConfigurationSupport가 handlerExceptionResolver라는 빈을 하나 등록하는데, 그것이 HandlerExceptionResolverComposite입니다(984–997행). 이름 그대로 리졸버 여러 개를 품고 차례로 물어 처음 답한 것을 돌려주는 리졸버이고(73–85행), 그 안에 같은 세 개가 같은 순서로 들어갑니다.

	protected final void addDefaultHandlerExceptionResolvers(List<HandlerExceptionResolver> exceptionResolvers,
			ContentNegotiationManager mvcContentNegotiationManager) {

		ExceptionHandlerExceptionResolver exceptionHandlerResolver = createExceptionHandlerExceptionResolver();
		// ...
		exceptionHandlerResolver.afterPropertiesSet();
		exceptionResolvers.add(exceptionHandlerResolver);

		ResponseStatusExceptionResolver responseStatusResolver = new ResponseStatusExceptionResolver();
		responseStatusResolver.setMessageSource(this.applicationContext);
		exceptionResolvers.add(responseStatusResolver);

		exceptionResolvers.add(new DefaultHandlerExceptionResolver());
	}

spring-webmvc/src/main/java/org/springframework/web/servlet/config/annotation/WebMvcConfigurationSupport.java 1033–1063행

예외가 리졸버를 거치는 순서. processHandlerException이 리졸버 목록을 차례로 돌고, 그 안의 HandlerExceptionResolverComposite가 다시 ExceptionHandlerExceptionResolver, ResponseStatusExceptionResolver, DefaultHandlerExceptionResolver 순으로 묻는다. null이 아닌 첫 답에서 멈추고, 셋 다 null이면 예외를 다시 던진다. 어느 길이든 순서는 같습니다. 우리가 쓴 @ExceptionHandler가 먼저, Spring이 미리 정해 둔 처리가 나중입니다.

순서 리졸버 맡는 예외 하는 일
① ExceptionHandlerExceptionResolver 맞는 @ExceptionHandler 메서드가 있는 예외 그 메서드를 찾아 부른다 (4절)
② ResponseStatusExceptionResolver ResponseStatusException, 또는 클래스에 @ResponseStatus가 붙은 예외 그 상태 코드로 response.sendError() (69–92행, 146행부터)
③ DefaultHandlerExceptionResolver Spring MVC 자신이 던지는 예외들. 필수 파라미터 누락, 타입 변환 실패, 지원하지 않는 HTTP 메서드, 핸들러 없음 등 예외마다 정해진 상태 코드(400, 405, 404 …)로 sendError() (187–262행)

②와 ③은 응답에 상태 코드를 직접 쓰고 빈 ModelAndView 를 돌려줍니다. 위 표의 둘째 줄, "처리했고 그릴 것은 없다"입니다. 순서가 이렇게 된 까닭은 표를 아래에서 위로 읽으면 보입니다. ③은 누구도 따로 처리하지 않았을 때의 기본값이고, ②는 예외 클래스에 미리 적어 둔 선언이고, ①은 지금 이 애플리케이션이 직접 쓴 코드입니다. 더 구체적인 뜻을 가진 쪽이 먼저 기회를 얻습니다.

예제 2의 앞부분과 뒷부분: 목록 확인, 그리고 ②와 ③

ex/u21/ExceptionHandlerPickExample.java입니다(Spring Framework 7.0.9로 실행). 먼저 @EnableWebMvc만 붙인 빈 설정으로 컨테이너를 띄워 HandlerExceptionResolver 빈을 찍고, 이어서 @ExceptionHandler가 하나도 없는 컨트롤러에 요청을 보냅니다. 가운데 2)와 3)은 4절에서 봅니다.

// 이 글을 위해 쓴 예제입니다 (Spring의 소스가 아닙니다)
@ResponseStatus(HttpStatus.NOT_FOUND)
static class OrderNotFoundException extends RuntimeException {
}

@RestController
static class NoHandlerController {
	@GetMapping("/orders")
	String order(@RequestParam int id) {
		throw new OrderNotFoundException();
	}

	@GetMapping("/unknown")
	String unknown() {
		throw new UnsupportedOperationException("nobody handles this");
	}
}
1) HandlerExceptionResolver beans registered by @EnableWebMvc
  handlerExceptionResolver = HandlerExceptionResolverComposite
    order = 0
    - ExceptionHandlerExceptionResolver
    - ResponseStatusExceptionResolver
    - DefaultHandlerExceptionResolver
4) With no @ExceptionHandler at all
  GET /orders?id=1 -> status=404, body="", resolvedException=OrderNotFoundException
[main] WARN org.springframework.web.servlet.mvc.support.DefaultHandlerExceptionResolver -  COMMONS-LOGGING Resolved [org.springframework.web.bind.MissingServletRequestParameterException: Required request parameter 'id' for method parameter type int is not present]
  GET /orders -> status=400, body="", resolvedException=MissingServletRequestParameterException
[main] WARN org.springframework.web.servlet.mvc.support.DefaultHandlerExceptionResolver -  COMMONS-LOGGING Resolved [org.springframework.web.method.annotation.MethodArgumentTypeMismatchException: Method parameter 'id': Failed to convert value of type 'java.lang.String' to required type 'int'; For input string: "abc"]
  GET /orders?id=abc -> status=400, body="", resolvedException=MethodArgumentTypeMismatchException
  GET /unknown -> perform() threw: ServletException, cause=UnsupportedOperationException

[main] WARN 두 줄은 예제가 아니라 DefaultHandlerExceptionResolver가 남긴 로그입니다(예제 1에서 적은 대로 Hibernate Validator의 버전 안내 두 줄은 뺐습니다). 첫 요청은 @ResponseStatus가 붙은 예외라 ②가 404로 바꿨습니다. 둘째와 셋째는 컨트롤러 메서드에 들어가기도 전에 인자를 준비하다 난 예외이고 ③이 400으로 바꿨습니다. 마지막 UnsupportedOperationException은 세 리졸버 누구의 것도 아니어서 throw ex까지 갔고, ServletException에 싸여 밖으로 나왔습니다.

헷갈리기 쉬운 곳

4. @ExceptionHandler 메서드는 어떻게 고르나

ExceptionHandlerExceptionResolver가 할 일은 둘입니다. 이 예외에 맞는 메서드를 찾고, 그 메서드를 부르는 것입니다.

찾는 순서: 컨트롤러 자신 → @ControllerAdvice

@ControllerAdvice는 "여러 컨트롤러에 공통으로 적용할 @ExceptionHandler 메서드 등을 모아 둔 클래스"라는 표시입니다. 어노테이션 정의에 @Component가 붙어 있어서(75–79행) 컴포넌트 스캔에 걸려 빈이 됩니다(어노테이션 위의 어노테이션을 읽는 방법은 13편). 이름이 AOP의 어드바이스와 비슷하지만 프록시와는 관계가 없습니다. 아래에서 보듯 리졸버가 목록을 직접 뒤질 뿐입니다.

	protected @Nullable ServletInvocableHandlerMethod getExceptionHandlerMethod(
			@Nullable HandlerMethod handlerMethod, Exception exception, ServletWebRequest webRequest) {
		// ...
		Class<?> handlerType = null;

		if (handlerMethod != null) {
			// Local exception handler methods on the controller class itself.
			// To be invoked through the proxy, even in case of an interface-based proxy.
			handlerType = handlerMethod.getBeanType();
			ExceptionHandlerMethodResolver resolver = this.exceptionHandlerCache.computeIfAbsent(
					handlerType, ExceptionHandlerMethodResolver::new);

			for (MediaType mediaType : acceptedMediaTypes) {
				ExceptionHandlerMappingInfo mappingInfo = resolver.resolveExceptionMapping(exception, mediaType);
				if (mappingInfo != null) {
					// ...
					return new ServletInvocableHandlerMethod(handlerMethod.getBean(), mappingInfo.getHandlerMethod(), this.applicationContext);
				}
			}
			// ...
		}

		for (Map.Entry<ControllerAdviceBean, ExceptionHandlerMethodResolver> entry : this.exceptionHandlerAdviceCache.entrySet()) {
			ControllerAdviceBean advice = entry.getKey();
			if (advice.isApplicableToBeanType(handlerType)) {
				ExceptionHandlerMethodResolver resolver = entry.getValue();
				for (MediaType mediaType : acceptedMediaTypes) {
					ExceptionHandlerMappingInfo mappingInfo = resolver.resolveExceptionMapping(exception, mediaType);
					if (mappingInfo != null) {
						// ...
						return new ServletInvocableHandlerMethod(advice.resolveBean(), mappingInfo.getHandlerMethod(), this.applicationContext);
					}
				}
			}
		}

		return null;
	}

spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/ExceptionHandlerExceptionResolver.java 509–564행

@ExceptionHandler 메서드를 찾는 순서. 먼저 예외가 난 컨트롤러 클래스 자신의 리졸버를 캐시에서 꺼내 예외 타입으로 찾고, 없으면 원인 예외로 다시 찾는다. 찾으면 컨트롤러 안의 메서드가 이긴다. 없으면 @ControllerAdvice 빈들을 정렬된 순서로 돌면서, 이 컨트롤러에 적용되는 어드바이스인지 확인한 뒤 같은 방법으로 찾는다. 끝까지 없으면 null을 돌려주어 다음 예외 리졸버로 넘어간다. 노란 상자가 캐시 둘입니다. 왼쪽 초록 상자에 닿으면 오른쪽은 아예 보지 않습니다.

  1. 컨트롤러 클래스 자신. 예외가 난 핸들러 메서드의 클래스에서 @ExceptionHandler 메서드를 찾습니다. 클래스마다 ExceptionHandlerMethodResolver를 하나 만들어 exceptionHandlerCache에 넣어 두고 다시 씁니다(119–120행). 여기서 찾으면 바로 반환합니다.
  2. @ControllerAdvice 빈들. 1에서 못 찾았을 때만 옵니다. exceptionHandlerAdviceCache는 리졸버가 초기화될 때 한 번 채워집니다. 컨테이너에서 @ControllerAdvice가 붙은 빈을 모두 찾아 순서대로 정렬하고(ControllerAdviceBean.findAnnotatedBeans 248–266행), @ExceptionHandler 메서드가 있는 것만 넣습니다(303–321행). 맵이 LinkedHashMap이라 넣은 순서, 곧 정렬 순서대로 돕니다(122–123행). 어드바이스가 여럿이면 @Order가 앞선 것이 먼저 기회를 얻습니다.
  3. 어드바이스마다 isApplicableToBeanType으로 이 컨트롤러에 적용되는지 확인합니다. @ControllerAdvice(basePackages = …)나 assignableTypes, annotations로 대상을 좁혀 둔 경우를 거르는 것입니다(203–205행, 273–280행).

한 클래스 안에서: 가장 가까운 타입

클래스 하나 안에서 메서드를 고르는 일은 spring-web 모듈의 ExceptionHandlerMethodResolver가 합니다. 생성자에서 그 클래스의 @ExceptionHandler 메서드를 모두 찾아 "(예외 타입, 미디어 타입) → 메서드 정보(ExceptionHandlerMappingInfo)" 표(mappedMethods)를 만들어 둡니다(93–105행). 어떤 예외를 맡는지는 어노테이션에 적힌 타입을 먼저 보고, 적지 않았으면 메서드 파라미터 가운데 Throwable 타입인 것에서 읽습니다(113–125행). 같은 예외 타입을(미디어 타입까지 같게) 두 메서드가 맡겠다고 하면 이 시점에 IllegalStateException("Ambiguous @ExceptionHandler method mapped for …")으로 실패합니다(144–150행).

찾을 때는 이렇게 합니다.

	public @Nullable ExceptionHandlerMappingInfo resolveExceptionMapping(Throwable exception, MediaType mediaType) {
		ExceptionHandlerMappingInfo mappingInfo = resolveExceptionMappingByExceptionType(exception.getClass(), mediaType);
		if (mappingInfo == null) {
			Throwable cause = exception.getCause();
			if (cause != null) {
				mappingInfo = resolveExceptionMapping(cause, mediaType);
			}
		}
		return mappingInfo;
	}
	// ...
	private ExceptionHandlerMappingInfo getMappedMethod(Class<? extends Throwable> exceptionType, MediaType mediaType) {
		List<ExceptionMapping> matches = new ArrayList<>();
		for (ExceptionMapping mappingInfo : this.mappedMethods.keySet()) {
			if (mappingInfo.exceptionType().isAssignableFrom(exceptionType) && mappingInfo.mediaType().isCompatibleWith(mediaType)) {
				matches.add(mappingInfo);
			}
		}
		if (!matches.isEmpty()) {
			if (matches.size() > 1) {
				matches.sort(new ExceptionMappingComparator(exceptionType, mediaType));
			}
			return Objects.requireNonNull(this.mappedMethods.get(matches.get(0)));
		}
		else {
			return NO_MATCHING_EXCEPTION_HANDLER;
		}
	}

spring-web/src/main/java/org/springframework/web/method/annotation/ExceptionHandlerMethodResolver.java 191–246행

getMappedMethod는 표를 훑어 던져진 예외를 받을 수 있는(isAssignableFrom) 항목을 모두 모읍니다. IllegalStateException이 던져졌다면 IllegalStateException용, RuntimeException용, Exception용이 모두 후보입니다. 후보가 여럿이면 정렬해서 첫 번째를 씁니다. 정렬 기준의 첫째가 ExceptionDepthComparator입니다.

	private int getDepth(Class<?> declaredException, Class<?> exceptionToMatch, int depth) {
		if (exceptionToMatch.equals(declaredException)) {
			// Found it!
			return depth;
		}
		// If we've gone as far as we can go and haven't found it...
		if (exceptionToMatch == Throwable.class) {
			return Integer.MAX_VALUE;
		}
		return getDepth(declaredException, exceptionToMatch.getSuperclass(), depth + 1);
	}

spring-core/src/main/java/org/springframework/core/ExceptionDepthComparator.java 64–74행

던져진 예외의 클래스에서 시작해 부모 클래스로 한 칸씩 올라가며 선언된 타입을 만날 때까지 걸음 수를 셉니다. IllegalStateException에서 보면 자기 자신은 0, RuntimeException은 1, Exception은 2입니다. 걸음 수가 가장 적은 것, 곧 가장 가까운 조상이 뽑힙니다. Java의 catch 블록을 구체적인 예외부터 적는 것과 같은 결과를, 메서드를 적은 순서와 상관없이 얻는 셈입니다.

두 가지를 더 봐 둡니다.

코드에 보이는 mediaType은 @ExceptionHandler(produces = …)로 "이 메서드는 이런 형식의 응답을 만든다"를 적어 두었을 때, 요청이 원하는 형식에 맞는 메서드를 고르기 위한 것입니다(이 메서드의 주석에 @since 6.2라고 적혀 있습니다, 183–190행). produces를 쓰지 않으면 모든 형식(MediaType.ALL)으로 등록되어 예외 타입만으로 고르게 됩니다.

예제 2의 가운데: 누가 이기나

같은 예제의 2)와 3)입니다.

// 이 글을 위해 쓴 예제입니다 (Spring의 소스가 아닙니다)
@RestController
static class LocalController {              // 컨트롤러 안에 RuntimeException 용이 있다
	@GetMapping("/local")
	String local() { throw new IllegalStateException("from /local"); }

	@ExceptionHandler(RuntimeException.class)
	String localRuntime(RuntimeException ex) { return "LocalController.localRuntime"; }
}

@RestController
static class PlainController {              // 컨트롤러 안에 처리 메서드가 없다
	@GetMapping("/plain/state")
	String state() { throw new IllegalStateException("from /plain/state"); }

	@GetMapping("/plain/io")
	String io() throws IOException { throw new FileNotFoundException("from /plain/io"); }
}

@RestController
static class CauseController {              // IOException 용뿐인데, 던지는 것은 그것을 품은 IllegalStateException
	@GetMapping("/cause")
	String cause() { throw new IllegalStateException("wrapper", new IOException("root cause")); }

	@ExceptionHandler(IOException.class)
	String localIo(IOException ex) { return "CauseController.localIo"; }
}

@ControllerAdvice
static class GlobalAdvice {
	@ExceptionHandler(IllegalStateException.class)
	@ResponseBody
	String adviceIllegalState(IllegalStateException ex) { return "GlobalAdvice.adviceIllegalState"; }

	@ExceptionHandler(Exception.class)
	@ResponseBody
	String adviceException(Exception ex) { return "GlobalAdvice.adviceException"; }
}
2) Picking inside GlobalAdvice (ExceptionHandlerMethodResolver)
  IllegalStateException -> adviceIllegalState
  FileNotFoundException -> adviceException
  RuntimeException(cause=IllegalStateException) -> adviceException
3) Inside the controller vs @ControllerAdvice
  GET /local -> status=200, body="LocalController.localRuntime", resolvedException=IllegalStateException
  GET /plain/state -> status=200, body="GlobalAdvice.adviceIllegalState", resolvedException=IllegalStateException
  GET /plain/io -> status=200, body="GlobalAdvice.adviceException", resolvedException=FileNotFoundException
  GET /cause -> status=200, body="CauseController.localIo", resolvedException=IllegalStateException

2)는 new ExceptionHandlerMethodResolver(GlobalAdvice.class)를 직접 만들어 resolveMethod에 물어본 결과이고, 3)은 세 컨트롤러와 어드바이스를 MockMvc에 올려 요청을 보낸 결과입니다.

헷갈리기 쉬운 곳

부르는 쪽: 컨트롤러 메서드와 같은 기계

찾은 메서드는 ServletInvocableHandlerMethod로 감싸 부릅니다(430–464행). 22편에서 컨트롤러 메서드를 부를 때 쓴 바로 그 클래스이고, 인자 리졸버와 반환값 핸들러 목록도 같은 방식으로 붙습니다. 그래서 @ExceptionHandler 메서드도 HttpServletRequest를 인자로 받을 수 있고, @ResponseBody나 ResponseEntity로 본문을 쓸 수도, 문자열로 뷰 이름을 돌려줄 수도 있습니다. 예외 객체는 원인 사슬 전체가 "미리 준비된 인자"로 함께 넘어가서(454–463행) 메서드 파라미터의 타입에 맞는 것이 들어갑니다. /cause에서 localIo(IOException ex)가 겉의 IllegalStateException이 아니라 속의 IOException을 받는 까닭입니다.

부른 뒤의 처리가 3절의 표와 이어집니다. 메서드가 응답을 직접 썼으면(mavContainer.isRequestHandled()) 빈 ModelAndView 를, 아니면 뷰 이름과 모델을 담은 ModelAndView를 돌려줍니다(479–495행).

@RestControllerAdvice는 @ControllerAdvice에 @ResponseBody를 합친 것입니다(46–51행). 위 예제의 GlobalAdvice에서 메서드마다 붙인 @ResponseBody를 클래스에 한 번만 붙이는 셈입니다.

ResponseEntityExceptionHandler와 ProblemDetail (짧게)

3절의 ③ DefaultHandlerExceptionResolver는 상태 코드만 정하고 본문은 비워 둡니다. REST API라면 오류도 JSON 본문으로 내려 주고 싶을 것입니다. 그럴 때 쓰라고 있는 것이 ResponseEntityExceptionHandler입니다. @ExceptionHandler 메서드 하나에 ③이 맡는 것과 거의 같은 표준 예외 목록을 적어 둔 추상 클래스이고(116–138행), 이것을 상속한 클래스에 @ControllerAdvice를 붙이면 그 예외들이 ③까지 가기 전에 ①에서 잡혀 본문이 있는 ResponseEntity로 바뀝니다. 본문은 ProblemDetail입니다. 소스 주석에 RFC 9457의 "problem detail" 표현이라고 적혀 있고, type·title·status·detail·instance 필드를 가진 단순한 객체입니다(ProblemDetail 31행, 54–67행). 상태 코드와 ProblemDetail을 함께 내놓을 수 있는 예외들의 공통 인터페이스가 ErrorResponse입니다(47–71행).

5. 뷰 — 이름 하나가 화면이 되기까지

render(): 이름을 뷰로, 뷰에게 그리기를

핸들러가 정상적으로 끝났든 예외 리졸버가 오류 화면을 골랐든, ModelAndView가 있으면 processDispatchResult()가 render()를 부릅니다(1040–1046행).

	protected void render(ModelAndView mv, HttpServletRequest request, HttpServletResponse response) throws Exception {
		// ...
		View view;
		String viewName = mv.getViewName();
		if (viewName != null) {
			// We need to resolve the view name.
			view = resolveViewName(viewName, mv.getModelInternal(), locale, request);
			if (view == null) {
				throw new ServletException("Could not resolve view with name '" + mv.getViewName() +
						"' in servlet with name '" + getServletName() + "'");
			}
		}
		// ...
			view.render(mv.getModelInternal(), request, response);
		// ...
	}

spring-webmvc/src/main/java/org/springframework/web/servlet/DispatcherServlet.java 1267–1313행

두 단계입니다. 뷰 이름이 있으면 resolveViewName()으로 뷰 객체로 바꾸고(이름 대신 뷰 객체가 직접 들어 있으면 그것을 씁니다), 그 뷰의 render()에 모델을 넘깁니다. 이름을 뷰로 바꾸는 쪽도 익숙한 모양입니다.

	private @Nullable View resolveViewNameInternal(String viewName, Locale locale) throws Exception {
		if (this.viewResolvers != null) {
			for (ViewResolver viewResolver : this.viewResolvers) {
				View view = viewResolver.resolveViewName(viewName, locale);
				if (view != null) {
					return view;
				}
			}
		}
		return null;
	}

spring-webmvc/src/main/java/org/springframework/web/servlet/DispatcherServlet.java 1345–1355행

ViewResolver는 메서드가 resolveViewName(viewName, locale) 하나인 인터페이스이고(55행), "내가 모르는 이름이면 null을 돌려주어 다음 리졸버에게 넘긴다"가 약속입니다(42–46행). 목록은 예외 리졸버와 같은 방식으로 채워집니다. 컨테이너의 ViewResolver 빈을 모아 정렬하고, 없으면 기본값인 InternalResourceViewResolver 하나를 씁니다(656–688행).

뷰 이름이 화면이 되는 과정. ModelAndView가 null이면 render를 건너뛴다. 있으면 DispatcherServlet.render가 resolveViewName으로 뷰 리졸버들에게 차례로 묻고, InternalResourceViewResolver가 접두사와 접미사를 붙여 InternalResourceView를 만들어 캐시에 둔다. AbstractView.render가 모델을 합치고, 모델을 request 속성으로 옮긴 뒤 RequestDispatcher.forward로 JSP에 넘긴다. 왼쪽 회색 길이 @ResponseBody의 길입니다. 이 절의 끝에서 봅니다.

뷰 리졸버 세 가지

뷰 리졸버 이름을 어떻게 뷰로 바꾸나 모르는 이름이면
InternalResourceViewResolver 접두사 + 이름 + 접미사를 주소로 하는 InternalResourceView를 만든다. JSP용 null을 돌려주지 않는다. 항상 뷰를 만든다
BeanNameViewResolver 그 이름의 빈이 컨테이너에 있고 View 타입이면 그 빈을 돌려준다 (67–82행) null
ContentNegotiatingViewResolver 스스로는 뷰를 만들지 않는다. 다른 리졸버들에게 물어 후보를 모은 뒤 요청이 원하는 미디어 타입에 맞는 뷰를 고른다 (218–228행) null

가장 자주 보는 InternalResourceViewResolver의 일은 부모 클래스 UrlBasedViewResolver에 들어 있습니다.

	protected @Nullable View createView(String viewName, Locale locale) throws Exception {
		// ...
		// Check for special "redirect:" prefix.
		if (viewName.startsWith(REDIRECT_URL_PREFIX)) {
			// ...
		}

		// Check for special "forward:" prefix.
		if (viewName.startsWith(FORWARD_URL_PREFIX)) {
			String forwardUrl = viewName.substring(FORWARD_URL_PREFIX.length());
			InternalResourceView view = new InternalResourceView(forwardUrl);
			return applyLifecycleMethods(FORWARD_URL_PREFIX, view);
		}

		// Else fall back to superclass implementation: calling loadView.
		return super.createView(viewName, locale);
	}
	// ...
	protected AbstractUrlBasedView buildView(String viewName) throws Exception {
		AbstractUrlBasedView view = instantiateView();
		view.setUrl(getPrefix() + viewName + getSuffix());
		// ...
		return view;
	}

spring-webmvc/src/main/java/org/springframework/web/servlet/view/UrlBasedViewResolver.java 449–582행

View.render(): 모델을 request에 옮기고 forward

뷰 구현체들의 공통 부모 AbstractView.render()는 모델에 정적 속성 등을 합친 뒤 renderMergedOutputModel()이라는 추상 메서드를 부르고(296–308행), 뷰 종류마다 이 메서드를 다르게 채웁니다. JSP용인 InternalResourceView는 이렇습니다.

	protected void renderMergedOutputModel(
			Map<String, Object> model, HttpServletRequest request, HttpServletResponse response) throws Exception {

		// Expose the model object as request attributes.
		exposeModelAsRequestAttributes(model, request);
		// ...
		// Determine the path for the request dispatcher.
		String dispatcherPath = prepareForRendering(request, response);

		// Obtain a RequestDispatcher for the target resource (typically a JSP).
		RequestDispatcher rd = getRequestDispatcher(request, dispatcherPath);
		// ...
		// If already included or response already committed, perform include, else forward.
		if (useInclude(request, response)) {
			// ...
			rd.include(request, response);
		}

		else {
			// ...
			rd.forward(request, response);
		}
	}

spring-webmvc/src/main/java/org/springframework/web/servlet/view/InternalResourceView.java 138–173행

  1. 모델을 요청 속성으로 옮깁니다. model.addAttribute("name", …)으로 넣은 값이 request.setAttribute("name", …)이 됩니다(AbstractView 430–441행). JSP가 ${name}으로 값을 읽을 수 있는 까닭입니다. JSP는 Spring의 모델을 모르고 서블릿 요청 속성만 압니다.
  2. 서블릿 컨테이너에게 그 주소의 RequestDispatcher를 받습니다.
  3. forward 합니다. 이 뒤로 HTML을 쓰는 것은 Spring이 아니라 서블릿 컨테이너의 JSP 엔진입니다. (이미 다른 자원 안에 포함되어 실행 중이거나 응답이 전송되기 시작했으면 include를 씁니다.)

Spring MVC가 뷰에 대해 하는 일은 결국 "모델을 건네고 어디로 넘길지 정하는 것"까지입니다. 템플릿 엔진이 바뀌면 renderMergedOutputModel()을 다르게 구현한 뷰와 그 뷰를 만드는 뷰 리졸버가 바뀔 뿐, DispatcherServlet.render()는 그대로입니다.

예제 3: 뷰 이름이 forward 주소가 되는 것

ex/u21/ViewForwardExample.java입니다(Spring Framework 7.0.9로 실행). MockMvc의 가짜 응답은 forward된 주소를 기록해 두므로 JSP 엔진 없이도 "어디로 넘기려 했는지"를 볼 수 있습니다.

// 이 글을 위해 쓴 예제입니다 (Spring의 소스가 아닙니다)
@Controller
static class PageController {
	@GetMapping("/hello")
	String hello(Model model) {
		model.addAttribute("name", "namalzi");
		return "hello";                 // 뷰 이름
	}

	@GetMapping("/api/hello")
	@ResponseBody
	String apiHello() {
		return "hello";                 // 같은 문자열이지만 응답 본문
	}

	@GetMapping("/old")
	String old() {
		return "redirect:/hello";
	}
}

// main 에서
MockMvc mockMvc = MockMvcBuilders.standaloneSetup(new PageController())
		.setViewResolvers(new InternalResourceViewResolver("/WEB-INF/views/", ".jsp"))
		.build();
// ... 2) 에서는 new InternalResourceViewResolver() — 접두사와 접미사 없이
1) prefix /WEB-INF/views/ + view name + suffix .jsp
  GET /hello
    viewName      = hello
    forwardedUrl  = /WEB-INF/views/hello.jsp
    redirectedUrl = null
    request attribute name = namalzi
    body          = ""
  GET /api/hello
    viewName      = (no ModelAndView)
    forwardedUrl  = null
    redirectedUrl = null
    request attribute name = null
    body          = "hello"
  GET /old
    viewName      = redirect:/hello
    forwardedUrl  = null
    redirectedUrl = /hello
    request attribute name = null
    body          = ""
2) InternalResourceViewResolver with no prefix and no suffix
  GET /hello -> ServletException: Circular view path [hello]: would dispatch back to the current handler URL [/hello] again. Check your ViewResolver setup! (Hint: This may be the result of an unspecified view, due to default view name generation.)

/hello에서 뷰 이름 hello가 /WEB-INF/views/hello.jsp로 forward됐고 모델의 name이 요청 속성에 들어 있습니다. 본문이 빈 것은 가짜 환경이라 JSP가 실제로 실행되지 않기 때문입니다.

InternalResourceViewResolver 의 부모 UrlBasedViewResolver.createView(viewName) 가 뷰 이름을 세 갈래로 나눈다. redirect: 로 시작하면 나머지를 주소로 하는 RedirectView — 예제 3의 GET /old 에서 redirect:/hello 가 redirectedUrl = /hello 가 됐다. forward: 로 시작하면 접두사·접미사를 붙이지 않은 InternalResourceView. 그 밖의 이름은 buildView 가 접두사 + 이름 + 접미사를 주소로 삼는다 — GET /hello 에서 hello 가 forwardedUrl = /WEB-INF/views/hello.jsp 가 됐고, InternalResourceView.render 가 모델을 요청 속성으로 옮긴 뒤(name = namalzi) forward 한다. 접두사·접미사가 없으면 주소 hello 가 요청 주소 /hello 와 같아져 예제 3의 2)처럼 ServletException 'Circular view path [hello]' 가 난다 뷰 이름 하나가 어느 주소로 가는지 정해지는 세 갈래입니다. 주소와 예외는 위 예제 3의 실제 출력입니다.

@ResponseBody는 왜 뷰를 타지 않나

/hello와 /api/hello는 똑같이 문자열 "hello"를 돌려주는데 한쪽은 뷰 이름이 되고 한쪽은 본문이 됐습니다. 갈림길은 22편에서 본 반환값 핸들러입니다. @ResponseBody를 맡는 RequestResponseBodyMethodProcessor는 반환값을 메시지 컨버터로 응답에 쓰기에 앞서 첫 줄에서 mavContainer.setRequestHandled(true)를 부릅니다(194–199행). "이 요청의 응답은 내가 처리했다"는 표시입니다. 핸들러 어댑터는 메서드 호출이 끝난 뒤 이 표시가 있으면 ModelAndView 대신 null 을 돌려줍니다(RequestMappingHandlerAdapter 1041–1047행). 그러면 processDispatchResult()의 if (mv != null && …)가 거짓이 되어 render()가 불리지 않습니다. 예제 출력의 "(no ModelAndView)"과 예제 1의 postHandle … (mv=null)이 같은 null입니다. 반환값 핸들러의 자세한 내용은 22편에 있습니다.

헷갈리기 쉬운 곳

이 글에서 뺀 것

이름과 위치만 적어 둡니다. 요청의 로케일(언어·지역)을 정하는 LocaleResolver와 그 구현들(web.servlet.i18n 패키지, 기본값은 AcceptHeaderLocaleResolver), 요청 파라미터로 로케일을 바꾸는 LocaleChangeInterceptor. render()의 첫 줄이 이 로케일을 응답에 적습니다(1268–1271행). redirect 너머로 값을 한 번 전달하는 플래시 맵을 맡는 FlashMapManager와 SessionFlashMapManager(web.servlet.support 패키지), 그리고 redirect 응답을 만드는 RedirectView. 뷰 이름이 없을 때 요청 주소로 이름을 만드는 RequestToViewNameTranslator. 비동기 요청에서 postHandle·afterCompletion 대신 불리는 AsyncHandlerInterceptor.afterConcurrentHandlingStarted(HandlerExecutionChain 186–200행). 예외 클래스 이름을 뷰 이름에 대응시키는 옛 방식의 SimpleMappingExceptionResolver. 리졸버가 특정 핸들러에만 적용되도록 거르는 shouldApplyTo(AbstractHandlerExceptionResolver 206행부터). @EnableWebMvc가 뷰 리졸버들을 묶어 등록하는 ViewResolverComposite(WebMvcConfigurationSupport 1109–1134행). 한 응답에 뷰 조각 여러 개를 그리는 SmartView·FragmentsRendering(DispatcherServlet 1292–1294행). 그리고 JSP가 아닌 뷰 기술들(web.servlet.view 아래의 freemarker, groovy, script, json, xml 등의 패키지)이 있습니다.

정리

개념 한 줄 요약
HandlerInterceptor 핸들러 앞(preHandle)·뒤(postHandle)·끝(afterCompletion)에 불리는 메서드 셋. 다음을 감싸지 않고 따로따로 불린다
HandlerExecutionChain 핸들러 + 이 요청에 적용할 인터셉터 목록. 요청마다 새로 만든다
interceptorIndex preHandle이 true를 돌려준 마지막 번호. afterCompletion은 여기서부터 거꾸로, 통과한 것만
MappedInterceptor 인터셉터 + 경로 조건. 맞을 때만 안쪽 인터셉터가 체인에 들어간다
HandlerExceptionResolver 예외를 받아 null(내 것 아님), 빈 ModelAndView(응답까지 썼음), 뷰 있는 ModelAndView(이 화면을 그려라) 가운데 하나로 답한다
기본 리졸버 셋의 순서 @ExceptionHandler → @ResponseStatus → Spring MVC 표준 예외. @EnableWebMvc에서는 HandlerExceptionResolverComposite 하나에 담긴다
ExceptionHandlerExceptionResolver 컨트롤러 자신에서 먼저 찾고, 없으면 @ControllerAdvice 빈을 정렬 순서로 돈다. 찾은 메서드는 컨트롤러 메서드와 같은 방식으로 부른다
ExceptionHandlerMethodResolver 한 클래스의 "(예외 타입, 미디어 타입) → 메서드" 표. 후보가 여럿이면 상속 거리가 가장 가까운 것, 없으면 원인 예외로 다시. 결과는 캐시
ResponseEntityExceptionHandler 표준 예외를 ProblemDetail 본문으로 바꿔 주는 @ControllerAdvice용 부모 클래스
ViewResolver 뷰 이름 → 뷰 객체. 모르면 null로 다음에게 넘긴다
InternalResourceViewResolver 접두사 + 이름 + 접미사. redirect:·forward: 접두사를 알아본다. 항상 답하므로 맨 끝에 둔다
InternalResourceView 모델을 요청 속성으로 옮기고 RequestDispatcher.forward
@ResponseBody와 뷰 반환값 핸들러가 setRequestHandled(true) → 어댑터가 null 반환 → render()를 건너뛴다

다음에 볼 것

이 글로 서블릿 위의 Spring MVC, 곧 21편의 DispatcherServlet, 22편의 핸들러 매핑과 어댑터, 그리고 이 글의 인터셉터·예외·뷰까지를 다 봤습니다. 세 글 내내 같은 모양이 되풀이됐습니다. DispatcherServlet은 순서만 쥐고, 일은 목록에 든 전략 객체들에게 차례로 물어서 시킵니다.

다음 글은 같은 문제를 전혀 다른 바탕 위에서 푸는 WebFlux입니다. 요청 하나에 스레드 하나를 붙여 두는 서블릿 모델 대신 적은 수의 스레드로 많은 요청을 번갈아 처리하는 리액티브 모델이고, DispatcherServlet의 자리에 DispatcherHandler가 있습니다. 핸들러 매핑·어댑터·결과 처리라는 구조는 닮았지만 모든 것이 Mono를 돌려주는 세계에서 그 구조가 어떻게 바뀌는지, 어노테이션 없이 함수로 경로를 적는 방식과 WebClient까지 봅니다.

다른 글