eelseungmin

[Spring] Bean Validation 사용 시 발생하는 예외를 ControllerAdvice로 관리하기

by eelseungmin

배경

서비스를 개발하면서 Controller에 validation 코드를 일일이 작성하는 대신, 대부분의 역할을 Bean Validation에 이관했다. 다만 기존에 작성했던 ControllerAdvice는 Bean Validation 사용 시 발생하는 예외를 완전하게 처리하지는 못하고 있었다. 이후 다음과 같은 과정을 통해 문제를 해결했다.

 

해결 과정

다음은 기존에 작성했던 ControllerAdvice이다.

@Slf4j
@RestControllerAdvice
public class ExceptionAdvice {

    @ExceptionHandler(Exception400.class)
    public ResponseEntity<?> badRequest(Exception400 e) {
        log.warn("400: " + e.getMessage());
        return ResponseEntity
                .status(e.status())
                .body(e.body());
    }

    @ExceptionHandler(Exception401.class)
    public ResponseEntity<?> unauthorized(Exception401 e) {
        log.warn("401: " + e.getMessage());
        return ResponseEntity
                .status(e.status())
                .body(e.body());
    }

    @ExceptionHandler(Exception403.class)
    public ResponseEntity<?> forbidden(Exception403 e) {
        log.warn("403: " + e.getMessage());
        return ResponseEntity
                .status(e.status())
                .body(e.body());
    }

    @ExceptionHandler(Exception404.class)
    public ResponseEntity<?> notFound(Exception404 e) {
        log.warn("404: " + e.getMessage());
        return ResponseEntity
                .status(e.status())
                .body(e.body());
    }

    @ExceptionHandler(Exception500.class)
    public ResponseEntity<?> serverError(Exception500 e) {
        log.error("500: " + e.getMessage(), e);
        return ResponseEntity
                .status(e.status())
                .body(e.body());
    }

    @ExceptionHandler(Exception.class)
    public ResponseEntity<?> unknownServerError(Exception e) {
        log.error("500: " + e.getMessage(), e);
        return ResponseEntity
                .status(HttpStatus.INTERNAL_SERVER_ERROR)
                .body(new ApiFailResponse(
                        HttpStatus.INTERNAL_SERVER_ERROR,
                        e.getMessage()
                ));
    }
}

 

그리고 아래는 특정 Controller의 코드다,

@PostMapping("/api/vote/{voteId}/result")
    public ResponseEntity<ApiSuccessResponse<VoteResultsResponse>> getVoteResult(
            @Positive(message = "voteId는 양수여야 합니다.") @PathVariable Long voteId,
            BindingResult requestParambindingResult,
            @Validated @RequestBody VoteResultRequest voteResultRequest,
            BindingResult requestBodybindingResult
    ) {
        bindingResultResolver(requestParambindingResult);
        bindingResultResolver(requestBodybindingResult);

        // 생략
    }

    // BindingResult에 에러가 있을 시(@Validated에 의해 유효성 검사를 통과하지 못한 경우) Exception400을 던진다.
    private void bindingResultResolver(BindingResult bindingResult) {
        if (bindingResult.hasErrors()) {
            throw new Exception400(
                    bindingResult.getFieldErrors().get(0).getField(),
                    bindingResult.getFieldErrors().get(0).getDefaultMessage()
            );
        }
    }

기존에는 BindingResult를 통해 @Validated로 발생하는 예외를 잡아 커스텀 예외인 Exception400으로 처리해주었는데, 이렇게 하니 @PathVariable이나 @RequestParam을 검증할 때 발생하는 예외는 Exception400이 아니라 Exception500(서버가 처리하지 못한 예외)으로 처리되고 있었다.

@RequestBody를 @Validated로 검증할 때는 'MethodArgumentNotValidException' 예외가 발생하지만, @PathVariable이나 @RequestParam을 검증할 때는 'ConstraintViolationException'이라는 다른 종류의 예외가 발생한다.

 

MethodArgumentNotValidException 예외 코드를 까보면 BindException이라는 예외를 상속하고 있고, BindException은 내부에 BindingResult를 품고 있다.

 

