이 글은 망나니개발자 민규님의 블로그를 참고하여 같은 팀원들에게 설명해주기 쉽게 하기 위해 변경 및 수정하였습니다.

스프링의 다양한 예외 처리 방법

예외 처리는 어플리케이션을 만드는 데 매우 중요한 부분을 차지한다. 스프링 프레임워크는 매우 다양한 예외 처리 방법을 제공하는데, 어떤 방법들이 있고 가장 좋은 방법이 무엇인지 살펴보자.


스프링의 기본적인 예외 처리 방법

우리가 만든 RecipeController에서 생각해보자.

@RequestMapping("/api")  
@RestController  
@RequiredArgsConstructor  
public class RecipeController {  

    private final RecipeService recipeService;  

    @GetMapping("/recipe")  
    public List<RecipeSearchDto> getRecipes(){  
        return recipeService.getRecipes();  
    }
}

getRecipes에서 만약 IllegalArgumentException이 발생했다면 우리는 500에러가 발생했다는 페이지를 받게 된다.

Spring은 만들어질 때 부터 에러 처리를 위한 BasicErrorController를 구현해두었고, 스프링 부트는 예외가 발생하면 기본적으로 /error로 에러 요청을 다시 전달하도록 WAS 설정을 해두었다.
그래서 별도의 설정이 없다면 예외 발생 시 BasicErrorController로 에러 처리 요청이 전달된다.
이는 스프링 부트의 WebMvcAutoConfiguration을 통해 자동 설정이 되게 하는 WAS의 설정이다.

여기서 요청이 /error으로 다시 전달된다는 부분에 주목하자. 일반적인 요청 흐름은 다음과 같다.
WAS(톰캣) -> filter -> DispatcherServlet -> 인터셉터 -> 컨트롤러

그리고 컨트롤러 하위에서 예외가 발생했을 때, 별도의 예외 처리를 하지 않으면 WAS까지 에러가 전달된다. 그러면 WAS는 애플리케이션에서 처리를 못하는 예외라고 생각하고, 대응 작업을 진행한다.
컨트롤러(예외 발생) -> 인터셉터 -> DispatcherServlet -> filter -> WAS(톰캣)

그래서 WAS는 스프링 부트가 등록한 에러 설정(/error)에 맞게 요청을 전달하는데, 이러한 흐름을 총 정리하면 다음과 같다.

WAS(톰캣) -> 필터 -> DispatcherServlet -> 인터셉터 -> 컨트롤러 -> '예외 발생' -> 
컨트롤러 -> 인터셉터 -> DispatcherServlet -> filter -> WAS(톰캣) ->
다시 filter -> DispatcherServlet -> 인터셉터 -> 컨트롤러(BasicErrorController)

위의 코드를 보면 위의 일반적인 요청 흐름이 계속 이어진다는 것을 알 수 있다.

결국 기본적인 에러 처리 방식이 결국 '에러 컨트롤러'를 한 번 더 호출하는 것임을 알 수 있다.

그러므로 filter나 인터셉터가 다시 호출되는 작업을 제어하려면 별도의 설정이 필요하다.
Servlet은 DispatcherType으로 요청의 종류를 구분하는데, 일반적인 요청은 REQUEST이며, 에러 처리 요청은 ERROR이다.
filter는 Servlet의 기술이므로 필터 등록(FilterRegisterationBean) 시에 호출될 DispatcherType을 설정할 수 있고, 별도의 설정이 없다면 REQUEST일때만 필터가 호출된다.
하지만 인터셉터는 스프링 기술이므로 DispatcherType을 설정할 수 없어서 별도의 URI 패턴으로 처리가 필요하다.

그러나 Spring Boot로 넘어오게 되면서, 스프링 부트가 WAS까지 직접 제어하게 되면서 이러한 WAS의 에러 설정까지 가능해졌다. 이것은 요청이 2번 생기는 것은 아니고, 1번의 요청이 2번 전달되는 것이다. 그리고 클라이언트는 이런 에러 처리 작업이 진행되었는지 알 수 없다.

BasicErrorController의 동작 및 에러 속성들

BasicErrorController는 accept 헤더에 따라 에러 페이지를 반환하거나 에러 메세지를 반환한다. 에러 경로는 기본적으로 /error로 정의되어 있으며, properties에서 server.error.path로 변경가능하다.

@Controller 
@RequestMapping("${server.error.path:${error.path:/error}}") 
public class BasicErrorController extends AbstractErrorController { 

    private final ErrorProperties errorProperties; 

    ... 
    @RequestMapping(produces = MediaType.TEXT_HTML_VALUE) 
    public ModelAndView errorHtml(HttpServletRequest request, HttpServletResponse response) {
         ... 
     } 

     @RequestMapping public ResponseEntity<Map<String, Object>> error(HttpServletRequest request) { 
         ... 
         return new ResponseEntity<>(body, status); 
     }

     ... 
 }
  • errorHtml()과 error()는 모두 getErrorAttributeOptions를 호출해 반환할 에러 속성을 얻는데, 기본적으로 DefaultErrorAttributes으로부터 반환할 정보를 가져온다.
  • DefaultErrorAttributes는 전체 항목들에서 설정에 맞춰 알아서 필요한 속성들만 가지고 온다.
    • timestamp: 에러가 발생한 시간
    • status: 에러의 Http 상태
    • error: 에러 코드
    • path: 에러가 발생한 uri
    • exception: 최상위 예외 클래스의 이름(설정 필요함)
    • message: 에러에 대한 내용(설정 필요)
    • errors: BindingException에 의해 생긴 에러 목록(설정 필요)
    • trace: PrintStackTrace(설정 필요)
  • 따라서 다음과 같은 result를 가지고 온다.
  • { "timestamp": "2021-12-31T03:35:44.675+00:00", "status": 500, "error": "Internal Server Error", "path": "/product/5000" }

