Spring

[Spring] GlobalExceptionHandler 구현하기

후뿡이 2024. 11. 18. 03:06

0. 배경지식


이 글을 이해하기 위해 필요한 간단한 기본 지식을 설명 드리겠습니다.

 

백엔드 개발자에게 예외 처리란 중요한 작업 중 하나입니다.

서비스 운영 시에는 예외를 로깅하고 예외를 가공해서 사용자에게 필요한 정보만을 노출하는 것이 중요합니다.

 

이러한 예외 처리를 Spring 은 다양한 기능으로 추상화 해 개발자의 예외 처리 어려움을 덜어줍니다.

Spring 은 예외 처리를 위해 기본적으로 3 가지의 ExceptionResolver 를 제공합니다.

 

  1. ExceptionHandlerExceptionResolver
  2. ResponseStatusExceptionResolver
  3. DefaultHandlerExceptionResolver

이 ExceptionResolver 가 동작하는 순서는 1 -> 2 -> 3 입니다.

즉 ExceptionHandlerExceptionResolver 가 가장 우선순위가 높습니다.

 

각각에 대해 한 번 간단하게 설명하겠습니다.

DefaultHandlerExceptionResolver

DefaultHandlerExceptionResolver 는 Spring 에서 내부적으로 발생하는 기본적인 예외를 처리해 줍니다.

API 서버의 경우 보통 사용자의 입력값이 잘못된 경우 이 에러가 발생합니다.

예시를 들어보겠습니다.

@Data
@AllArgsConstructor
public class CreateBookDto {

    @NotEmpty(message = "출석부 제목은 필수 입니다")
    private String title;
}

 

위와 같은 DTO 가 있다고 할 때 사용자가 title 에 integer 를 입력하면 어떻게 될까요 ?

 

DefaultHandlerExceptionResolver 가 사용되는 에러 로그

 

에러 로그에 DefaultHandlerExceptionResolver 가 사용되는 것을 확인할 수 있습니다.

저희는 아무것도 설정을 안했는데 말이죠 ?

이는 Spring 이 제공하는 기본적인 ExceptionResolver 중 하나입니다.

 

 

ResponseStatusExceptionResolver

이 Resolver는 주로 HTTP 상태 코드를 지정하는데 사용됩니다.

다른 ExceptionHandler 와 함께 사용되는 경우가 많습니다.

사용 방법은

@ResponseStatus 데코레이터를 ExceptionHandler 위에 달아주면 됩니다.

그러면 Spring 이 위의 어노테이션이 달린 핸들러를 찾아서 HTTP Status 코드를 적용해 줍니다.

 

⭐ ExceptionHandlerExceptionResolver + @ControllerAdvice

가장 중요합니다 !

Spring API Server 에러 핸들링의 꽃이라고 할 수 있습니다.

가장 손쉬운 사용 방법은 @ExceptionHandler 데코레이터를 달아서 사용하는 것이 가장 일반적인 방법입니다.

코드를 보면 더 쉽게 이해가 되실 겁니다.

 

@Slf4j
@RestController
@RequestMapping("/api/v1/book")
@RequiredArgsConstructor
public class AttendanceBookController {

    private final AttendanceBookService attendanceBookService;

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ErrorResponse handleValidationExceptions(MethodArgumentNotValidException ex) {
        log.error("Validation failed", ex);
        Map<String, String> errors = new HashMap<>();
        for (FieldError error : ex.getBindingResult().getFieldErrors()) {
            errors.put(error.getField(), error.getDefaultMessage());
        }
        return new ErrorResponse(HttpStatus.BAD_REQUEST.value(), "Validation failed", errors);
    }

    @Operation(summary = "출석부 생성")
    @PostMapping("")
    public ResponseDto<Long> createAttendanceBook(@RequestBody @Valid CreateBookDto request) {

        return new ResponseDto(200, "Success", 1L);
    }

}

 

위의 코드에서 확인할 수 있는 것처럼

@ExceptionHandler 데코레이터를 통해서 이 Controller 에서 해당 Exception 이 발생했을 때

어떻게 에러를 처리할 건지 설명할 수 있습니다.

MethodArgumentNotValidException 은 Hibernate Validator 가 validation 실패 시에 발생시키는 에러입니다.


위의 DTO 에서 title 을 NotEmpty 로 검증했는데 빈 값을 보내면 어떻게 되는지 확인해 봅시다.

등록한 ExceptionHandler 가 적용된 모습

등록한 ExceptionHandler 가 적용된 모습을 확인할 수 있습니다.

하지만 이상한 부분이 하나 있습니다.

분명 에러가 발생했는데 HTTP Status = 200 인 것이죠.

 

이를 해결하기 위해서 위에서 설명했던 @ResponseStatus 를 사용할 수 있습니다.

ExceptionHandler 코드를 수정해 봅시다.

