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

2025. 2. 4. 11:40·공부하기/Spring

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

짧게 다시 복기해보자면 !

커스텀 예외 처리 방법

가장 명시적으로 예외 처리는 `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": "존재하지 않는 자원에 대한 요청입니다."
}

예외 처리 완료 !

저작자표시 비영리 변경금지 (새창열림)

'공부하기 > Spring' 카테고리의 다른 글

[Spring] JPA Cascade에 대해 정리해보자 💭  (0) 2025.03.11
[Spring] 영속성 컨텍스트에 대해 정리해보자  (0) 2025.02.27
[Spring] Interceptor에 대해 알아보자  (0) 2025.02.26
[Spring] LoginCheckFilter 구현 : 로그인/아웃 로직 구현하기  (0) 2025.02.10
[Spring] JDBC 이해하기  (0) 2025.01.24
'공부하기/Spring' 카테고리의 다른 글
  • [Spring] 영속성 컨텍스트에 대해 정리해보자
  • [Spring] Interceptor에 대해 알아보자
  • [Spring] LoginCheckFilter 구현 : 로그인/아웃 로직 구현하기
  • [Spring] JDBC 이해하기
다섯자두
다섯자두
All I need is 💻 , ☕️ and a dash of luck
  • 다섯자두
    subbni
    다섯자두
  • 전체
    오늘
    어제
    • 전체 글 (89)
      • 개발 이야기 (0)
      • 만들어보기 (17)
        • FromBookToBook (5)
        • Spring (5)
        • Node.js & React (3)
        • TroubleShooting (4)
      • 공부하기 (72)
        • Network (3)
        • Cloud (1)
        • Database (5)
        • Java (13)
        • Javascript (0)
        • Spring (9)
        • React (18)
        • Algorithm (8)
        • 자료구조 (7)
        • ETC (8)
      • 회고 (0)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

    • velog
  • 공지사항

  • 인기 글

  • 태그

    알고리즘
    JPA
    Til
    pdf 자동 다운로드
    재시도 로직
    pdf 프리뷰 실패
    Express
    springboot
    Database
    HTTP
    java
    SQL
    SSE
    network
    프로젝트
    자료구조
    서명알고리즘
    최단거리
    실시간 데이터 전송 기술
    로그인
    Spring
    outbox 패턴
    outbox
    redis
    mysql
    오블완
    티스토리챌린지
    알림 기능
    SQS
    aws
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.6
다섯자두
[Spring] 기본 예외 처리와 Custom 예외 처리 함께 사용하기
상단으로

티스토리툴바