반면 ConstraintViolationException 예외는 내부에 ConstraintViolation 객체 타입의 Set을 포함하고 있으며 ValidationException을 상속하고 있다. 관련된 코드를 살펴봐도 BindingResult와 연관된 코드는 보이지 않았다.

 

이렇듯 두 가지 케이스가 아예 다른 예외로 처리되므로 BindingResult를 통해 동시에 예외 처리를 할 수 없었던 것이다.

그래서 ControllerAdvice에서 두 가지 예외를 처리해주는 ExceptionHandler를 구현하여 문제를 해결하고 이를 통해 Controller마다 추가해줘야 하는 코드까지 줄여보았다.

 

@Slf4j
@RestControllerAdvice
public class ExceptionAdvice {

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<?> handleMethodArgumentNotValidException(MethodArgumentNotValidException e) {
        log.warn("400: " + e.getMessage());
        return ResponseEntity
                .status(HttpStatus.BAD_REQUEST)
                .body(new ApiFailResponse(
                        HttpStatus.BAD_REQUEST,
                        e.getFieldErrors().get(0).getField(),
                        e.getFieldErrors().get(0).getDefaultMessage())
                );
    }

    @ExceptionHandler(ConstraintViolationException.class)
    public ResponseEntity<?> handleConstraintViolationException(ConstraintViolationException e) {
        log.warn("400: " + e.getMessage());
        return ResponseEntity
                .status(HttpStatus.BAD_REQUEST)
                .body(new ApiFailResponse(
                        HttpStatus.BAD_REQUEST,
                        e.getConstraintViolations().stream()
                                .map(ConstraintViolation::getInvalidValue)
                                .map(o -> String.valueOf(o))
                                .collect(Collectors.joining()),
                        e.getConstraintViolations().stream()
                                .map(ConstraintViolation::getMessage)
                                .collect(Collectors.joining()))
                );
    }

    @ExceptionHandler(Exception400.class)
    public ResponseEntity<?> badRequest(Exception400 e) {
        log.warn("400: " + e.getMessage());
        return ResponseEntity
                .status(e.status())
                .body(e.body());
    }

    @ExceptionHandler(Exception401.class)
    public ResponseEntity<?> unauthorized(Exception401 e) {
        log.warn("401: " + e.getMessage());
        return ResponseEntity
                .status(e.status())
                .body(e.body());
    }

    @ExceptionHandler(Exception403.class)
    public ResponseEntity<?> forbidden(Exception403 e) {
        log.warn("403: " + e.getMessage());
        return ResponseEntity
                .status(e.status())
                .body(e.body());
    }

    @ExceptionHandler(Exception404.class)
    public ResponseEntity<?> notFound(Exception404 e) {
        log.warn("404: " + e.getMessage());
        return ResponseEntity
                .status(e.status())
                .body(e.body());
    }

    @ExceptionHandler(Exception500.class)
    public ResponseEntity<?> serverError(Exception500 e) {
        log.error("500: " + e.getMessage(), e);
        return ResponseEntity
                .status(e.status())
                .body(e.body());
    }

    @ExceptionHandler(Exception.class)
    public ResponseEntity<?> unknownServerError(Exception e) {
        log.error("500: " + e.getMessage(), e);
        return ResponseEntity
                .status(HttpStatus.INTERNAL_SERVER_ERROR)
                .body(new ApiFailResponse(
                        HttpStatus.INTERNAL_SERVER_ERROR,
                        e.getMessage()
                ));
    }
}
@PostMapping("/api/vote/{voteId}/result")
    public ResponseEntity<ApiSuccessResponse<VoteResultsResponse>> getVoteResult(
            @Positive(message = "voteId는 양수여야 합니다.") @PathVariable Long voteId,
            @Validated @RequestBody VoteResultRequest voteResultRequest
    ) {

        // 생략
    }

검증할 때마다 작성해야 했던 코드와 파라미터(BindingResult)가 깔끔하게 사라진 것을 확인할 수 있다.

블로그의 정보

eel.log

eelseungmin

활동하기