그러나 이 에러는 클라이언트 입장에서는 유용하지 못하다. 클라이언트는 '어디서' 에러가 터졌는지 보다는 '왜' 에러가 터졌는지가 중요하기 때문이다.
그래서 다음과 같이 properties를 통해 응답을 조정할 수있다.
물론 운영 환경을 생각하면 구현이 노출되는 trace는 제공하지 않는것이 좋다.

server.error.include-message: always 
server.error.include-binding-errors: always 
server.error.include-stacktrace: always 
server.error.include-exception: false

하지만 설정을 변경했음에도 불구하고 status는 여전히 500이고, 유의미한 에러 응답을 전달하지 못한다. (여기서 status가 500인 이유는 에러가 처리되지 않고 WAS가 에러를 전달받았기 때문에 서버에러인 500을 뱉는다.) 또한 흔히 사용되는 API 에러 처리 응답으로는 보다 세밀한 제어가 요구된다. 그러므로 우리는 별도의 에러 처리 전략을 통해 상황에 맞는 응답을 제공해야 한다.

{ 
    "timestamp": "2021-12-31T03:35:44.675+00:00", 
    "status": 500, "error": "Internal Server Error", 
    "trace": "java.util.NoSuchElementException: No value present ...", 
    "message": "No value present", 
    "path": "/product/5000" 
}

와 같이 말이다.

스프링이 제공하는 다양한 예외 처리 방법

JAVA에서는 예외 처리를 위해 try-catch문을 써야 하지만 try-catch를 모든 코드에 붙이는 것은 비효율적이다. Spring은 AOP라는 개념을 통해 에러 처리라는 공통 관심사(Cross-cutting concerns)를 메인 로직으로부터 분리 하는 다양한 예외 처리 방식을 고안하였고, 예외 처리 전략을 추상화한 HandlerExceptionResolver라는 인터페이스를 만들었다.
대부분의 HandlerExceptionResolver는 발생한 Exception을 catch하고 HTTP상태나 응답 메세지 등을 설정한다. 그래서 WAS 입장에서는 해당 요청이 정상적인 응답인 것으로 인식되며, 위에서 설명한 복잡한 WAS 에러 전달(WAS로부터 컨트롤러까지의)이 진행되지 않는다.

public interface HandlerExceptionResolver { 
    ModelAndView resolveException(HttpServletRequest request, 
        HttpServletResponse response, Object handler, Exception ex); 
}

위의 Object 타입인 handler는 예외가 발생한 컨트롤러 객체이다. 예외가 throw되면 DispatcherServlet까지 전달되는데, 적합한 예외 처리를 위해 HandlerExceptionResolver 구현체들을 빈으로 등록해서 관리한다. 그리고 적용 가능한 구현체를 찾아 예외 처리를 하는데, 우선순위대로 아래의 4가지 구현체들이 Bean으로 등록되어 있다.

<현재 상황>

WAS(톰캣) -> 필터 -> DispatcherServlet -> 인터셉터 -> 컨트롤러 -> '예외 발생' ->
                          ⇩ (We are here)
컨트롤러 -> 인터셉터 -> DispatcherServlet -> filter -> WAS(톰캣) ->
다시 filter -> DispatcherServlet -> 인터셉터 -> 컨트롤러(BasicErrorController)
  • DefaultErrorAttributes: 에러 속성을 저장하며 직접 예외를 처리하지는 않는다.
  • ExceptionHandlerExceptionResolver: 에러 응답을 위한 Controller나 ControllerAdvice에 있는 ExceptionHandler를 처리함
  • ResponseStatusExceptionResolver: Http 상태 코드를 지정하는 @ResponseStatus 또는 ResponseStatusException를 처리함
  • DefaultHandlerExceptionResolver: 스프링 내부의 기본 예외들을 처리한다.

DefaultErrorAttributes는 직접 예외를 처리하지 않고 속성만 관리하므로 성격이 다르다. 그래서 내부적으로 DefuaultErrorAttributes를 제외하고 직접 예외를 처리하는 3가지 ExceptionResolver들을 HandlerExceptionResolverComposite로 모아서 관리한다. 즉, 컴포지트 패턴을 이용해 실제 예외 처리기들을 따로 관리하는 것이다.

Spring은 아래와 같은 도구들로 ExceptionResolver를 동작시켜 에러를 처리할 수 있는데, 각각의 방식에 대해 자세히 살펴보도록 하자.

  1. ResponseStatus
  2. ResponseStatusException
  3. ExceptionHandler
  4. ControllerAdvice, RestControllerAdvice 👈 결국 마지막에 우리가 쓸 것

@ResponseStatus

어노테이션 이름에서 예측가능하듯이, @ResponseStatus는 Error의 HttpStatus를 변경하도록 도와주는 어노테이션이다. @ResponseStatus는 다음과 같은 경우들에 적용가능하다.

  • Exception class 자체
  • 메소드에 @ExceptionHandler와 함께
  • 클래스에 @RestControllerAdvice 와 함께

그래서 우리가 만든 예외 클래스에 다음과 같이 @ResponseStatus로 응답 상태를 지정해 줄 수 있다.

@ResponseStatus(value = HttpStatus.NOT_FOUND)
public class NoSuchElementFoundException extends RuntimeException{
    ...
}

그 결과 RepsonseStatusExceptionResolver가 지정해준 상태로 에러 응답이 내려가도록 처리한다.

 { 
     "timestamp": "2021-12-31T03:35:44.675+00:00", 
     "status": 404, 
     "error": "Not Found", 
     "path": "/product/5000" 
 }

하지만 에러 응답에서 볼 수 있듯이 이는 BasicErrorController에 의한 응답이다. 즉, @ResponseStatus를 처리하는 ResponseStatusExceptionResolver는 WAS까지 예외를 전달시키고, 복잡한 WAS의 에러 요청이 진행되는 것이다. 그래서 @ResponseStatus는 다음과 같은 한계점을 가진다.

<현재 상황>

