이 글에서 다루는 것
"Spring 소스 파헤치기" 시리즈의 스물세 번째 글입니다. 21편에서 요청 하나가 DispatcherServlet.doDispatch()를 지나가는 전체 순서를 봤고, 22편에서 그 한가운데, 곧 @RequestMapping 메서드를 찾아 인자를 채우고 부르는 과정을 봤습니다. 이번 글은 그 가운데를 둘러싼 것들을 봅니다. 컨트롤러 메서드 앞뒤에 끼어드는 인터셉터, 메서드가 예외를 던졌을 때의 처리, 그리고 메서드가 돌려준 뷰 이름이 화면이 되는 마지막 단계입니다. 1편 지도의 Phase 5(웹) 가운데 U21에 해당하고, 이 글로 Spring MVC 쪽(U19–U21)이 끝납니다.
답하려는 질문은 네 가지입니다.
- 인터셉터 셋 가운데 두 번째가 요청을 막으면 누구의
afterCompletion이 불리는가.HandlerExecutionChain은 그것을 어떻게 기억하는가 - 컨트롤러가 던진 예외는 누구에게, 어떤 순서로 넘어가는가 (
HandlerExceptionResolver세 개) @ExceptionHandler메서드가 컨트롤러 안에도 있고@ControllerAdvice에도 있으면 누가 이기는가. 상위 타입용과 하위 타입용이 둘 다 있으면 무엇이 뽑히는가- 컨트롤러가 돌려준 문자열
"hello"는 어떻게/WEB-INF/views/hello.jsp가 되고,@ResponseBody일 때는 왜 이 과정을 타지 않는가
| 항목 | 내용 |
|---|---|
| 기준 소스 | 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행)를 이 글의 관심사만 남겨 다시 그리면 이렇습니다.
파란 상자가 인터셉터(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;
}
눈여겨볼 것이 두 가지입니다.
- 체인은 요청마다 새로 만듭니다(
new HandlerExecutionChain(handler)). 인터셉터 객체들은 싱글톤 빈으로 계속 같이 쓰지만, 그것들을 담은 체인은 요청 하나가 끝나면 버려집니다. 바로 뒤에 나올interceptorIndex라는 상태를 체인의 필드로 둘 수 있는 이유입니다. MappedInterceptor는 경로가 맞을 때만 들어갑니다.MappedInterceptor는 인터셉터 하나에 "어느 경로에 적용할지"를 붙여 둔 포장입니다(62–69행).matches()가 제외 패턴, 제외 HTTP 메서드, 포함 패턴, 포함 HTTP 메서드를 차례로 확인하고(224–270행), 통과하면 체인에는 포장을 벗긴 안쪽 인터셉터가 들어갑니다. 경로 조건이 없는 인터셉터는 그냥 다 들어갑니다.
그러면 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행).
applyPreHandle은 앞에서 뒤로preHandle을 부르고, 하나가true를 돌려줄 때마다 그 번호를interceptorIndex에 적습니다. 처음 값은-1입니다(48행).applyPostHandle은 뒤에서 앞으로 전부 부릅니다.triggerAfterCompletion은interceptorIndex에서 0까지 거꾸로 부릅니다.
노란 상자가 체인이 들고 있는 상태입니다. 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입니다. 아래에서 이유를 봅니다.
헷갈리기 쉬운 곳
postHandle은 예외가 나면 불리지 않습니다.applyPostHandle은doDispatch()의 안쪽try에서 핸들러 호출 다음 줄에 있어서(963–970행), 핸들러가 예외를 던지면 건너뜁니다. 반드시 해야 하는 정리는afterCompletion에 둡니다.afterCompletion의ex가null이어도 예외가 없었다는 뜻은 아닙니다. 3번처럼 예외 리졸버가 예외를 처리했으면null이 넘어옵니다(1058–1061행). 파라미터 설명에도 "예외 리졸버가 처리한 예외는 포함하지 않는다"고 적혀 있습니다(150–151행). 21편의 예제에서는 아무도 처리하지 못한 예외라서ex에 예외가 들어왔습니다.@ResponseBody핸들러에서는postHandle의ModelAndView가null입니다. 1번 출력의mv=null이 그것입니다(@RestController는 모든 메서드에@ResponseBody를 붙인 것과 같습니다). 응답 본문은 핸들러 어댑터 안에서 이미 쓰였고, 그릴 뷰가 없기 때문입니다. 5절 끝에서 다시 봅니다.afterCompletion이 던진 예외는 삼켜집니다.triggerAfterCompletion은 인터셉터마다try/catch (Throwable)로 감싸 로그만 남기고 다음 인터셉터로 갑니다(174–179행). 한 인터셉터의 정리가 실패해도 나머지의 정리는 불려야 하기 때문입니다. 반대로preHandle과postHandle이 던진 예외는 안쪽try안이라 핸들러가 던진 예외와 똑같이 3절의 길로 갑니다.
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());
}
어느 길이든 순서는 같습니다. 우리가 쓴 @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에 싸여 밖으로 나왔습니다.
헷갈리기 쉬운 곳
@ExceptionHandler메서드 안에서 또 예외가 나면 원래 예외로 계속 갑니다. ①은 처리 메서드 호출을try로 감싸고, 실패하면 경고 로그를 남긴 뒤null을 돌려줍니다(466–477행).null은 "내 것이 아니다"이므로 원래 예외가 ②, ③으로 넘어갑니다. 새로 난 예외가 응답이 되는 것이 아닙니다.- 뷰를 그리다 난 예외는 리졸버에게 가지 않습니다.
render()는processDispatchResult()안에서 불리고, 이 메서드는dispatchException을 담는 안쪽try의 바깥에 있습니다(980행). 그래서 렌더링 예외는 바깥catch로 가서afterCompletion만 부르고 그대로 던져집니다. - 리졸버에게 묻기 전에 응답을 조금 치웁니다.
processHandlerException()은 맨 앞에서 응답의Content-Type·Content-Disposition헤더와 본문 버퍼를 비웁니다(1210–1221행). 성공 응답과 오류 응답의 형식이 다를 수 있기 때문이라고 주석에 적혀 있습니다. 응답이 이미 전송되기 시작했으면(IllegalStateException) 그냥 넘어갑니다.
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;
}
노란 상자가 캐시 둘입니다. 왼쪽 초록 상자에 닿으면 오른쪽은 아예 보지 않습니다.
- 컨트롤러 클래스 자신. 예외가 난 핸들러 메서드의 클래스에서
@ExceptionHandler메서드를 찾습니다. 클래스마다ExceptionHandlerMethodResolver를 하나 만들어exceptionHandlerCache에 넣어 두고 다시 씁니다(119–120행). 여기서 찾으면 바로 반환합니다. @ControllerAdvice빈들. 1에서 못 찾았을 때만 옵니다.exceptionHandlerAdviceCache는 리졸버가 초기화될 때 한 번 채워집니다. 컨테이너에서@ControllerAdvice가 붙은 빈을 모두 찾아 순서대로 정렬하고(ControllerAdviceBean.findAnnotatedBeans248–266행),@ExceptionHandler메서드가 있는 것만 넣습니다(303–321행). 맵이LinkedHashMap이라 넣은 순서, 곧 정렬 순서대로 돕니다(122–123행). 어드바이스가 여럿이면@Order가 앞선 것이 먼저 기회를 얻습니다.- 어드바이스마다
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;
}
}
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 블록을 구체적인 예외부터 적는 것과 같은 결과를, 메서드를 적은 순서와 상관없이 얻는 셈입니다.
두 가지를 더 봐 둡니다.
- 결과는 캐시됩니다. 예외 타입마다 표를 훑고 정렬하는 일을 되풀이하지 않도록
lookupCache(항목 24개까지 담는ConcurrentLruCache)에 넣어 둡니다(84–85행, 221–224행). "맞는 메서드가 없다"는 결과도NO_MATCHING_EXCEPTION_HANDLER라는 표시용 값으로 캐시합니다. - 못 찾으면 원인(cause)으로 다시 찾습니다. 위
resolveExceptionMapping의 뒷부분입니다. 던져진 예외의 타입으로 맞는 메서드가 없을 때만getCause()로 내려갑니다.
코드에 보이는 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에 올려 요청을 보낸 결과입니다.
헷갈리기 쉬운 곳
- "가장 가까운 타입"은 한 클래스 안에서만 통합니다.
/local을 보면, 어드바이스에 딱 맞는IllegalStateException용 메서드가 있는데도 컨트롤러 안의RuntimeException용이 이겼습니다. 찾는 순서가 "컨트롤러 → 어드바이스"이고 컨트롤러에서 찾으면 거기서 끝나기 때문입니다. 거리 비교는 그다음, 한 클래스 안의 후보끼리만 합니다. 어드바이스가 여럿일 때도 같습니다. 앞선 어드바이스의Exception용이 뒤 어드바이스의 더 구체적인 메서드보다 먼저 뽑힙니다. - 컨트롤러 안에서는 원인 예외까지 본 다음에야 어드바이스로 넘어갑니다.
/cause가 그 경우입니다. 던져진 것은IllegalStateException이고 어드바이스에 그 타입용 메서드가 있지만, 컨트롤러 자신의 리졸버가 원인인IOException으로 다시 찾아localIo를 뽑았습니다.resolveExceptionMapping한 번의 호출 안에 "타입으로 → 원인으로"가 다 들어 있기 때문입니다. - 원인은 "못 찾았을 때만" 봅니다. 2)의 셋째 줄에서
RuntimeException의 원인이IllegalStateException인데도adviceException이 뽑혔습니다.RuntimeException자체가Exception용 메서드에 맞으므로 원인까지 내려가지 않은 것입니다.Exception용 메서드를 하나 두면 그 클래스에서는 원인으로 찾는 일이 일어나지 않습니다.
부르는 쪽: 컨트롤러 메서드와 같은 기계
찾은 메서드는 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행).
왼쪽 회색 길이 @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행
- 이름이
redirect:로 시작하면 나머지를 주소로 하는RedirectView를,forward:로 시작하면 접두사·접미사를 붙이지 않은InternalResourceView를 만듭니다. 컨트롤러에서return "redirect:/list";가 통하는 것은 뷰 리졸버가 이 접두사를 알아보기 때문입니다. - 그 밖의 이름은
buildView()가 접두사 + 뷰 이름 + 접미사를 주소로 넣은 뷰를 만듭니다. 접두사가/WEB-INF/views/, 접미사가.jsp이면"hello"는/WEB-INF/views/hello.jsp가 됩니다. - 한 번 만든 뷰는 캐시됩니다. 한 단계 위 부모인
AbstractCachingViewResolver가 "뷰 이름 + 로케일"을 열쇠로 맵에 넣어 두고(170–200행), 기본 한도는 1024개입니다(49행). 뷰 객체는 "어느 주소로 넘길지"만 아는 상태 없는 객체라 요청마다 새로 만들 이유가 없습니다.
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행
- 모델을 요청 속성으로 옮깁니다.
model.addAttribute("name", …)으로 넣은 값이request.setAttribute("name", …)이 됩니다(AbstractView430–441행). JSP가${name}으로 값을 읽을 수 있는 까닭입니다. JSP는 Spring의 모델을 모르고 서블릿 요청 속성만 압니다. - 서블릿 컨테이너에게 그 주소의
RequestDispatcher를 받습니다. 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가 실제로 실행되지 않기 때문입니다.
뷰 이름 하나가 어느 주소로 가는지 정해지는 세 갈래입니다. 주소와 예외는 위 예제 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편에 있습니다.
헷갈리기 쉬운 곳
InternalResourceViewResolver는 줄의 맨 끝에 서야 합니다. 이 리졸버는 JSP 파일이 실제로 있는지 미리 알 방법이 없어서 어떤 이름이든 뷰를 만들어 돌려줍니다(존재 확인 메서드checkResource의 기본 구현이 항상true입니다,AbstractUrlBasedView91–93행). 앞에 서면 뒤의 리졸버는 영영 차례가 오지 않습니다. 클래스 주석에도 여러 뷰 리졸버를 이을 때는 이것이 항상 마지막이어야 한다고 적혀 있습니다(37–39행).- "Circular view path". 예제의 2)입니다. 접두사·접미사가 없으면 뷰
hello의 주소가 지금 처리 중인 요청 주소/hello와 같아집니다. 그대로 forward하면 같은 핸들러가 또 불리고 또 forward하는 일이 끝없이 되풀이되므로,InternalResourceView가 forward 직전에 이를 알아채고 예외를 던집니다(201–216행, 이 검사는InternalResourceViewResolver가 뷰를 만들 때 켭니다, 113행). 그리고 이 예외는 뷰를 그리다 난 것이라 3절에서 본 대로 예외 리졸버를 거치지 않고 그대로 밖으로 나옵니다. - 뷰 이름을 정하지 않아도 뷰 이름이 생길 수 있습니다.
ModelAndView는 있는데 뷰가 비어 있으면DispatcherServlet이 요청 주소에서 기본 뷰 이름을 만들어 넣습니다(1009–1016행). 위 오류 메시지 끝의 "Hint"가 이 경우를 가리킵니다.
이 글에서 뺀 것
이름과 위치만 적어 둡니다. 요청의 로케일(언어·지역)을 정하는 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까지 봅니다.
- 다음 글: Spring 소스 파헤치기 24: WebFlux — DispatcherHandler, 함수형 엔드포인트, WebClient
- 앞 글: 22편: 핸들러 매핑과 어댑터 — @RequestMapping은 어떻게 찾고 어떻게 부르나, 21편: DispatcherServlet — 요청 하나가 응답이 되기까지, 20편: spring-web 기반 — HttpMessageConverter, WebApplicationContext, 필터
- 이 글이 기댄 글: 11편: @Aspect는 어떻게 발견되고 어드바이스 체인은 어떻게 돌아가나 (AOP 쪽의 체인), 13편: ResolvableType과 MergedAnnotations (어노테이션 위의 어노테이션)
- 1편: AI와 함께 소스를 읽는 방법과 분석 지도