Spring Boot의 다양한 예외 처리 방식

사람·2025년 3월 3일
0

Backend

목록 보기
4/11

솔직히 그동안 기능 구현하기에 바빠서 예외 처리에 잘 신경을 못 썼다.
그러다 보니 프론트엔드 개발자 분들이 테스트하실 때 예외가 발생하면 Response Body가 텅~ 비어 있어서 원인 확인하려고 매번 내가 콘솔 로그를 뒤져야 했다.... 그 시간을 기다려주시는 프론트엔드 개발자분들께 정말 죄송했다. 그래서 이젠 정말 이대로는 안 되겠다는 걸 느끼고 예외 처리를 제대로 해보기로 했다.

Spring Boot에서 예외를 처리하는 방식은 여러가지가 있었다. 각 방식에 대해서 이해하는 데에는 특히 이 글을 많이 참고했다. (감사합니다...)
결론부터 말하자면 나는 이 글의 가장 마지막에 있는 @ControllerAdvice 어노테이션을 사용하는 방식을 택했다. 왜 그랬는지는 글을 읽어보면 알 수 있다.

1. Controller-Level에서 @ExceptionHandler 사용

public class FooController {
    
    @ExceptionHandler({ CustomException1.class, CustomException2.class })
    public ResponseEntity<String> handleException() {
        return return ResponseEntity.status(INTERNAL_SERVER_ERROR).body("예외 발생!");
    }
    
    //...
}

각 컨트롤러에 정의된 API가 호출되어 기능을 수행하는 중 발생할 수 있는 예외들을 @ExceptionHandler라는 어노테이션에 정의하고, 그 예외들이 발생했을 때 수행할 예외 처리를 메소드 내에 정의하는 방식이다.
이 방식의 가장 큰 단점은 이 @ExceptionHandler가 커버하는 범위가 자신이 정의되어 있는 하나의 컨트롤러에만 국한되기 때문에, 같은 예외가 여러 컨트롤러에서 발생하면 그 모든 컨트롤러에 똑같은 예외 처리를 정의해야 한다는 것이다. 그러므로 불필요한 코드의 중복이 매우 많이 발생할 수밖에 없다. 뿐만 아니라, 예외 처리 로직이 모든 컨트롤러에 분산되어 구현되기 때문에 이들을 통합적으로 관리하는 데에도 어려움이 생긴다.
예외 처리 하나 하겠다고 모든 컨트롤러 코드에 손을 대야 한다니... 이건 아닌 것 같다.

2. HandlerExceptionResolver 커스텀

springframework에서는 HandlerExceptionResolver라는 인터페이스의 구현체를 통해 예외 처리를 수행한다. 이 HandlerExceptionResolver를 직접 커스텀함으로써 Controller-Level에서 @ExceptionHandler를 사용할 때의 단점을 상쇄하고 일관된 예외 처리 매커니즘을 유지하는 것이 가능하다.

2.1. 기본 제공되는 HandlerExceptionResolver의 구현체

HandlerExceptionResolver를 커스텀하기 전에 스프링이 기본적으로 제공하는 HandlerExceptionResolver의 구현체들에 대해 간단히 알아보자.

2.1.1. ExceptionHandlerExceptionResolver

Spring 3.1부터 지원된다.
앞에서 본 @ExceptionHandler 어노테이션이 붙은 예외를 처리하는 기능을 수행하는 하는 클래스이다.

2.1.2. DefaultHandlerExceptionResolver

Spring 3.0부터 지원된다.
이 클래스는 Spring MVC 표준 예외 발생 시 그 예외와 HTTP Status Code를 매핑시켜 준다. 매핑이 지원되는 예외의 종류와 각 예외가 어떤 HTTP Status Code와 매핑되는지는 공식 문서에서 살펴볼 수 있다.

하지만 이 방식은 HTTP Status Code만 지정해줄 뿐 Response Body는 설정할 수 없어서 Response Body가 null로 응답된다는 한계가 있다. 클라이언트가 HTTP Status Code만으로는 현재 발생한 문제에 대해 파악하기 충분치 않음은 자명하다.

2.1.3. ResponseStatusExceptionResolver

Spring 3.0부터 지원된다.
이 클래스는 @ResponseStatus 어노테이션을 처리하는 역할을 한다.
이 어노테이션을 사용하면 커스텀 예외에 대해서도 HTTP Status Code를 매핑해줄 수 있다.

@ResponseStatus(value = HttpStatus.NOT_FOUND)
public class MyResourceNotFoundException extends RuntimeException {
    public MyResourceNotFoundException() {
        super();
    }
    public MyResourceNotFoundException(String message, Throwable cause) {
        super(message, cause);
    }
    public MyResourceNotFoundException(String message) {
        super(message);
    }
    public MyResourceNotFoundException(Throwable cause) {
        super(cause);
    }
}