WAS(톰캣) -> 필터 -> DispatcherServlet -> 인터셉터 -> 컨트롤러 -> '예외 발생' ->
컨트롤러 -> 인터셉터 -> DispatcherServlet -> filter -> WAS(톰캣) ->
                                                          ⇩ (We are here)
다시 filter -> DispatcherServlet -> 인터셉터 -> 컨트롤러(BasicErrorController)
  • 에러 응답의 내용(Payload)를 수정할 수 없음 (DefaultErrorAttributes를 수정하면 가능하긴 함)
  • 예와 클래스와 강하게 결합되어 같은 예외는 같은 상태와 에러 메세지를 반환함
  • 별도의 응답 상태가 필요하다면 예외 클래스를 추가해야 됨
  • WAS까지 예외가 전달되고, WAS의 에러 요청 전달이 진행됨
  • 외부에서 정의한 Exception 클래스에는 @ResponseStatus를 붙여줄 수 없음

결국 위와 같은 한계의 이유로 우리는 다른 방법을 이용해야 한다.

ResponseStatusException

외부 라이브러리에서 정의한 코드는 우리가 수정할 수 없으므로 @ResponseStatus를 붙여줄 수 없다. Spring 5부터는 @ResponseStatus의 프로그래밍적 대안으로써 손쉽게 에러를 반환할 수 있는 ResponseStatusException이 추가되었다. ResponseStatuseException은 HttpStatus와 함께 선택적으로 reason과 cause를 추가할 수 있고, Unchecked(Runtime) 예외를 상속받고 있어 명시적으로 에러를 처리해주지 않아도 된다. 이러한 ResponseStatusException은 다음과 같이 사용할 수 있다.

@GetMapping("/recipes/{id}")
public ResponseEntity<Recipes> getRecipe(@Pathvariable Long id){
    try{
        return ResponseEntity.ok(recipeService.getRecipe(id));
    } catch (NoSuchElementFoundException e){
        throw new ResponseStatusException(HttpStatus.NOT_FOUND, "레시피가 없습니다");
    }
}

예외가 발생하면 ResponseStatusExceptionResolver가 에러를 처리한다는 점에서 @ResponseStatus와 동일하다. 그러나 ResponseStatusException을 터트리면 다음과 같은 이점이 있다.

  • 기본적인 예외 처리를 빠르게 적용할 수 있으므로 손쉽게 프로토타이핑할 수 있음
  • HttpStatus를 직접 설정하여 예외 클래스와의 결합도를 낮출 수 있음
    • ResponseEntity에서 설정하는 방식과 비슷하다
  • 불필요하게 많은 별도의 예외 클래스를 만들지 않아도 됨
    • 아까 ResponseStatus같은 경우에는 전부 다 클래스를 만들고 @Value(=status)로 지정해 주었음
  • 프로그래밍 방식으로 예외를 직접 생성하므로 예외를 더욱 잘 제어할 수 있음

그렇지만 ResponseStatusException같은 경우도 한계가 있었는데, 다음과 같은 이유 때문에@ExceptionHandler를 사용하는 방식이 더 많이 사용된다.

  • 직접 예외 처리를 프로그래밍하므로 일관된 예외 처리가 어려움
    • 실수라도 일어난다...? ^^
  • 예외 처리 코드가 중복될 수 있음
    • Spring을 사용하면서 AOP적인 처리가 안된다...?
  • Spring 내부의 예외를 처리하는 것이 어려움
  • 예외가 WAS까지 전달되고, WAS의 에러 요청 전달이 진행됨
    • 결국 Controller까지 가는 부분은 똑같잖아!

@ExceptionHandler

ExceptionHandler는 매우 유연하게 에러를 처리할 수 있는 방법을 제공하는 기능이다.
다음에서 @ExceptionHandler라는 어노테이션을 추가함으로써 에러를 손쉽게 처리할 수 있다.

  • 컨트롤러의 메소드 부분에 추가
  • @ControllerAdvice나 @RestControllerAdvice가 있는 클래스의 메소드에 추가

예를 들어 컨트롤러의 메소드에 @ExceptionHandler를 추가함으로써 에러를 처리할 수 있다. @ExceptionHandler에 의해 발생한 예외는 ExceptionHandlerExceptionResolver에 의해 처리된다.

@RequestMapping("/api")  
@RestController  
@RequiredArgsConstructor  
public class RecipeController {  

    private final RecipeService recipeService;  

    @GetMapping("/recipe/{id}")  
    public RecipeResponseDto getRecipe(@PathVariable Long id){  
        return recipeService.getRecipe(id);  
    }
                                    ⇩ (We are here)
    @ExceptionHandler(NoSuchElementFoundException.class)
    public ResponseEntity<String> handleNoSuchFoundException{
        return ResponseEntity.status(HttpStatus.NOT_FOUND).body(exception.getMessage());
    }
}

@ExceptionHandler는 Exception 클래스를 속성으로 받아 처리할 예외를 지정할 수 있다.
만약 ExceptionHandler 어노테이션에 예외 클래스를 지정하지 않는다면, 파라미터에 설정된 에러 클래스를 처리하게 된다. 또한 @ResponseStatus와도 결합은 가능하지만, ResponseEntity에서도 status를 지정하고 @ResponseStatus도 있다면 ResponseEntity의 status가 우선순위를 갖는다.

ExceptionHandler는 @ResponseStatus와는 달리 에러 응답(payload)을 자유롭게 다룰 수 있다는 점에서 유연하다. 예를 들어 응답을 다음과 같이 정의해서 내려준다면 좋을 것이다.

  • code: '어떠한 종류'의 에러가 발생하는지에 대한 에러 코드
  • message: '왜' 에러가 발생했는지?에 대한 설명
  • errors: '어디에서' @Valid에 의한 검증이 실패했는지를 위한 에러 목록
    ☝ 이제 우리가 슬슬 보던 ErrorResponse의 형태가 나오죠?

여기서 code로 Error001, Error002와 같은 값보다는 BAD_REQUEST와 같은 Http 표준 상태와 같이 가독성을 고려해 사용하는것이 FrontEnd의 입장에서도 대응하기 좋고, 유지보수하는 입장에서도 좋다.

