들어가며
오늘은 우리가 Spring Framework로 애플리케이션을 만들면서 정말 자주 사용하는 '예외 처리'에 대해 이야기해보려고 합니다.
아마 많은 분들이 @ExceptionHandler나 @RestControllerAdvice를 사용해 컨트롤러에서 발생하는 예외를 처리해 본 경험이 있을 겁니다. 그런데 혹시 이런 궁금증을 가져본 적 없으신가요?
- "내가 만든 핸들러가 없는 예외는 대체 어디까지 날아가는 걸까?"
- "Spring은 수많은 컨트롤러와 메서드 중에서 어떻게 정확히 이 예외를 처리할
@ExceptionHandler를 찾아내는 걸까?"
Spring Framework의 소스 코드를 직접 따라가며, 예외 하나가 어떤 여정을 거쳐 처리되는지 함께 알아보도록 하겠습니다. 이 글을 다 읽고 나면, 여러분의 예외 처리 코드가 단순한 '마법'이 아니라, 잘 설계된 메커니즘 위에서 동작하고 있다는 사실을 깨닫게 될 겁니다.
🍰 이론편: Spring 예외 처리
들어가기 앞서, Spring이 제공하는 대표적인 예외 처리 방식들을 간단히 짚고 넘어가겠습니다.
@ResponseStatus: 예외 클래스에 붙여서, 해당 예외가 발생하면 특정 HTTP 상태 코드를 응답하도록 지정합니다.ResponseStatusException:@ResponseStatus와 비슷하지만, 예외를 직접 만들어서 던지는 시점에 상태 코드와 메시지를 동적으로 지정할 수 있습니다.@ExceptionHandler: 특정 컨트롤러 내에서 발생하는 예외를 처리하는 메서드를 지정합니다.@ControllerAdvice/@RestControllerAdvice: 여러 컨트롤러에 걸쳐 전역적으로 예외를 처리하는 로직을 모아둘 수 있습니다.
이 글에서는 가장 많이 사용되고, 동작 원리의 핵심을 담고 있는 @ExceptionHandler와 @ControllerAdvice에 집중할 겁니다. 잠시 후 코드를 보면 알게 되겠지만, 이 둘은 사실상 ExceptionHandlerExceptionResolver 라는 같은 해결사에 의해 처리됩니다.
설명을 위해, 우리만의 비즈니스 예외 클래스를 하나 정의하고 시작하겠습니다.
public class BusinessException extends RuntimeException {
public BusinessException(String message) {
super(message);
}
}
환경
- JDK 17
- Spring Framework 6.1.2

