[Spring] 기본 예외 처리와 Custom 예외 처리 함께 사용하기
일정 관리 앱 과제를 수행하면서 예외 처리를 커스텀 하는 법에 대해서 공부했다.
짧게 다시 복기해보자면 !
커스텀 예외 처리 방법
가장 명시적으로 예외 처리는 `try-catch` 문으로 할 수 있다. 그렇지만 예외가 발생할 수 있는 모든 부분에 `try-catch` 문을 덕지덕지 붙일 수는 없다 .. (아무도 보고 싶지 않아하는 누더기 같은 코드가 될 수 있음)
Spring에서는 어플리케이션을 실행하는 동안 특정 예외가 발생할 경우 개발자가 등록한 특정 메서드를 실행시켜 주는 기능을 제공한다. (🙇🏻♀️🙇🏻♀️)
해당 메서드에는 `@ExceptionHandler` 어노테이션을 달아주면 된다.
이 `@ExceptionHandler` 어노테이션안에 담당할 예외 클래스들을 명시해준다.
@ExceptionHandler(NullPointerException.class)
public ResponseEntity<ErrorResponse> customExceptionHanlder(NullPointerException ex) {
// 원하는 예외 처리
}
위와 같은 handler 메서드들을 각 Controller에 각각 넣어줄 수도 있지만, 모든 Controller에서 같은 예외들을 공통적으로 처리하고자 한다면 handler 메서드들을 모두 같은 클래스 내에 넣어서 관리할 수 있다.
이 때 이 클래스에 `@ControllerAdvice`나 `@RestControllerAdvice` 어노테이션을 달아주면 된다.
자세한 내용은 공식 문서를 참고하자
Controller Advice :: Spring Framework
@ExceptionHandler, @InitBinder, and @ModelAttribute methods apply only to the @Controller class, or class hierarchy, in which they are declared. If, instead, they are declared in an @ControllerAdvice or @RestControllerAdvice class, then they apply to any c
docs.spring.io
내가 만든 예외 처리 클래스 : TodoExceptionHandler
아래는 내가 만든 예외 처리 클래스이다!
package com.example.todo.exception;
import ...
@Slf4j
@ControllerAdvice
public class TodoExceptionHandler {
@ExceptionHandler(TodoException.class)
public ResponseEntity<ErrorResponse> handleTodoException(TodoException e) {
log.debug("Todo Custom Exception [statusCode = {}, errorMessage = {}, cause = {}]",
e.getHttpStatus(), e.getMessage(), e.getStackTrace());
return ResponseEntity.status(e.getHttpStatus())
.body(ErrorResponse.from(e));
}
@ExceptionHandler(MethodArgumentNotValidException.class) /* @Valid 검증 오류 */
public ResponseEntity<ErrorResponse> handleRequestValidationException(
MethodArgumentNotValidException e,
HttpServletRequest request
) {
ExceptionType exceptionType = ExceptionType.REQUEST_VALIDATION_FAILED;
List<String> errors = e.getBindingResult().getFieldErrors().stream()
.map(error -> "["+error.getField()+"] " + error.getDefaultMessage())
.toList();
log.debug("Validation Exception [uri = { }, errorMessage = {}, cause = {}]",
request.getRequestURI(), e.getMessage(), e.getCause());
return ResponseEntity.status(exceptionType.getHttpStatus())
.body(new ErrorResponse(exceptionType, String.join(", ",errors)));
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleException(Exception e) {
log.error("Server exception [errorMessage = {}, cause = {}]",
e.getMessage(), e.getCause());
return ResponseEntity.internalServerError()
.body(new ErrorResponse(ExceptionType.INTERNAL_SERVER_ERROR,
ExceptionType.INTERNAL_SERVER_ERROR.getMessage()));
}
}
세 개의 메서드로 이루어져 있는 것을 확인할 수 있다.
- 커스텀 예외인 `TodoException`
- Request의 validation 검증 실패의 경우 발생하는 `MethodArgumentNotValidException`
- 기타 알 수 없는 에러 `Exception`
으로 나누어서 예외 처리를 해주고 있다.
`TodoException`, `MethodArgumentNotValidException`는 비즈니스 로직 상 발생하는 예외들이고,
기타 예방할 수 없는 에러 원인들이 client에게 직접적으로 전달되지 않도록 `Exception` 클래스를 잡아서 핸들링 해주었다.
에러가 발생하면 다음과 같이 응답한다.
{
"exceptionType": "PASSWORD_NOT_MATCH",
"message": "비밀번호가 일치하지 않습니다."
}
흠흠 잘 온다.
그런데 API 테스트를 하던 중 문제를 직면했다.
💥 기존에 실행되던 Spring의 기본 예외 처리가 실행되지 않는다
요청 uri에 해당하는 path에 매핑되는 컨트롤러 메서드가 없을 경우 `404 Not Found` 에러 메세지가 응답되어야 한다. (실제 Spring의 기본 예외 처리)
그런데 이젠 아래와 같이 `INTERNAL_SERVER_ERROR`가 응답된다.
나의 Custom 예외 처리 클래스의 Exception.class에 잡혀버려서 내가 직접 명시한 `TodoException`, `MethodArgumentNotValidException`을 제외한 모든 예외들이 `INTERNAL_SERVER_ERROR`로 전부 응답되어 버리고 있는 것이다 🥲
만일 Exception.class를 커스텀 예외 처리하지 않는다면 기존 Spring 예외 처리 메서드가 실행된다.
Java에서 `Exception`는 모든 예외의 부모 클래스로서, 모든 Checked Exception과 Unchecked Exception은 `Exception`을 상속받는다.
📌 즉, Exception.class를 `@ExceptionHandler`로 처리하면, 기타 모든 예외가 해당 메서드에서 처리되어 Spring의 기본 예외 처리 메서드가 실행되지 않는다.

이대로 둔다면 위 예시처럼 client의 request가 잘못된 경우에도 서버에 문제가 있다는 잘못된 에러 메세지가 응답된다.
사실 위 상황말고도 정말 많은 Exception들을 Spring에서 처리해주고 있다.
올바른 에러 메세지를 전달하기 위해서는 자주 등장할만한 예외들을 골라서 작성하거나 (그건 또 어떻게 정할건데?)
기존 Spring이 처리하던 모든 예외들을 명시적으로 잡아서 직접 메서드를 작성해줘야 하는데 ..
시간 비용이 만만찮을 것은 뻔하거니와 이건 아니다.. 라는 직감이 들었다.
이런 고민을 하는 게 내가 처음이 아닐 확률 100%
방법을 찾아보자 🏃🏻♀️➡️
요구사항
- Spring이 기본적으로 제공하는 예외 처리들을 함께 가져가면서,
- 비즈니스 로직에 의한 예외만 커스텀 처리 클래스에 명시하고,
- 기타 모든 Exception들은 INTERNAL_SERVER_ERROR로 응답하기
📂 스프링의 예외 처리 로직 알아보기
기본 로직 (커스텀 X)
커스텀 예외처리 클래스를 지우고 예외를 일으켜보았다.
로그를 통해 기존에는`DefaultHandlerExceptionResolver`가 처리함을 알 수 있다.

.w.s.m.s.DefaultHandlerExceptionResolver : Resolved [org.springframework.web.HttpRequestMethodNotSupportedException: Request method 'DELETE' is not supported]
DefaultHandlerExceptionResolver

찾아보니 이렇게나 많은 Exception들을 처리하고 있다.
@Override
@Nullable
protected ModelAndView doResolveException(
HttpServletRequest request, HttpServletResponse response, @Nullable Object handler, Exception ex) {
try {
// ErrorResponse exceptions that expose HTTP response details
if (ex instanceof ErrorResponse errorResponse) {
ModelAndView mav = null;
if (ex instanceof HttpRequestMethodNotSupportedException theEx) {
mav = handleHttpRequestMethodNotSupported(theEx, request, response, handler);
}
else if (ex instanceof HttpMediaTypeNotSupportedException theEx) {
mav = handleHttpMediaTypeNotSupported(theEx, request, response, handler);
}
... 생략
return (mav != null ? mav :
handleErrorResponse(errorResponse, request, response, handler));
}
// Other, lower level exceptions
if (ex instanceof ConversionNotSupportedException theEx) {
return handleConversionNotSupported(theEx, request, response, handler);
}
else if (ex instanceof TypeMismatchException theEx) {
return handleTypeMismatch(theEx, request, response, handler);
}
... 생략
}
catch (Exception handlerEx) {
if (logger.isWarnEnabled()) {
logger.warn("Failure while trying to resolve exception [" + ex.getClass().getName() + "]", handlerEx);
}
}
return null;
}
각 예외에 대한 메서드를 정의해놓고, `doResolveException`으로 분기하여 해당하는 메서드를 실행한다.
이 클래스의 메서드는 누가 호출하는걸까? `DispatchServlet`이다.
DispatchServlet

`processHandlerException`메서드 내에서 에러를 처리한다.
여러개의 `HandlerExceptionResolver`를 순서대로 확인하여 예외를 처리할 수 있는지 확인하고, 처리 가능한 경우 해당 결과를 반환한다.

Spring MVC의 HandlerExceptionResolver 목록
- ExceptionHandlerExceptionResolver
- ResponseStatusExceptionResolver
- DefaultHandlerExceptionResolver
이 중 하나가 예외를 처리할 수 있으면 해당 로직을 실행하고 종료된다.
(위의 핸들러들이 모두 예외를 처리하지 못하면, Spring Boot의 BasicErrorController가 실행된다고 한다.)
커스텀 예외 처리 로직
다시 `TodoExceptionHandler`를 작성하고 커스텀한 TodoException을 일부러 일으켜 보았다.

`ExceptionHandlerExceptionResolver`에서 핸들링을 하고 있음을 확인할 수 있다.
이는 위의 Spring MVC의 HandlerExceptionResolver 목록에서 첫 번째 원소였던 Resolver이다!
즉, 개발자가 `@ExceptionHandler`, `@ControllAdvice` 어노테이션을 통해 등록한 핸들러 메서드를 먼저 찾고,
예외가 처리되지 않으면 `ResponseStatusException`,` DefaultExceptionResolver` 를 확인하여 처리한다.
그런데 나는 `Exception.class`을 `INTERNAL_SERVER_ERROR`로 처리하고 싶단 말이지 ...
이러면 항상 `ExceptionHandlerExceptionResolver`단에서 예외처리가 끝나기 때문에 `ResponseStatusException`,` DefaultExceptionResolver`를 확인해보지 않는다.
찾아보니 이런 경우 사용할 수 있는 클래스가 역시 있었다.
✅ ResponseEntityExceptionHandler 사용하기
이 클래스는 기존 `DefaultExceptionResolver`에서 하던 예외처리 로직을 구현한 `@ExceptionHandler` 메서드를 가진 클래스이다.
/**
* Handle all exceptions raised within Spring MVC handling of the request.
* @param ex the exception to handle
* @param request the current request
*/
@ExceptionHandler({
HttpRequestMethodNotSupportedException.class,
HttpMediaTypeNotSupportedException.class,
HttpMediaTypeNotAcceptableException.class,
MissingPathVariableException.class,
MissingServletRequestParameterException.class,
MissingServletRequestPartException.class,
ServletRequestBindingException.class,
MethodArgumentNotValidException.class,
HandlerMethodValidationException.class,
NoHandlerFoundException.class,
NoResourceFoundException.class,
AsyncRequestTimeoutException.class,
ErrorResponseException.class,
MaxUploadSizeExceededException.class,
ConversionNotSupportedException.class,
TypeMismatchException.class,
HttpMessageNotReadableException.class,
HttpMessageNotWritableException.class,
MethodValidationException.class,
BindException.class,
AsyncRequestNotUsableException.class
})
@Nullable
public final ResponseEntity<Object> handleException(Exception ex, WebRequest request) throws Exception {
if (ex instanceof HttpRequestMethodNotSupportedException subEx) {
return handleHttpRequestMethodNotSupported(subEx, subEx.getHeaders(), subEx.getStatusCode(), request);
}
else if (ex instanceof HttpMediaTypeNotSupportedException subEx) {
return handleHttpMediaTypeNotSupported(subEx, subEx.getHeaders(), subEx.getStatusCode(), request);
}
else if (ex instanceof HttpMediaTypeNotAcceptableException subEx) {
return handleHttpMediaTypeNotAcceptable(subEx, subEx.getHeaders(), subEx.getStatusCode(), request);
... 생략
즉, 이 클래스를 상속받아서 예외 처리 클래스를 작성하면 (내가 전부 다 작성할 필요없이) 기존의 기본 예외 처리 로직을 함께 할 수 있는 것이다.
커스텀 예외 처리 클래스에 적용하기
🚨 문제 발생
`extends ResponseEntityExceptionHandler` 처리만 해주었더니 아래와 같은 에러가 발생했다.
org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'handlerExceptionResolver' defined in class path resource [org/springframework/boot/autoconfigure/web/servlet/WebMvcAutoConfiguration$EnableWebMvcConfiguration.class]: Failed to instantiate [org.springframework.web.servlet.HandlerExceptionResolver]: Factory method 'handlerExceptionResolver' threw exception with message: Ambiguous @ExceptionHandler method mapped for [ExceptionHandler{exceptionType=org.springframework.web.bind.MethodArgumentNotValidException, mediaType=*/*}]: {public org.springframework.http.ResponseEntity com.example.todo.exception.TodoExceptionHandler.handleRequestValidationException(org.springframework.web.bind.MethodArgumentNotValidException,jakarta.servlet.http.HttpServletRequest), public final org.springframework.http.ResponseEntity org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler.handleException(java.lang.Exception,org.springframework.web.context.request.WebRequest) throws java.lang.Exception}
핵심 라인
Ambiguous @ExceptionHandler method mapped for [ExceptionHandler{exceptionType=org.springframework.web.bind.MethodArgumentNotValidException, mediaType=*/*}]
- `원인`
- @ExceptionHandler(MethodArgumentNotValidException.class)를 TodoExceptionHandler에 직접 정의했음
- ResponseEntityExceptionHandler도 내부적으로 같은 예외(MethodArgumentNotValidException)를 처리하는 메서드를 가짐
- Spring이 어떤 핸들러를 실행해야 할지 모호(Ambiguous)하다는 에러
- `해결`
- 오버라이딩을 통해 하나의 메서드에서 처리하도록 해준다.
☑️ 해결 코드
@Override
public ResponseEntity<Object> handleMethodArgumentNotValid( /* @Valid 검증 오류 */
MethodArgumentNotValidException e,
HttpHeaders headers,
HttpStatusCode status,
WebRequest request
) {
ExceptionType exceptionType = ExceptionType.REQUEST_VALIDATION_FAILED;
List<String> errors = e.getBindingResult().getFieldErrors().stream()
.map(error -> "["+error.getField()+"] " + error.getDefaultMessage())
.toList();
log.debug("Validation Exception [uri = { }, errorMessage = {}, cause = {}]",
request.getContextPath(), e.getMessage(), e.getCause());
return ResponseEntity.status(exceptionType.getHttpStatus())
.body(new ErrorResponse(exceptionType, String.join(", ",errors)));
}
`ResponseEntityExceptionHandler`에서 `MethodArgumentNotValidException`를 처리하는 메서드를 오버라이딩해주니 정상적으로 실행이 되는 것을 확인하였다.

기본 예외 처리와 함께

커스텀 예외처리도 잘 된다!
기존 응답 형식은 아래와 같았는데,
{
"exceptionType": "RESOURCE_NOT_FOUND",
"message": "존재하지 않는 자원에 대한 요청입니다."
}
Spring 기본 응답과 형식을 통일하기 위해 아래와 같이 변경하였다.
{
"type": "RESOURCE_NOT_FOUND",
"detail": "존재하지 않는 자원에 대한 요청입니다."
}
예외 처리 완료 !