@RestController 
@RequiredArgsConstructor 
public class RecipeController { 

    ... 

    @ExceptionHandler(NoSuchElementFoundException.class) 
    public ResponseEntity<ErrorResponse> handleItemNotFoundException(NoSuchElementFoundException exception) { 
        ... 
    } 

    @ExceptionHandler(MethodArgumentNotValidException.class) 
    public ResponseEntity<ErrorResponse> handleMethodArgumentNotValid(MethodArgumentNotValidException ex) { 
        ... 
    } 
                            👇          얘랑 얘가 동일해야 한다.           👇
    @ExceptionHandler(Exception.class)                                      👇
    public ResponseEntity<ErrorResponse> handleAllUncaughtException(Exception exception) { 
        ... 
    } 
}

Spring은 예외가 발생하면 가장 구체적인 예외 핸들러를 먼저 찾고, 없으면 부모 예외의 핸들러를 찾는다. 예를 들어 위에서 NPE가 발생했는데 NPEHandler가 없으면 Exception에 대한 처리기가 찾아진다. @ExceptionHandler를 사용할 때 주의할 점은, @ExceptionHandler에 등록된 예외 클래스파라미터로 받는 예외 클래스동일해야 한다는 것이다. 만약 값이 다르면 스프링은 컴파일 시점에 에러를 내지 않다가 런타임 시점에 에러를 발생시킨다. (이게 무서워서 우리는 지네릭스를 사용하기도 한다. )
런타임 시점에 다음과 같은 에러 메세지가 출력된다.

java.lang.IllegalStateException: No suitable resolver for argument [0] [type=...] HandlerMethod details: ...

참고 : Exception은 크게 Exception과 그 자손, 그리고 RuntimeException과 그 자손으로 나뉜다.
기준 : Complier가 예외 처리여부를 체크하느냐(Exception) / 체크하지 않느냐(RuntimeException)으로 나누기 때문에 Checked Exception / UncheckedException으로 분류하기도 한다.

ExceptionHandler의 파라미터로 HttpServletRequest나 WebRequest등을 얻을 수 있으며 반환 타입으로는 자유롭게 올 수 있다. 와일드카드를 이용해 타입을 정해줄수도 있다.
@ExceptionHandler는 이러한 장점이 있지만, 다음과 같은 단점도 있다.

  • 컨트롤러에 구현하므로 특정 컨트롤러에서만 발생하는 예외만 처리된다.
  • 컨트롤러에 에러 처리 코드가 섞여서 코드가 지저분해진다.
  • 에러 처리 코드가 중복될 가능성이 높다.
  • AOP스럽지 않다.

그래서 스프링에서는 다음과 같은, 전역적으로(Global) 예외를 처리할 수 있는 좋은 기술을 제공한다.

@ConrollerAdvice, @RestControllerAdvice

Spring은 전역적으로 @ExceptionHandeler를 적용할 수 있는 @ControllerAdvice@RestControllerAdvice를 제공한다. 두 개의 차이는 @Controller@RestController같이 @ResponseBody가 붙어 있어 응답을 JSON으로 내려준다는 점에서 다르다.

@ControllerAdvice와 @RestController의 구성

@Target(ElementType.TYPE) 
@Retention(RetentionPolicy.RUNTIME) 
@Documented 
@ControllerAdvice 
@ResponseBody 
public @interface RestControllerAdvice { 
    ... 
} 

@Target(ElementType.TYPE) 
@Retention(RetentionPolicy.RUNTIME) 
@Documented 
@Component 
public @interface ControllerAdvice {
    ... 
}

ControllerAdvice는 여러 컨트롤러에 대해 전역적으로 ExceptionHandler를 적용해준다. 위에서 보이듯 ControllerAdvice 어노테이션에는 @Component 어노테이션이 있어서 스프링 컨테이너가 뜰 때 자동으로 스프링 빈으로 등록된다. 그래서 우리는 다음과 같은 전역적 에러 핸들링 클래스를 만들어 어노테이션을 붙여주면 에러 처리를 이 클래스에 위임해 줄 수 있다.

@RestControllerAdvice 
public class GlobalExceptionHandler {

    @ExceptionHandler(NoSuchElementFoundException.class) 
    protected ResponseEntity<?> handleNoSuchElementFoundException(NoSuchElementFoundException e) { 
    final ErrorResponse errorResponse = ErrorResponse.builder() 
                    .code("Item Not Found") 
                    .message(e.getMessage()).build(); 
        return ResponseEntity.status(HttpStatus.NOT_FOUND).body(errorResponse); 
    } 
}

ControllerAdvice는 전역적으로 적용되는데, 만약 특정 클래스에만 제한적으로 적용하고 싶다면 @RestControllerAdvice의 basePackages 등을 설정함으로써 제한할 수 있다.

Spring 예외에는 대표적으로 잘못된 URI를 호출하여 발생하는 NoHandlerFoundException등이 있다. Spring은 스프링 예외를 미리 처리해둔 ResponseEntityExceptionHandler를 추상 클래스로 제공하고 있다. ResponseEntityExceptionHandler에는 스프링 예외에 대한 ExceptionHandler가 모두 미리 구현되어 있으므로, ControllerAdvice가 이를 상속받게 하면 된다.
만약 ResponseEntityExceptionHandler를 상속받지 않는다면 스프링 예외들은 DefaultHandlerExceptionResolver가 처리하게 되는데, 그러면 예외 처리기가 달라지므로 클라이언트가 일관되지 못한 에러 응답을 받는 것이 아니게 된다.
그래서 ResponseEntityExceptionHandler 를 상속시키는 것이 좋다. 또한 이는 기본적으로 에러 메세지를 반환하지 않으므로, 스프링 예외에 대한 응답 에러를 보내려면 아래의 메소드를 오버라이딩해야 한다.

public abstract class ResponseEntityExceptionHandler { 

    ... 

