Spring MVC 예외 처리 내부 구조
Spring을 사용하다 보면 자연스럽게 이런 코드를 작성하게 된다.
@RestController
public class UserController {
@GetMapping("/users/{id}")
public User getUser(@PathVariable Long id) {
throw new RuntimeException("에러 발생");
}
}
실행하면
{
"timestamp": "...",
"status": 500,
"error": "Internal Server Error"
}
응답이 나온다.
그런데 의문이 생긴다.
예외를 잡지도 않았는데
Spring은 어떻게 에러 응답을 만드는 걸까?
이번 글에서는
- DispatcherServlet 내부 흐름
- Exception 발생 과정
- @ExceptionHandler
- @ControllerAdvice
- HandlerExceptionResolver
- Spring Boot 기본 예외 처리
까지 실제 동작 원리 중심으로 알아본다.
웹 요청이 들어오는 순간
예를 들어
GET /users/1
요청이 들어왔다고 가정해보자.
Spring MVC 내부에서는
클라이언트
↓
DispatcherServlet
↓
HandlerMapping
↓
Controller
↓
Response
흐름이 진행된다.
실제 코드
@GetMapping("/users/{id}")
public User getUser(
@PathVariable Long id
) {
return service.findById(id);
}
DispatcherServlet이
service.findById()
까지 실행시킨다.
예외 발생
만약
public User findById(Long id) {
throw new RuntimeException();
}
발생하면
호출 스택은
UserService
↓
UserController
↓
DispatcherServlet
순으로 전파된다.
결국
DispatcherServlet까지 예외가 올라온다.
DispatcherServlet은 try-catch를 가지고 있다
많은 사람들이 모르는 부분
Spring MVC 핵심 진입점인
DispatcherServlet
내부에는
실제로 이런 구조가 있다.
try {
handler.execute();
}
catch(Exception ex) {
processHandlerException();
}
(실제 구현은 훨씬 복잡)
즉
예외가 발생하면
DispatcherServlet이 잡는다.
그 다음은?
DispatcherServlet은
직접 예외를 처리하지 않는다.
대신
HandlerExceptionResolver
들에게 처리 요청을 보낸다.
구조
Exception 발생
↓
DispatcherServlet
↓
HandlerExceptionResolver
↓
적절한 응답 생성
이게 Spring MVC 예외 처리의 핵심이다.
HandlerExceptionResolver
인터페이스
public interface HandlerExceptionResolver {
ModelAndView resolveException(
HttpServletRequest request,
HttpServletResponse response,
Object handler,
Exception ex
);
}
역할
예외 발견
↓
누가 처리 가능한지 확인
↓
응답 생성
Spring 기본 Resolver
Spring Boot 시작 시
기본적으로 여러 Resolver가 등록된다.
대표적으로
ExceptionHandlerExceptionResolver
ResponseStatusExceptionResolver
DefaultHandlerExceptionResolver
순서대로 검사한다.
ExceptionHandlerExceptionResolver
가장 많이 사용하는 Resolver
예시
@RestController
public class UserController {
@ExceptionHandler(RuntimeException.class)
public String handle(RuntimeException e) {
return "예외 처리";
}
}
예외 발생
throw new RuntimeException();
Resolver가
Reflection으로
@ExceptionHandler
메서드를 찾는다.
찾으면
handle()
실행
결과
예외 처리
응답
내부 동작
실제로는
예외 발생
↓
ExceptionHandlerExceptionResolver
↓
@Controller 내부 검색
↓
@ExceptionHandler 발견
↓
Reflection 호출
↓
응답 반환
과정이다.
여기서도 결국
Reflection이 등장한다.
@ControllerAdvice
실무에서는
Controller마다
예외 처리 안 한다.
예시
@RestController
public class UserController {
}
@RestController
public class OrderController {
}
@RestController
public class ProductController {
}
각각
@ExceptionHandler
를 넣으면
중복이 심하다.
그래서 등장한 것이
@ControllerAdvice
이다.
전역 예외 처리
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(RuntimeException.class)
public String handle(RuntimeException e) {
return "전역 처리";
}
}
이제
모든 Controller에서
RuntimeException
발생 시
여기로 온다.
구조
Controller
↓
Exception 발생
↓
DispatcherServlet
↓
ExceptionResolver
↓
@ControllerAdvice
↓
응답 생성
실무 방식
보통은
예외 객체를 만든다.
예시
public class UserNotFoundException
extends RuntimeException {
}
서비스
throw new UserNotFoundException();
전역 처리
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(
UserNotFoundException.class
)
public ErrorResponse handle(
UserNotFoundException e
) {
return new ErrorResponse(
"USER_NOT_FOUND"
);
}
}
응답
{
"code": "USER_NOT_FOUND"
}
실무에서는 거의 이런 형태다.
@ResponseStatus
간단한 방법
@ResponseStatus(HttpStatus.NOT_FOUND)
public class UserNotFoundException
extends RuntimeException {
}
예외 발생
throw new UserNotFoundException();
자동으로
404 NOT FOUND
응답
내부적으로
ResponseStatusExceptionResolver
가 처리한다.
ResponseStatusException
동적으로 처리 가능
throw new ResponseStatusException(
HttpStatus.NOT_FOUND,
"회원 없음"
);
결과
404 NOT FOUND
실무에서는
커스텀 예외 + ControllerAdvice가 더 많이 사용된다.
DefaultHandlerExceptionResolver
Spring MVC 자체 예외 처리
예시
@GetMapping("/users/{id}")
호출
/users/abc
하지만
Long id
로 변환 실패
예외 발생
MethodArgumentTypeMismatchException
Resolver가
400 BAD REQUEST
응답 생성
개발자가 처리 안 해도 된다.
Spring Boot 기본 예외 처리
만약
어떤 Resolver도 처리하지 못하면?
마지막으로
BasicErrorController
가 동작한다.
바로 우리가 자주 보는
{
"timestamp": "...",
"status": 500,
"error": "Internal Server Error"
}
응답이다.
흐름
예외 발생
↓
Resolver 전부 실패
↓
BasicErrorController
↓
500 응답
실무에서 가장 많이 쓰는 패턴
예외
public class BusinessException
extends RuntimeException {
private final ErrorCode errorCode;
}
에러 코드
public enum ErrorCode {
USER_NOT_FOUND,
INVALID_REQUEST,
DUPLICATE_USER
}
전역 처리
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(
BusinessException.class
)
public ResponseEntity<ErrorResponse>
handle(BusinessException e) {
return ResponseEntity
.badRequest()
.body(
new ErrorResponse(
e.getErrorCode()
)
);
}
}
실무 프로젝트 대부분이 이런 구조다.
DispatcherServlet 기준 전체 흐름
최종적으로
HTTP 요청
↓
DispatcherServlet
↓
Controller
↓
Service
↓
Exception 발생
↓
DispatcherServlet
↓
HandlerExceptionResolver
↓
@ExceptionHandler
또는
@ControllerAdvice
↓
Response 생성
↓
HTTP 응답
지금까지의 연결
이번 시리즈에서 배운 것들이 전부 연결된다.
Annotation
↓
Reflection
↓
Spring Bean 생성
↓
Proxy 생성
↓
DispatcherServlet 호출
↓
ExceptionResolver
↓
@ExceptionHandler 실행
즉
@RestControllerAdvice
@ExceptionHandler
도 결국
Spring이 Reflection으로 찾고 실행하는 구조다.
정리
Spring MVC의 예외 처리는
try-catch
가 아니라
DispatcherServlet
↓
HandlerExceptionResolver
구조 위에서 동작한다.
실무에서는
커스텀 예외
↓
@ControllerAdvice
↓
공통 ErrorResponse
패턴을 거의 표준처럼 사용한다.
그리고 이 구조를 이해하면
Spring Security 예외 처리
Spring Validation 예외 처리
Spring Transaction Rollback
까지 훨씬 쉽게 이해할 수 있다.
다음 글부터는 11. 실무 성능 최적화 카테고리로 넘어간다.
첫 번째 주제는 객체 생성 비용 줄이기(Object Creation Cost) 이다. 왜 new를 많이 한다고 무조건 느려지는 것은 아닌지, JVM이 객체 생성을 실제로 어떻게 최적화하는지부터 시작해본다.
'language > java' 카테고리의 다른 글
| Escape Analysis 재방문 (Scalar Replacement, Lock Elision) (0) | 2026.06.29 |
|---|---|
| 객체 생성 비용 줄이기 (Object Creation Cost) (0) | 2026.06.29 |
| Annotation 기반 동작 원리 (0) | 2026.06.24 |
| ExecutorService와 @Async (0) | 2026.06.24 |
| ThreadLocal과 Spring Security (0) | 2026.06.24 |
댓글