위 예시에서는 커스텀 예외 클래스에 @ResponseStatus를 추가함으로써 HTTP Status Code를 404로 매핑해주었다.

이 방식은 DefaultHandlerExceptionResolver와 마찬가지로 Response Body를 설정할 수 없어 null로 응답된다는 한계가 있다.

2.2. Custom HandlerExceptionResolver

그렇다면 하나의 클래스에서 예외를 통합적으로 관리하면서, Response Body도 설정해줄 수는 없을까?
HandlerExceptionResolver를 상속받아 직접 커스텀하면 가능하다.

@Component
public class RestResponseStatusExceptionResolver extends AbstractHandlerExceptionResolver {

    @Override
    protected ModelAndView doResolveException(
      HttpServletRequest request, 
      HttpServletResponse response, 
      Object handler, 
      Exception ex) {
        try {
            if (ex instanceof IllegalArgumentException) {
                return handleIllegalArgument(
                  (IllegalArgumentException) ex, request, response, handler);
            }
            ...
        } catch (Exception handlerException) {
            logger.warn("Handling of [" + ex.getClass().getName() + "] 
              resulted in Exception", handlerException);
        }
        return null;
    }

    private ModelAndView 
      handleIllegalArgument(IllegalArgumentException ex, HttpServletRequest request, HttpServletResponse response) 
      throws IOException {
        response.sendError(HttpServletResponse.SC_CONFLICT);
        String accept = request.getHeader(HttpHeaders.ACCEPT);
        ...
        return new ModelAndView();
    }
}

AbstractHandlerExceptionResolverdoResolveException()은 발생한 예외에 대한 실질적인 처리 로직을 구현하면 되는 추상 메소드 템플릿이다.

AbstractHandlerExceptionResolver를 상속받는 커스텀 클래스 내부에 이 메소드를 구현함으로써 예외 처리를 통합적으로 관리할 수 있다.
그리고 보이다시피 Response Body인 ModelAndView를 리턴하기 때문에 앞서 본 HandlerExceptionResolver의 구현체들만으로는 Response Body를 설정할 수 없었던 한계를 극복 가능하다.
뿐만 아니라, 이 메소드는 파라미터로 클라이언트의 request를 받을 수 있다. 따라서, request의 ACCEPT 헤더를 확인함으로써 클라이언트에서 요청한 Content-Type으로 Response Body를 제공해줄 수 있다는 이점이 있다.

이렇듯 지금까지의 여타 예외 처리 방식들의 한계를 보완한 방식이지만, 이 방식 또한 다음과 같은 몇 가지 한계점을 안고 있다.

  1. HttpServletResponse를 직접 사용하는 방식임.
    doResolveException()ResponseEntity와 같은 고수준의 방식이 아닌 HttpServletResponse을 직접 사용하는 방식이기 때문에 Response의 Status Code, Header, Body를 하나하나 설정해 주어야만 한다. 예를 들어, JSON 응답을 반환하려면 response.getWriter().write(jsonString); 같은 방식으로 개발자가 직접 JSON 직렬화를 처리하고 응답을 구성해야만 한다.
    또한, HttpServletResponse를 직접 다루면 코드가 서블릿 API에 종속되므로 유지보수성과 확장성이 떨어질 수 있다.