    protected ResponseEntity<Object> handleExceptionInternal ( Exception ex, @Nullable Object body, HttpHeaders headers, HttpStatus status, WebRequest request) { 
    ... 
    } 
}

우리는 이런 ControllerAdvice를 사용함으로써 다음과 같은 이점을 누릴 수 있다.

  • 하나의 클래스로 모든 컨트롤러에 대해 전역적으로 예외처리가 가능하다
  • 직접 정의한 에러 응답을 일관성있게 클라이언트에 내려줄 수 있다.
  • 별도의 try-catch문이 없어 코드의 가독성이 높아진다.

이러한 이유로 API에 의한 예외 처리를 할 때에는 ControllerAdvice를 이용하면 평가된다. 하지만 ControllerAdvice를 사용할 때에는 항상 다음의 내용들을 주의해야 한다. 여러 ControllerAdvice가 있을 때 @Order 어노테이션으로 순서를 지정하지 않는다면 Spring은 ControllerAdvice를 임의의 순서로 처리할 수 있으므로 일관된 예외 응답을 위해서는 이러한 점에 주의해야 한다.

  • 한 프로젝트당 하나의 ControllerAdvice만 관리하는 것이 좋다.
  • 만약 여러 ControllerAdvice가 필요하다면 basePackages나 annotations 등을 지정해야 한다.
  • 직접 구현한 Exception 클래스들은 한 공간에서 관리한다.

JuBTI 프로젝트에 작성된 ExceptionHandler를 확인해보자.

@Slf4j  
@RestControllerAdvice  
public class GlobalExecptionHandler extends ResponseEntityExceptionHandler {

    @ExceptionHandler(value = { ConstraintViolationException.class, DataIntegrityViolationException.class})  
    protected ResponseEntity<ErrorResponse> handleDataException() {  
        log.error("handleDataException throw Exception : {}", DUPLICATE_RESOURCE);  
        return ErrorResponse.toResponseEntity(DUPLICATE_RESOURCE);  
    }  

    @ExceptionHandler(value = { CustomException.class })  
    protected ResponseEntity<ErrorResponse> handleCustomException(CustomException e) {  
        log.error("handleCustomException throw CustomException : {}", e.getErrorCode());  
        return ErrorResponse.toResponseEntity(e.getErrorCode());  
    }  
}
  • @RestControllerAdvice를 씀으로써 전역적으로 적용되는 ExceptionHandler라는 것을 확인할 수 있고, 클라이언트는 응답값을 JSON형태로 받을 것이라는 것을 알 수 있다.
  • ResponseEntityExceptionHandler를 상속받음으로써 모든 스프링 예외에 대해 대응할 수 있다.
  • @ExceptionHandler 안에 있는 Exception class를 통해 미루어 볼 때 어느 에러에 대응되는 메소드인지 알 수 있다.
    • ConstraintViolentException : 데이터의 삽입/수정이 무결성 제약 조건을 위반할 때 발생하는 예외, hibernate단에서 발생하는 예외이다.
    • CustomException : 우리가 만든 커스텀한 에러이다.

CustomException과 ErrorResponse에 대해 설명하지 않았으므로, 이제 설명하겠다.
설명하기 전에 Spring의 예외 처리 흐름에 대해 알아보고 출발하자.

Spring의 예외 처리 흐름

다음과 같은 예외 처리기들은 @Configuration 어노테이션을 통해 스프링의 빈으로 등록되어 있고, 예외가 발생하면 순차적으로 다음의 Resolver들이 처리가능한지 판별한 후에 예외가 처리된다.

  1. ExceptionHandlerExceptionResolver: 에러 응답을 위한 Controller나 ControllerAdvice에 있는 ExceptionHandler를 처리함
  2. ResponseStatusExceptionResolver: Http 상태 코드를 지정하는 @ResponseStatus 또는 ResponseStatusException를 처리함
  3. DefaultHandlerExceptionResolver: 스프링 내부의 기본 예외들을 처리한다.
    img
    위의 동작방식을 하나하나 뜯어보자.
  4. ExceptionHandlerExceptionResolver가 동작함
    1. 예외가 발생한 컨트롤러 안에 적합한 @ExceptionHandler가 있는가? 를 검사
    2. 컨트롤러의 @ExceptionHandler에서 처리가능하다면 거기서 처리하고, 그렇지 않으면 ControllerAdvice로 넘어간다.
    3. ControllerAdvice 안에 적합한 @ExceptionHandler가 있는지 검사한다.
    4. 처리가능하면 @ExceptionHandler에 Exception.class에 맞는 메소드가 동작한다.
  5. ResponseStatusExceptionResolver가 동작함
    1. @ResponseStatus가 있는지, 혹은 ResponseStatusException인지 검사함
    2. 맞으면 ServeletResponse sendError()로 예외를 서블릿까지 던지고, 서블릿이 BasicErrorController로 요청을 전달한다.
  6. DefaultHandlerExceptionResolver가 동작함
    1. Spring 예외인지 검사하고 맞으면 에러처리, 아니면 넘어감
  7. 적합한 ExceptionResolver가 없으므로 예외가 서블릿까지 전달되고, 서블릿은 스프링 부트가 진행한 Configuration에 맞게 BasicErrorController로 요청을 다시 전달한다.

결국 ExceptionHandler나 ControllerAdvice같이 직접 에러를 반환하는 경우가 아니면 BasicController를 거쳐 에러가 처리되기 때문에 컨트롤러로 요청이 2번씩 가게 된다.

결론 : ControllerAdvice를 이용하는게 최선이다.

RestControllerAdvice를 이용한 Spring 예외 처리 방법

에러 코드 정의하기

우리가 클라이언트에 보내줄 에러 코드를 정의해야 한다. 기본적으로 ErrorName과 HTTP status, message를 가지고 있는 에러 코드 클래스를 만들어 보자. 에러 코드는 애플리케이션에서 전역적으로 사용되는 CommonErrorCode와 특정 도메인에 대해 구체적으로 내려가는 UserErrorCode로 나누고, 인터페이스를 이용해 공통되는 부분만 추상화해두도록 하자.

