공부하기/Spring

[Spring] 기본 예외 처리와 Custom 예외 처리 함께 사용하기

다섯자두 2025. 2. 4. 11:40

일정 관리 앱 과제를 수행하면서 예외 처리를 커스텀 하는 법에 대해서 공부했다.

짧게 다시 복기해보자면 !

커스텀 예외 처리 방법

가장 명시적으로 예외 처리는 `try-catch` 문으로 할 수 있다. 그렇지만 예외가 발생할 수 있는 모든 부분에 `try-catch` 문을 덕지덕지 붙일 수는 없다 .. (아무도 보고 싶지 않아하는 누더기 같은 코드가 될 수 있음)

 

Spring에서는 어플리케이션을 실행하는 동안 특정 예외가 발생할 경우 개발자가 등록한 특정 메서드를 실행시켜 주는 기능을 제공한다. (🙇🏻‍♀️🙇🏻‍♀️)

해당 메서드에는 `@ExceptionHandler` 어노테이션을 달아주면 된다.

 

이 `@ExceptionHandler` 어노테이션안에 담당할 예외 클래스들을 명시해준다.

@ExceptionHandler(NullPointerException.class)
public ResponseEntity<ErrorResponse> customExceptionHanlder(NullPointerException ex) {
	// 원하는 예외 처리
}

 

위와 같은 handler 메서드들을 각 Controller에 각각 넣어줄 수도 있지만, 모든 Controller에서 같은 예외들을 공통적으로 처리하고자 한다면 handler 메서드들을 모두 같은 클래스 내에 넣어서 관리할 수 있다.

이 때 이 클래스에 `@ControllerAdvice`나 `@RestControllerAdvice` 어노테이션을 달아주면 된다.

 

자세한 내용은 공식 문서를 참고하자

https://docs.spring.io/spring-framework/reference/web/webmvc/mvc-controller/ann-advice.html#page-title

 

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의 기본 예외 처리 메서드가 실행되지 않는다.

실제 DELETE /api/todo/all 에 해당하는 controller 메서드가 없는 상황

 

이대로 둔다면 위 예시처럼 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 목록

  1. ExceptionHandlerExceptionResolver
  2. ResponseStatusExceptionResolver
  3. 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": "존재하지 않는 자원에 대한 요청입니다."
}

예외 처리 완료 !