  2. ModelAndView를 리턴하는 방식은 REST API의 예외 처리에 적절치 않음.
    @RestController가 추가되어 있는 REST API의 경우 JSON이나 XML과 같은 데이터를 반환한다. 하지만 doResolveException()가 반환하는 ModelAndView은 주로 HTML 기반의 View를 반환하는 데 사용되기에 Spring MVC의 전통적인 템플릿 렌더링 방식에 더 적합한 객체이다. 따라서 이 예외 처리 방식은 RESTful 방식과는 맞지 않는다고 볼 수 있다.

한 마디로 너무 구닥다리인 방식이다...

3. @ControllerAdvice와 Global @ExceptionHandler 사용

이러한 한계를 보완하기 위해 Spring 3.2는 @ExceptionHandler을 시스템 전역에서 사용할 수 있도록 하는 @ControllerAdvice 어노테이션을 지원하기 시작했다.
앞서 보았듯, Controller-Level에서 @ExceptionHandler를 사용하면 해당 컨트롤러 내에서 발생한 예외밖에 처리할 수가 없었다. 하지만 @ControllerAdvice를 추가한 클래스의 내부에서 @ExceptionHandler를 사용하면 해당 예외 처리가 시스템 전역에 적용된다! 따라서 예외 처리를 위한 클래스를 생성해 @ControllerAdvice를 추가하면, 그 하나의 클래스 내에서 모든 예외를 관리할 수가 있는 것이다.
뿐만 아니라, @ExceptionHandler를 사용하면 HttpServletResponse를 직접 다루는 대신ResponseEntity를 사용할 수 있어 응답에 대한 쉬운 통제, 관리 권한을 개발자가 쥐게 될 수 있다.
만약 REST API에 대한 예외 처리를 하려고 한다면, @ControllerAdvice 대신 @ControllerAdvice + ResponseBody의 의미인 @RestControllerAdvice를 추가하면 된다. (이 둘의 차이는 @Controller@RestController의 차이를 생각하면 된다.)

이처럼 이 방식이 기존의 Spring Boot의 예외 처리 방식의 한계를 완전히 보완한 방식이기에 이 방식을 채택해 다음과 같이 예외 처리를 하게 되었다.

import lombok.Getter;

import java.time.LocalDateTime;

@Getter
public class ErrorResponse {
    private LocalDateTime timestamp;
    private int status;
    private String error;
    private String message;
    public ErrorResponse(int status, String error, String message) {
        this.timestamp = LocalDateTime.now();
        this.status = status;
        this.error = error;
        this.message = message;
    }

}

일단 위와 같이 Response Body를 구성하기 위한 DTO를 생성했다.

import com.google.firebase.auth.FirebaseAuthException;
import com.score.backend.dtos.ErrorResponse;
import io.jsonwebtoken.JwtException;
import org.apache.tomcat.websocket.AuthenticationException;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.multipart.MaxUploadSizeExceededException;

import java.io.IOException;
import java.sql.SQLException;
import java.text.ParseException;

import static org.springframework.http.HttpStatus.*;

@RestControllerAdvice
public class CustomControllerAdvice {
    @ResponseStatus(INTERNAL_SERVER_ERROR)
    @ExceptionHandler({Exception.class, RuntimeException.class, SQLException.class, FirebaseAuthException.class, IOException.class})
    public ResponseEntity<ErrorResponse> handleServerErrors(Exception ex) {
        return ResponseEntity.status(INTERNAL_SERVER_ERROR).body(new ErrorResponse(INTERNAL_SERVER_ERROR.value(), ex.toString(), ex.getMessage()));
    }

    @ResponseStatus(NOT_FOUND)
    @ExceptionHandler({NotFoundException.class})
    public ResponseEntity<ErrorResponse> handleNoSuchElementException(NotFoundException ex) {
        return ResponseEntity.status(NOT_FOUND).body(new ErrorResponse(NOT_FOUND.value(), ex.toString(), ex.getType().getMessage()));
    }

    @ResponseStatus(BAD_REQUEST)
    @ExceptionHandler(ScoreCustomException.class)
    public ResponseEntity<ErrorResponse> handleBadRequestException(ScoreCustomException ex) {
        return ResponseEntity.status(BAD_REQUEST).body(new ErrorResponse(BAD_REQUEST.value(), ex.toString(), ex.getType().getMessage()));
    }

    @ResponseStatus(PAYLOAD_TOO_LARGE)
    @ExceptionHandler(MaxUploadSizeExceededException.class)
    public ResponseEntity<ErrorResponse> handleMaxUploadSizeExceededException(MaxUploadSizeExceededException ex) {
        return ResponseEntity.status(PAYLOAD_TOO_LARGE).body(new ErrorResponse(PAYLOAD_TOO_LARGE.value(), ex.toString(), ExceptionType.EXCEEDED_FILE_SIZE.getMessage()));
    }

    @ResponseStatus(UNAUTHORIZED)
    @ExceptionHandler({ParseException.class, JwtException.class, AuthenticationException.class})
    public ResponseEntity<ErrorResponse> handleAuthenticationException(Exception ex) {
        return ResponseEntity.status(UNAUTHORIZED).body(new ErrorResponse(UNAUTHORIZED.value(), ex.toString(), ex.getMessage()));
    }
}

그리고 이 DTO를 가지고 위와 같이 예외 처리를 해주었다.
아직 리팩토링이 많이 필요하지만, 구현 방식을 참고하는 데에는 충분하리라 생각한다.
2.1.3.에 정리해둔 @ResponseStatus 어노테이션을 함께 사용해 HTTP Status Code도 지정해주었다.

이제 예외가 발생하면 위와 같이 아름다운 JSON 형태의 Response Body를 볼 수 있게 되었다!!
아직 예외 처리에 대해 더 보완하고 알아볼 게 많아서 조만간 관련해 글을 한 번 더 쓸 것 같다.

profile
알고리즘 블로그 아닙니다.

0개의 댓글