public interface ErrorCode{

    String name();
    HttpStatus getHttpStatus();
    String getMessage();

}

그리고 발생할 수 있는 에러 코드를 다음과 같이 정의할 수 있다.

@Getter
@RequiredArgsConstructor
public enum CommonErrorCode implements ErrorCode{

    INVALID_PARAMETER(HttpStatus.BAD_REQUEST, "Invalid parameter included"),
    RESOURCE_NOT_FOUND(HttpStatus.NOT_FOUND, "Resource not exists"),
    INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "Internal server error"),
    ;

    private final HttpStatus httpStatus; 

    private final String message;

}

이제는 우리가 발생할 예외를 처리해줄 예외 클래스(Exception Class)를 추가해주어야 한다. 우리는 Unchecked Exception(RuntimeException)를 상속받는 예외 클래스를 다음과 같이 추가해 줄 수 있다.

@Getter
@RequiredArgsConstructor
public class CustomException extends RuntimeException{

    private final ErrorCode errorCode;

}

Question : 어 근데 아까 예외는 RuntimeException과 Exception으로 나눌 수 있다고 했는데 왜 RuntimeException만 처리해주나요?

Answer : 일반적인 비즈니스 로직들은 따로 catch해서 처리할 것이 없으므로, 만약 checked 예외로 한다면 불필요하게 throws가 생겨야 한다.

참고 : Checked Exception
체크드 예외는 RuntimeException 클래스를 상속받지 않은 예외 클래스들이다. 체크드 예외는 복구 가능성이 있는 예외이므로 반드시 예외를 처리하는 코드를 함께 작성해야 한다. 대표적으로 IOException, SQLException등이 있으며, 예외를 처리하기 위해서는 반드시 catch문으로 잡거나 throws를 통해 메소드 밖으로 던져야 한다. 만약 예외를 처리하지 않으면 컴파일 에러가 발생한다.
체크 예외는 개발자가 실수로 예외 처리를 누락하지 않도록 컴파일러가 도와준다. 하지만 개발자가 모든 체크 예외를 처리해주어야 하므로 번거로우며, 신경쓰지 않고 싶은 예외까지 처리해야 한다는 단점이 있다.
또한 실제로 애플리케이션 개발에서 발생하는 예외들은 복구 불가능한 경우가 많다. 예를 들어 SQLExceptoin과 같은 체크 예외를 catch해도, 쿼리를 수정하여 재배포하지 않는 이상 복구되지 않는다. 그래서 실제 개발에서는 대부분 언체크 예외를 사용한다.

또한 Spring은 내부적으로 발생한 예외를 확인하여 Unchecked Exception(Runtime Exception과 그 자손)이거나 Error라면 자동으로 트랜잭션을 롤백시키도록 처리한다. Spring에서 체크 예외만 롤백을 하지 않는 이유는 처리가 강제되기 때문에 개발자가 어련히 처리해주겠지....하는 기대 때문이다.

에러 응답 클래스 생성하기

우리는 클라이언트에게 다음과 같은 포맷의 에러를 던져주도록 해야 한다고 하자.

{ 
    "code": "INACTIVATE_USER", 
    "message": "User is inactive" 
}

이를 위해 다음과 같은 에러 응답 클래스를 추가해줄 수 있다.

@Getter 
@Builder 
@RequiredArgsConstructor 
public class ErrorResponse { 

    private final String code; 

    private final String message; 

    @JsonInclude(JsonInclude.Include.NON_EMPTY)
    private final List<ValidationError> errors; 

    @Getter 
    @Builder 
    @RequiredArgsConstructor 
    public static class ValidationError { 

        private final String field; 
        private final String message;

        public static ValidationError of(final FieldError fieldError) { 
            return ValidationError.builder() 
                    .field(fieldError
                    .getField()) 
                    .message(fieldError.getDefaultMessage()) 
                    .build();
        }
    } 
}

추가적으로 위의 예외에서는 @Valid를 사용했을 때 에러가 발생한 경우 어느 필드에서 에러가 발생했는지 응답을 위한 ValidationError를 내부의 static class로 추가해두었다. 만약 errors가 없다면 응답으로 내려가지 않도록 @JsonInclude 어노테이션도 추가해주었다.

@RestControllerAdvice 구현하기

이제는 전역적으로 에러를 처리해주는 @RestControllerAdvice 클래스를 추가해주어야 한다. Spring은 스프링 예외를 미리 처리해둔 ResponseExceptionHandler를 추상 클래스로 제공하고 있다. ResponseEntityExceptionHandler에는 스프링 예외에 대한 ExceptionHandler가 모두 구현되어 있으므로 ControllerAdvice 클래스가 이를 상속받게 하면 된다. 하지만 에러 메세지는 반환하지 않으므로 스프링 예외에 대한 에러 응답을 보내려면 아래 메소드를 오버라이딩 해야 한다.

public abstract class ResponseEntityExceptionHandler { 

    ... 

    protected ResponseEntity<Object> handleExceptionInternal( Exception ex, @Nullable Object body, HttpHeaders headers, HttpStatus status, WebRequest request){ 

    ... 
    } 
}

이제는 예제에서 만든 CustomException 예외, @Valid에 의한 유효성 검증에 실패했을 때 발생하는 IllegalArgumentException 예외, 그리고 잘못된 파라미터를 넘겼을 경우 발생하는 IllegalArgumentException 에러를 처리해주도록 하자.