@ResponseStatus(HttpStatus.BAD_REQUEST)
@ExceptionHandler(MethodArgumentNotValidException.class)
public ErrorResponse handleValidationExceptions(MethodArgumentNotValidException ex) {
    log.error("Validation failed", ex);
    Map<String, String> errors = new HashMap<>();
    for (FieldError error : ex.getBindingResult().getFieldErrors()) {
        errors.put(error.getField(), error.getDefaultMessage());
    }
    return new ErrorResponse(HttpStatus.BAD_REQUEST.value(), "Validation failed", errors);
}

 

위와 같이 @ResponseStatus(HttpStatus.BAD_REQUEST) 데코레이터를 달아주었습니다.

결과를 확인해 봅시다.

 

HTTP Status 가 400으로 나온 모습

 

HTTP Status = 400 으로 나오는 것을 확인할 수 있습니다.

 

이렇게 Spring 에서 제공하는 @ExceptionHandler 를 이용하면 에러처리를 쉽게 할 수 있습니다.

 

2. @ControllerAdvice 의 필요성


하지만 이렇게 일일히 Controller 별로 @ExceptionHandler 를 다는 것은 단점이 있습니다.

 

  1. 불필요한 코드의 중복이 발생합니다 => 모든 Controller 에 달아줘야 하므로
  2. 에러처리 로직과 정상흐름 로직이 한 곳에 섞여 있어 가독성이 떨어질 수 있습니다.

위와 같은 문제들을 이미 선조들이 겪었고 이 문제를 해결하기 위한 기능이 이미 존재합니다.

바로 @ControllerAdvice 입니다.

 

기능은 간단합니다. 대상으로 지정한 컨트롤러에 @ExceptHandler, @InitBinder 를 적용해준다.

이 얘기가 뭔지 와닿지 않을 수 있는데 코드로 보면 쉽게 이해가 갑니다.

 

@RestControllerAdvice
public class GlobalExceptionHandler {

    private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);

    @ResponseStatus(HttpStatus.BAD_REQUEST)
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ErrorResponse handleValidationExceptions(MethodArgumentNotValidException ex) {
        log.error("Validation failed", ex);
        Map<String, String> errors = new HashMap<>();
        for (FieldError error : ex.getBindingResult().getFieldErrors()) {
            errors.put(error.getField(), error.getDefaultMessage());
        }
        return new ErrorResponse(HttpStatus.BAD_REQUEST.value(), "Validation failed", errors);
    }
}

 

아까 AttendanceBookController 에서 만든 @ExceptionHandler 를 GlobalExceptionHandler로 옮겨 왔습니다.

@RestControllerAdvice 데코레이터를 달아주면 작성한 class 가 ControllerAdvice 기능을 한다는 얘기입니다.

( 참고 : @RestControllerAdvice = @ControllerAdvice + @ResponseBody )

 

아까와 같이 MethodArgumentNotValidException 를 처리하는 메소드를 만들어 주었습니다.

지금은 Controller 내부에 @ExceptionHandler 를 단 것이 아닌 @ControllerAdvice 내부에 구현을 해주었습니다.

 

다시 한번 에러를 호출하면 어떻게 될까요 ?

똑같이 에러 처리가 된 모습

 

역시나 똑같이 에러 처리가 된 것을 확인할 수 있습니다.

 

이처럼 @ControllerAdvice 는 등록한 ExceptionHandler 를 지정한 Controller 에 등록해주는 역할을 합니다.

그리고 지금처럼 Controller를 지정하지 않은 경우에는 모든 Controller 에 ExceptionHandler 를 적용해 줍니다?

이 얘기 뭔가 느낌이 오시지 않나요 ?

바로 GlobalExceptionHandler 입니다.

 

3. GlobalExceptionHandler


위에서 설명한 @ControllerAdvice@ExceptionHandler 를 적용한다면

모든 Controller 에서 발생하는 에러를 처리할 수 있는 GlobalExceptionHandler 를 구현할 수 있습니다.

( 이미 위에서 구현을 해보았네요 )

 

그리고 특정 클래스에만 적용이 필요하다면 @ExceptionHandler 에 Class 를 지정해 주어 특정 경로에만 적용할 수 있습니다.

이 때 대상 지정은 아래의 세 가지 방법이 있습니다.

  1. Controller 클래스 종류를 지정
  2. 패키지 경로를 지정
  3. 특정 Controller 를 지정

이렇게 GlobalExceptionHandler 내에서 특정 컨트롤러에 대한 에러처리도 가능하다는 것이죠.

 

4. GlobalExceptionHandler 에서 못잡는 에러는 ?


만약 GlobalExceptionHandler 에서 처리하지 않는 Exception 이 있다면 어떻게 될까요 ?

그러면 배경지식에서 설명드렸던 것처럼 아래의 ExceptionResolver 를 탐색하면 에러를 처리합니다.

 

ResponseStatusExceptionResolver

DefaultHandlerExceptionResolver

 

만약 여기서도 잡히지 않는 오류가 발생한다면 ... 끔찍하네요 ...