Validation 예외 핸들러로 응답 포맷 수정하기

Gongmeda·2023년 2월 12일
0
post-thumbnail

최근에 채용 과제를 진행하면서 개발한 API 서버에서 다음과 같은 공통된 응답 포맷을 사용했습니다.

{
	"data": {
		// some data
	},
	"error": "SomeException",
	"message": null
}
@Getter
@AllArgsConstructor(access = AccessLevel.PRIVATE)
public class Response<T> {

    private T data;
    private String error;
    private String message;

    public static <T> Response<T> data(final T data) {
        return new Response<>(data, null, null);
    }

    public static <T> Response<T> error(
        final T data,
        final String error,
        @Nullable final String message
    ) {
        return new Response<>(data, error, message);
    }
}

이와 같이 공통된 응답 포맷을 사용하면 클라이언트 개발자 입장에서 응답을 파싱하고 처리하기 쉬워지고, 문제가 생겼을 때 상황을 좀 더 명확하게 전달할 수 있다는 장점이 있습니다.

스프링 MVC에서 공통 응답 포맷을 적용하기 위해서는 여러가지 방법이 있습니다.

단순히 Controller에서 응답할 데이터를 공통 응답 객체로 wrapping해서 반환해도 되고, 중복을 줄이고 싶다면 AOP 를 활용해서 반환되는 객체를 공통 포맷으로 만들어주는 pipeline을 구성할 수도 있습니다.

하지만 예외가 던져지는 경우에는 pipeline 마저 통과하지 않습니다.

위는 요청의 body를 매핑하는 DTO에서 Validation을 통과하지 못해서 찍힌 로그인데, DefaultHandlerExceptionResolver 가 던져진 예외를 처리했음을 알 수 있습니다.

{
	"timestamp": "2023-02-12T11:51:14.532+00:00",
	"status": 400,
	"error": "Bad Request",
	"path": "/path"
}

결과적으로 위와 같은 응답이 오는데, 저는 공통 응답 포맷으로 해당 내용을 변환하여 반환하고 싶었습니다.

MethodArgumentNotValidException vs ConstraintViolationException

Validation은 @Valid 또는 @Validated 어노테이션을 사용하여 검증 여부를 설정할 수 있습니다.

검증이 처리되고, 통과하지 못한다면 예외가 던져지는데 상황에 따라 예외의 타입이 달라집니다.

@Valid@RequestBody 에 매핑되는 DTO 클래스를 검증할 때 던져지는 예외는 MethodArgumentNotValidException 으로, 위에서 언급한 DefaultHandlerExceptionResolver 라는 기본 예외 핸들러에서 HTTP Status 400 으로 기본 응답을 반환합니다.

하지만, @PathVariable 에 매핑되는 경로 파라미터 또는 @RequestParam 에 매핑되는 쿼리 파라미터를 검증할 때 던져지는 예외는 ConstraintViolationException 타입입니다. 해당 타입은 DefaultHandlerExceptionResolver 에 핸들러가 선언되어 있지 않기 때문에 HTTP Status 500 으로 처리됩니다. 따라서, 이 상황에 적합한 HTTP Status 400 을 반환하기 위해서는 커스텀 예외 핸들러를 추가해야 합니다.

Validation 예외 핸들러

ControllerAdvice 로 커스텀 예외 핸들러를 생성할 수 있습니다. ControllerAdvice 는 Controller들에 전역으로 Advice를 제공할 수 있는 Spring MVC의 기능입니다.

ControllerAdvice 를 생성하기 위해 @RestControllerAdvice 어노테이션을 사용하는데, 이는 @ControllerAdvice@ResponseBody 를 합친 편의성 어노테이션입니다. @ResponseBody 는 반환값을 View가 아닌 응답 body에 매핑하기 위해 사용하는 어노테이션입니다.

@ExceptionHandler 로 예외 타입에 대한 핸들러를 매핑하고, @ResponseStatus 로 응답의 HTTP Status를 설정할 수 있습니다.

@RestControllerAdvice
public class ExceptionHandlerAdvice {

    @ExceptionHandler(ConstraintViolationException.class)
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    Response<Object> onConstraintValidationException(ConstraintViolationException e) {
        Map<String, String> errors = e.getConstraintViolations().stream()
            .collect(Collectors.toMap(
                violation -> StreamSupport.stream(violation.getPropertyPath().spliterator(), false)
                    .reduce((first, second) -> second)
                    .get().toString(),
                ConstraintViolation::getMessage
            ));
        return Response.error(errors, e.getClass().getSimpleName(), null);
    }

    @ExceptionHandler(MethodArgumentNotValidException.class)
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    Response<Object> onMethodArgumentNotValidException(MethodArgumentNotValidException e) {
        Map<String, String> errors = e.getBindingResult().getFieldErrors().stream()
            .collect(Collectors.toMap(
                FieldError::getField,
                fieldError -> Optional.ofNullable(fieldError.getDefaultMessage()).orElse("")
            ));
        return Response.error(errors, e.getClass().getSimpleName(), null);
    }

    @ExceptionHandler(Exception.class)
    @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
    Response<Object> onException(Exception e) {
        e.printStackTrace();
        return Response.error(null, e.getClass().getSimpleName(), null);
    }
}

ConstraintViolationExceptionMethodArgumentNotValidException 각각에 대한 핸들러 메소드를 선언하고 각 예외의 데이터를 파싱해 공통 응답 객체에 맞게 변환해주어 반환하는 로직을 구현했습니다.

추가로 예상치 못한 예외에 대해서도 공통 응답 포맷에 맞추기 위해 모든 예외들의 부모인 Exception 타입을 처리하는 핸들러를 추가로 구현했습니다.

참조

profile
백엔드 깎는 장인

0개의 댓글