@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler{

    @ExceptionHandler(CustomException.class)
    public ResponseEntity<Object> handleCustomException(CustomException E){
        ErrorCode errorcode = e.getErrorCode();
        return handleExceptionInternal(errorCode);    
    }

    @ExceptionHandler(IllegalException.class)
    public ResponseEntity<Object> handleIllegalArgumentException
                                        (IllegalArgumentException e){
        log.warn("handleIllegalArgument", e);
        ErrorCode errorcode = commonErrorCode.INVALID_PARAMETER;
        return handleExceptionInternal(errorcode, e.getMessage());
    }

    @Override 
    public ResponseEntity<Object> handleMethodArgumentNotValid( MethodArgumentNotValidException e, HttpHeaders headers, HttpStatus status, WebRequest request) { 
        log.warn("handleIllegalArgument", e); 
        ErrorCode errorCode = CommonErrorCode.INVALID_PARAMETER; 
        return handleExceptionInternal(e, errorCode); 
    } 

    @ExceptionHandler({Exception.class}) 
    public ResponseEntity<Object> handleAllException(Exception ex) {
         log.warn("handleAllException", ex); 
         ErrorCode errorCode = CommonErrorCode.INTERNAL_SERVER_ERROR; 
         return handleExceptionInternal(errorCode); 
     } 

    private ResponseEntity<Object> handleExceptionInternal(ErrorCode errorCode) {
              return ResponseEntity.status(errorCode.getHttpStatus())
                                .body(makeErrorResponse(errorCode)); 
    } 

    private ErrorResponse makeErrorResponse(ErrorCode errorCode) { 
        return ErrorResponse.builder() 
                        .code(errorCode.name())
                        .message(errorCode.getMessage()) 
                        .build(); 
    } 

    private ResponseEntity<Object> handleExceptionInternal(ErrorCode errorCode, String message) { 
        return ResponseEntity.status(errorCode.getHttpStatus()) 
                        .body(makeErrorResponse(errorCode, message)); 
    } 

    private ErrorResponse makeErrorResponse(ErrorCode errorCode, String message) {
        return ErrorResponse.builder() 
                        .code(errorCode.name()) 
                        .message(message) .build(); 
    } 

    private ResponseEntity<Object> handleExceptionInternal(BindException e, ErrorCode errorCode) { 
        return ResponseEntity.status(errorCode.getHttpStatus())
                             .body(makeErrorResponse(e, errorCode)); 
     } 

     private ErrorResponse makeErrorResponse(BindException e, ErrorCode errorCode) {
        List<ErrorResponse.ValidationError> validationErrorList = e.getBindingResult()
                             .getFieldErrors() 
                             .stream() 
                             .map(ErrorResponse.ValidationError::of) 
                             .collect(Collectors.toList()); 
        return ErrorResponse.builder() 
                        .code(errorCode.name()) 
                        .message(errorCode.getMessage()) 
                        .errors(validationErrorList) 
                        .build(); 
    }

}

CustomException 예외와 IllegalArgumentException의 경우에는 이를 캐치해서 핸들링하는 @ExceptionHandler를 구현해주면 됐다. 하지만 @Valid에 의한 MethodArgumentNotValidException의 경우에는 에러 필드와 메세지를 추가해주어야 하는데, 관련 정보는 MethodNotValidException의 getBindingResult를 통해서 얻을 수 있다.

에러 응답 확인

이제 실제로 우리가 원하는 대로 응답이 내려오는지 확인할 차례이다. 이를 위해 우리의 컨트롤러를 조금 변경했다.

@RequestMapping("/api")  
@RestController  
@RequiredArgsConstructor  
public class RecipeController {  

    private final RecipeService recipeService;  

    @GetMapping("/recipe/{id}")  
    public ResponseEntity<Recipe> getRecipe(){  
        throw new CustomException(UserErrorCode.RECIPE_NOT_FOUND)
    }
}

그리고 해당 API를 호출해보면 다음과 같이 우리가 원하는대로 에러 응답이 내려오는 것을 확인할 수 있다.

{
    "code" : "RECIPE_NOT_FOUND"
    "message" : "레시피가 존재하지 않습니다."
}

흐름 정리

  • 스프링의 기본적인 예외 처리인 BasicErrorController란 무엇이고, 어떻게 호출되는지에 대해 알았다.
  • Controller를 2번 거치지 않는 방법으로 @ResponseStatus와 ResponseStatusException라는 것으로 발전했고, 이것이 무엇이고 어떻게 호출되는지에 대해 알았다.
  • @ResponseStatus와 ResponseStatusException 각각의 한계에 대해 이해했고, 이를 극복하기 위해 @ExceptionHandler라는 것이 왜 나왔는지에 대해 이해했다.
  • @ExceptionHandler의 한계에 대해 이해했고, 이제는 ControllerAdvice와 RestControllerAdvice가 왜 나왔는지, 어떻게 쓰는지에 대해 이해했다.

JuBTI의 ExceptionHandler를 보며 다시 되새기자 :)

  1. 먼저 RuntimeException을 상속받는 CustomException을 만든다.
  2. @Getter public class CustomException extends RuntimeException { private final ErrorCode errorCode; public CustomException(ErrorCode errorCode){ this.errorCode = errorCode; }

}


2. ErrorCode를 Enum type으로 만들어준다.
    1. 이번에는 일정한 도메인에서만 내려 줄 에러코드 없이 전역적으로 ErrorCode를 사용할 것이므로, CommonErrorCode와 UserErrorCode를 나누지 않고, 하나의 ErrorCode Enum에서 사용했습니다.