📕 1부: 아무도 처리하지 않은 예외
만약 우리가 컨트롤러에서 예외를 던지기만 하고 아무런 처리도 하지 않는다면 어떻게 될까요? 결론부터 말하면, 예외는 Spring의 손을 떠나 최종적으로 서블릿 컨테이너인 Tomcat에게 넘겨집니다. 그리고 Tomcat은 우리에게 익숙한 'Whitelabel Error Page'를 보여주게 되죠. 그 과정을 한 단계씩 따라가 보겠습니다.
아래와 같이 BusinessException을 무조건 던지는 간단한 컨트롤러가 있다고 가정해 봅시다.
@GetMapping("/item")
public ResponseEntity<String> getItem() {
if(true)
throw new BusinessException("Item is not existing");
return ResponseEntity.ok(birdService.getBird());
}
Step 1: 컨트롤러 메서드 호출과 예외 발생 (InvocableHandlerMethod)
Spring MVC의 심장부인 DispatcherServlet은 요청을 처리할 컨트롤러 메서드를 찾은 뒤, InvocableHandlerMethod라는 클래스를 통해 해당 메서드를 실행합니다. 여기서 중요한 점은, Java의 리플렉션 기술을 사용해 메서드를 호출한다는 것입니다.
리플렉션으로 메서드를 호출할 때 발생하는 예외는 InvocationTargetException으로 한 번 감싸여서(wrapping) 던져집니다. InvocableHandlerMethod의 doInvoke 메서드 내부를 살펴보면 이 과정을 명확히 볼 수 있습니다.
protected Object doInvoke(Object... args) throws Exception {
try {
return method.invoke(getBean(), args);
} catch (InvocationTargetException ex) {
//래핑을 벗겨서, BusinessException 이 드러난다.
Throwable targetException = ex.getCause();
//이 부분이 실행된다.
if (targetException instanceof RuntimeException runtimeException) {
throw runtimeException;
}
else if (targetException instanceof Error error) {
throw error;
}
else if (targetException instanceof Exception exception) {
throw exception;
}
else {
throw new IllegalStateException(formatInvokeError("Invocation failure", args), targetException);
}
}
}
catch (InvocationTargetException ex) 블록을 보세요. 여기서 ex.getCause()를 통해 래핑된 원래 예외, 즉 우리의 BusinessException을 꺼내 다시 던져줍니다. 이제 예외는 호출 스택을 따라 위로 전파되기 시작합니다.
Step 2: DispatcherServlet의 예외 감지
예외는 이제 Spring MVC의 중앙 관제 센터, DispatcherServlet으로 돌아옵니다. doDispatch 메서드는 요청 처리의 전 과정을 조율하는데, 여기서 예외를 try-catch로 잡습니다.
protected void doDispatch(HttpServletRequest request, HttpServletResponse response) throws Exception {
try {
HandlerAdapter ha = getHandlerAdapter(mappedHandler.getHandler());
mv = ha.handle(processedRequest, response, mappedHandler.getHandler());
} catch (Exception ex) {
dispatchException = ex;
}
processDispatchResult(processedRequest, response, mappedHandler, mv, dispatchException);
}
ha.handle() (핸들러 어댑터가 컨트롤러를 실행하는 부분)에서 BusinessException이 터지면, catch 블록에서 dispatchException 변수에 이 예외가 담깁니다. 그리고 이 변수는 processDispatchResult 메서드로 전달됩니다.
processDispatchResult는 예외 처리의 첫 번째 분기점입니다. 만약 우리가 @ExceptionHandler 같은 처리기를 등록해뒀다면 여기서 예외가 처리되고 여정은 끝납니다. (이 부분은 2부에서 자세히 다룹니다.)
하지만 지금은 아무 처리기가 없으므로, processDispatchResult 메서드는 BusinessException을 그대로 다시 던져버립니다.
Step 3: 서블릿 컨테이너를 향하여
DispatcherServlet이 던진 예외는 그 부모 클래스인 FrameworkServlet으로 넘어갑니다.
protected final void processRequest(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
try {
doService(request, response);
}
// 이 부분이 실행
catch (Throwable ex) {
failureCause = ex;
throw new ServletException("Request processing failed: " + ex, ex);
}
}
여기서 우리의 BusinessException은 catch (Throwable ex) 블록에 걸립니다. 그리고 서블릿 스펙에 맞는 ServletException으로 다시 한번 감싸여 던져집니다. 왜냐하면 이제부터는 Spring의 영역을 벗어나, 서블릿 컨테이너(Tomcat)가 이해할 수 있는 방식으로 소통해야 하기 때문입니다.
Step 4: Tomcat의 예외 접수 (StandardWrapperValve)
이제 예외는 드디어 Tomcat의 영역으로 들어왔습니다. Tomcat의 파이프라인에는 Valve라는 처리기들이 체인 형태로 존재하는데, 그 중 StandardWrapperValve가 이 예외를 받습니다.
public void invoke(Request request, Response response) throws IOException, ServletException {
try {
filterChain.doFilter(request.getRequest(), response.getResponse());
} catch (ServletException e) {
exception(request, response, e);
}
}
doFilter를 실행하다가 ServletException이 날아오자, catch 블록의 exception() 메서드를 호출합니다. 이 메서드 내부를 보면 흥미로운 일이 일어납니다.
private void exception(Request request, Response response, Throwable exception, int errorCode) {
request.setAttribute(RequestDispatcher.ERROR_EXCEPTION, exception);
response.setStatus(errorCode);
response.setError();
}
여기서는 예외를 다시 던지는 대신, request 객체에 예외 정보를 속성으로 저장하고(setAttribute), 응답 상태 코드를 500 (Internal Server Error)으로 설정합니다. 예외 전파는 여기서 일단 멈춥니다.
Step 5: 에러 페이지로의 안내 (StandardHostValve)
예외 전파는 멈췄지만, 아직 클라이언트에게 보여줄 에러 페이지를 결정해야 합니다. 이 역할은 다음 Valve인 StandardHostValve가 담당합니다.
public void invoke(Request request, Response response) throws IOException, ServletException {
context.getPipeline().getFirst().invoke(request, response);
Throwable t = (Throwable) request.getAttribute(RequestDispatcher.ERROR_EXCEPTION);
if (response.isErrorReportRequired()) {
if (t != null) {
throwable(request, response, t);
}
}
}
이 코드는 앞선 StandardWrapperValve에서 request에 저장해 둔 ERROR_EXCEPTION 속성을 다시 꺼내옵니다. 만약 값이 존재한다면, throwable() 메서드를 호출해 적절한 에러 페이지를 찾아 포워딩(forwarding) 해줍니다. Spring Boot 환경에서는 기본적으로 /error 경로로 포워딩되며, 이 요청은 Spring Boot의 BasicErrorController가 처리하여 우리에게 익숙한 흰색 바탕의 에러 페이지를 보여주게 됩니다.
지금까지의 여정을 요약하면, 처리되지 않은 예외는 Spring MVC → FrameworkServlet → Tomcat의 Valve 파이프라인을 거치며 전파되고, 최종적으로 Tomcat에 의해 에러 페이지로 포워딩됩니다.
🗿 2부: @ExceptionHandler는 어떻게 예외를 처리할까?
자, 이제 이 긴 여정을 단축시키는 @ExceptionHandler를 사용해 보겠습니다. 컨트롤러를 다음과 같이 수정합니다.
public class ItemController {
@GetMapping("/item")
public ResponseEntity<String> getItem() {
if(true)
throw new BusinessException("Item is not existing");
return ResponseEntity.ok(birdService.getBird());
}
@ExceptionHandler(BusinessException.class)
public ResponseEntity<String> handleBusiness(BusinessException e) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(e.getMessage());
}
}
이제 예외가 발생하면 어떤 일이 벌어질까요? Step 1~2까지의 과정은 동일합니다. BusinessException이 발생하여 DispatcherServlet의 doDispatch 메서드까지 전달되고, dispatchException 변수에 담깁니다.
처리의 갈림길: processHandlerException
진정한 차이는 processDispatchResult 메서드 내부에서 시작됩니다. 이 메서드는 processHandlerException 메서드를 호출합니다. 바로 여기가 우리가 기다리던 핵심입니다.
protected ModelAndView processHandlerException(HttpServletRequest request, HttpServletResponse response,
@Nullable Object handler, Exception ex) throws Exception {
for (HandlerExceptionResolver resolver : this.handlerExceptionResolvers) {
exMv = resolver.resolveException(request, response, handler, ex);
if (exMv != null) {
break;
}
}
if (exMv != null) {
return exMv;
}
throw ex;
}
DispatcherServlet은 handlerExceptionResolvers라는 리스트를 가지고 있습니다. 이것은 예외를 처리할 수 있는 '해결사'들의 목록입니다. Spring은 이 해결사들을 하나씩 순회하며 "이 예외, 혹시 처리할 수 있니?"라고 물어봅니다. 만약 해결사 중 누군가가 예외를 처리하고 ModelAndView 객체(또는 null이 아닌 값)를 반환하면, 더 이상 예외를 던지지 않고 정상적으로 처리를 마칩니다.
해결사들의 등장: HandlerExceptionResolverComposite
handlerExceptionResolvers 리스트에는 기본적으로 몇몇 해결사들이 등록되어 있습니다. 그중 우리의 주인공은 ExceptionHandlerExceptionResolver입니다. 실제로는 HandlerExceptionResolverComposite 라는 복합 해결사 안에 여러 해결사들이 포함된 구조이지만, 간단하게 아래와 같은 해결사들이 있다고 이해하면 좋습니다.
1. ExceptionHandlerExceptionResolver: @ExceptionHandler와 @ControllerAdvice를 처리하는 핵심 해결사.
2. ResponseStatusExceptionResolver: ResponseStatusException 예외와 @ResponseStatus 어노테이션을 처리.
3. DefaultHandlerExceptionResolver: 몇몇 표준 Spring 예외를 HTTP 상태 코드로 변환.
우리의 BusinessException은 ExceptionHandlerExceptionResolver에 의해 처리될 것입니다.
최종 해결사: ExceptionHandlerExceptionResolver
이 해결사의 resolveException 메서드는 내부적으로 doResolveHandlerMethodException이라는 메서드를 호출합니다. 이 메서드의 로직이 바로 마법의 실체입니다.
protected ModelAndView doResolveHandlerMethodException(HttpServletRequest request, HttpServletResponse response, @Nullable HandlerMethod handlerMethod, Exception exception) {
//handle 할 메서드 결정
ServletInvocableHandlerMethod exceptionHandlerMethod = getExceptionHandlerMethod(handlerMethod, exception);
//해당 메서드 실행
exceptionHandlerMethod.invokeAndHandle(webRequest, mavContainer, arguments);
}
코드는 두 단계로 나뉩니다.
1. getExceptionHandlerMethod(): 발생한 예외(exception)를 처리할 수 있는 적절한 @ExceptionHandler 메서드를 찾습니다.
2. invokeAndHandle(): 찾아낸 메서드를 리플렉션으로 실행하고, 그 결과를 ModelAndView로 만들어 반환합니다.
getExceptionHandlerMethod의 탐색 방법
그렇다면 getExceptionHandlerMethod는 어떻게 수많은 메서드 중에서 딱 맞는 핸들러를 찾아낼까요?
protected ServletInvocableHandlerMethod getExceptionHandlerMethod(@Nullable HandlerMethod handlerMethod, Exception exception) {
Class<?> handlerType = null;
// 1. @ExceptionHandler 처리 방식 (in Controller)
if (handlerMethod != null) {
handlerType = handlerMethod.getBeanType();
ExceptionHandlerMethodResolver resolver = this.exceptionHandlerCache.computeIfAbsent(
handlerType, ExceptionHandlerMethodResolver::new);
Method method = resolver.resolveMethod(exception);
if (method != null) {
return new ServletInvocableHandlerMethod(handlerMethod.getBean(), method, this.applicationContext);
}
}
// 2. @ControllerAdvice 처리 방식 (Global)
for (Map.Entry<ControllerAdviceBean, ExceptionHandlerMethodResolver> entry : this.exceptionHandlerAdviceCache.entrySet()) {
ControllerAdviceBean advice = entry.getKey();
if (advice.isApplicableToBeanType(handlerType)) {
ExceptionHandlerMethodResolver resolver = entry.getValue();
Method method = resolver.resolveMethod(exception);
if (method != null) {
return new ServletInvocableHandlerMethod(advice.resolveBean(), method, this.applicationContext);
}
}
}
return null;
}
아까전에 말했듯이, @ExceptionHandler와 @ControllerAdvice 모두 ExceptionHandlerExceptionResolver 이 처리합니다. 위의 코드에 그 부분이 드러나 있습니다.
1. 로컬 @ExceptionHandler 탐색
스프링은 컨트롤러 클래스별로 ExceptionHandlerMethodResolver 클래스를 매핑해 둡니다. 이 클래스는 내부적으로 다음과 같은 Map 자료구조를 가집니다.
private final Map<Class<? extends Throwable>, Method> mappedMethods;
애플리케이션이 시작될 때 Spring은 각 컨트롤러를 스캔하여, 이 맵에 {예외 클래스.class : 처리할 메서드} 형태로 미리 저장해 둡니다. 우리의 경우 {BusinessException.class : handleBusiness()} 가 저장되어 있을 겁니다. resolver.resolveMethod(exception)는 이 맵에서 발생한 예외에 맞는 처리 메서드를 찾아 반환합니다.
2. 전역 @ControllerAdvice 탐색
만약 컨트롤러 내에서 적절한 핸들러를 찾지 못했다면, 전역 핸들러인 @ControllerAdvice가 등록된 빈들을 뒤지기 시작합니다. 로직은 매우 유사합니다. 전역 핸들러인 클래스별로 등록된 ExceptionHandlerExceptionResolver를 뒤지면서, 현재 예외를 처리할 수 있는 메서드가 있는지 찾습니다.
이 과정을 통해 handleBusiness 메서드가 선택되고, invokeAndHandle에 의해 실행되고 예외 처리는 Tomcat에 도달하기 전에 마무리됩니다.
마무리
오늘은 Spring 예외 처리의 내부 동작 원리를 깊숙이 들여다봤습니다. 이제 우리는 다음 사실들을 명확히 이해하게 되었습니다.
- 처리되지 않은 예외의 여정: 사용자가 처리하지 않은 예외는 컨트롤러에서 시작해
DispatcherServlet을 거쳐 서블릿 컨테이너(Tomcat)까지 전파됩니다. Tomcat은 이 예외를 받아 기본 에러 페이지로 포워딩합니다. - 예외 처리의 핵심 분기점:
DispatcherServlet의processHandlerException메서드가 예외 처리의 핵심입니다. 이 메서드는 등록된HandlerExceptionResolver들을 통해 예외를 처리하려고 시도합니다. @ExceptionHandler의 마법:@ExceptionHandler와@ControllerAdvice는 모두ExceptionHandlerExceptionResolver에 의해 처리됩니다. 이 Resolver는 애플리케이션 로딩 시점에 예외 처리 메서드들을 미리 스캔하여 캐시(Map 형태) 해두고, 예외 발생 시 이 캐시를 사용하여 매우 효율적으로 적절한 핸들러를 찾아 실행합니다.
'스프링' 카테고리의 다른 글
| 톰캣 NIOConnector는 어떻게 수많은 요청을 처리할까? (feat. Acceptor, Poller) (0) | 2025.06.02 |
|---|---|
| Hibernate의 객체 매핑 성능 비밀: 내부 동작 원리 파헤치기 (0) | 2025.05.11 |
| JPA의 @Transactional(readOnly = true) 동작 (0) | 2025.05.11 |