최근에 채용 과제를 진행하면서 개발한 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"
}
결과적으로 위와 같은 응답이 오는데, 저는 공통 응답 포맷으로 해당 내용을 변환하여 반환하고 싶었습니다.
Validation은 @Valid
또는 @Validated
어노테이션을 사용하여 검증 여부를 설정할 수 있습니다.
검증이 처리되고, 통과하지 못한다면 예외가 던져지는데 상황에 따라 예외의 타입이 달라집니다.
@Valid
로 @RequestBody
에 매핑되는 DTO 클래스를 검증할 때 던져지는 예외는 MethodArgumentNotValidException
으로, 위에서 언급한 DefaultHandlerExceptionResolver
라는 기본 예외 핸들러에서 HTTP Status 400
으로 기본 응답을 반환합니다.
하지만, @PathVariable
에 매핑되는 경로 파라미터 또는 @RequestParam
에 매핑되는 쿼리 파라미터를 검증할 때 던져지는 예외는 ConstraintViolationException
타입입니다. 해당 타입은 DefaultHandlerExceptionResolver
에 핸들러가 선언되어 있지 않기 때문에 HTTP Status 500
으로 처리됩니다. 따라서, 이 상황에 적합한 HTTP Status 400
을 반환하기 위해서는 커스텀 예외 핸들러를 추가해야 합니다.
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);
}
}
ConstraintViolationException
와 MethodArgumentNotValidException
각각에 대한 핸들러 메소드를 선언하고 각 예외의 데이터를 파싱해 공통 응답 객체에 맞게 변환해주어 반환하는 로직을 구현했습니다.
추가로 예상치 못한 예외에 대해서도 공통 응답 포맷에 맞추기 위해 모든 예외들의 부모인 Exception
타입을 처리하는 핸들러를 추가로 구현했습니다.