```java
@Getter  
@AllArgsConstructor  
public enum ErrorCode {  

    /* 400 BAD_REQUEST : 잘못된 요청 */    
    INVALID_REFRESH_TOKEN(BAD_REQUEST, "리프레시 토큰이 유효하지 않습니다"),  
    MISMATCH_REFRESH_TOKEN(BAD_REQUEST, "리프레시 토큰의 유저 정보가 일치하지 않습니다"),  
    DUPLICATE_USERS(BAD_REQUEST, "중복된 유저가 존재합니다"),
    INVALID_REQUEST(BAD_REQUEST, "잘못된 요청입니다."),  

    /* 401 UNAUTHORIZED : 인증되지 않은 사용자 */    
    INVALID_AUTH_TOKEN(UNAUTHORIZED, "권한 정보가 없는 토큰입니다"),  
    UNAUTHORIZED_MEMBER(UNAUTHORIZED, "현재 내 계정 정보가 존재하지 않습니다"),

    /* 403 FORBIDDEN : 권한 없음*/
    UNAUTHORIZED_USER(FORBIDDEN, "접근 권한이 없습니다."),

    /* 404 NOT_FOUND : Resource 를 찾을 수 없음 */    
    MEMBER_NOT_FOUND(NOT_FOUND, "해당 유저 정보를 찾을 수 없습니다"),  
    EMPTY_CLIENT(NOT_FOUND, "등록된 유저가 없습니다."),  
    NOT_FOUND_CLIENT(NOT_FOUND, "해당 유저를 찾을 수 없습니다."),  
    NOT_FOUND_RECIPE(NOT_FOUND,"해당 레시피를 찾을 수 없습니다."),
    NOT_FOUND_COMMENT(NOT_FOUND, "해당 댓글을 찾을 수 없습니다."),  

    /* 409 CONFLICT : Resource 의 현재 상태와 충돌. 보통 중복된 데이터 존재 */
    DUPLICATE_RESOURCE(CONFLICT, "데이터가 이미 존재합니다"),  

    ;  

    private final HttpStatus httpStatus;  
    private final String detail;  
}
  1. ErrorCode를 내려줄 수 있는 ErrorResponse class를 만든다
  2. @Getter @Builder public class ErrorResponse { private final int status; // private final String error; // private final String code; private final String message; @JsonInclude(JsonInclude.Include.NON_EMPTY) private final List<ValidationError> errors; public static ResponseEntity<ErrorResponse> of(ErrorCode errorCode) { return ResponseEntity .status(errorCode.getHttpStatus()) .body(ErrorResponse.builder() .status(errorCode.getHttpStatus().value()) // .error(errorCode.getHttpStatus().name()) // .code(errorCode.name()) .message(errorCode.getDetail()) .build() ); } public static ResponseEntity<ErrorResponse> from(ErrorCode errorCode, String message){ return ResponseEntity .status(errorcode.getHttpStatus()) .body(ErrorResponse.builder() .status(errorCode.getHttpStatus().value()) .message(message) .build() ); } @Getter @Builder @RequiredArgsConstructor public static class ValidationError{ private String field; private String message; public static ValidationError of(final FieldError fieldError){ return ValidationError.builder() .field(fieldError.getField()) .message(fieldError.getDefaultMessage()) .build(); } } }

참고 : 우리가 클라이언트에 던져주어야 하는 response 형식은

{
    "status" : 200,
    "message" : "ErrorMessage"
}

와 같은 int 형식의 status code와 string형식의 message를 던져주어야 한다. (JuBTI API명세서 참고)만약에 status가 아닌 "code" : "200"이나 "error" : "NOT_FOUND"라면 String 형식을 이용하자. 또한 밑에 return도 무엇을 선택할지에 따라 바꾸어줄 수 있다.

  1. ErrorResponse를 반환할 수 있는 GlobalExceptionHandler를 만든다.
  2. @Slf4j @RestControllerAdvice public class GlobalExceptionHandler extends ResponseEntityExceptionHandler { @ExceptionHandler(value = { ConstraintViolationException.class, DataIntegrityViolationException.class}) protected ResponseEntity<ErrorResponse> handleDataException() { log.error("handleDataException throw Exception : {}", DUPLICATE_RESOURCE); return ErrorResponse.of(DUPLICATE_RESOURCE); } @ExceptionHandler(value = { CustomException.class }) protected ResponseEntity<ErrorResponse> handleCustomException(CustomException e) { log.error("handleCustomException throw CustomException : {}", e.getErrorCode()); return ErrorResponse.of(e.getErrorCode()); } @ExceptionHandler(value = {IllegalArgumentException.class}) protected ResponseEntity<ErrorResponse> handleIllegalArgumentException(IllegalArgumentException e){ ErrorCode errorCode = ErrorCode.INVALID_REQUEST; return ErrorResponse.from(errorCode, e.getMessage()); } protected ErrorResponse makeErrorResponse(ErrorCode errorCode){ return ErrorResponse.builder() .status(errorCode.getHttpStatus().value()) .message(errorCode.getDetail()) .build(); } private ResponseEntity<ErrorResponse> handleExceptionInternal(ErrorCode errorCode) { return ResponseEntity.status(errorCode.getHttpStatus()) .body(makeErrorResponse(errorCode)); } private ResponseEntity<ErrorResponse> handleExceptionInternal(ErrorCode errorCode, String message) { return ResponseEntity.status(errorCode.getHttpStatus()) .body(makeErrorResponse(errorCode, message)); } protected ErrorResponse makeErrorResponse(ErrorCode errorCode, String message){ return ErrorResponse.builder() .status(errorCode.getHttpStatus().value()) .message(message) .build(); }

}

- ConstraintViolationException.class, DataIntegrityViolationException.class는 데이터의 무결성과 관련된 Exception으로 데이터 무결성과 관련된 예외입니다. SQL ERROR라고 생각하면 쉽습니다.

5. 원하는 곳에 Error 처리를 해봅시다.
```java
@Service  
@RequiredArgsConstructor  
public class CommentsService {  
    //작성자 권재현  
    private final RecipeRepository recipeRepository;  
    private final CommentsRepository commentsRepository;  
    private final UserRepository userRepository;  

    @Transactional  
    public StatusResponseDto updateComments(Long commentsid, CommentsRequestDto requestDto,String username) {

        User user = userRepository.findByUsername(username).orElseThrow(
            () -> new CustomException(EMPTY_CLIENT)
        );

        Comments comments = commentsRepository.findById(commentsid).orElseThrow(
            ()->new CustomException(NOT_FOUND_COMMENT));  

        if(!comments.getUser().equals(user)){  
            throw new IllegalArgumentException("본인 댓글만 삭제 가능")
        }

        comments.update(requestDto);  

        return StatusResponseDto.success(200,"수정 완료");  
    }  

}

+ Recent posts