지난번에 작성했던 코드들은 애플리케이션에서 발생할 수 있는 예외를 처리하는 프로세스가 전혀 적용되지 않았었다.
그래서 애플리케이션에 어떤 예외가 발생했는지 클라이언트 쪽에서 구체적으로 알 수 있는 방법이 없었기 때문에, 지난번에 작성한 샘플 코드에 예외 처리를 적용해 보는 실습을 진행해봤다.
{
"timestamp": "2022-10-25T07:58:16.228+00:00",
"status": 400,
"error": "Bad Request",
"path": "/v1/members"
}
위와 같은 Response Body
의 내용만으로는 요청 데이터 중 어떤 항목이 유효성 검사에 실패했는지 알 수 없다.
이전 실습에서 클라이언트가 전달 받는 Response Body
는 애플리케이션에서 예외가 발생했을 때, 내부적으로 Spring에서 전송해주는 에러 응답 메시지 중 하나이다.
Spring이 처리하는 에러 응답 메시지를 직접 처리하도록 코드를 수정해봤다.
@RestController
@RequestMapping("/v6/members")
@Validated
@Slf4j
public class MemberController {
...
@ExceptionHandler
public ResponseEntity handleException(MethodArgumentNotValidException e) {
// (1)
final List<FieldError> fieldErrors = e.getBindingResult().getFieldErrors();
// (2)
return new ResponseEntity<>(fieldErrors, HttpStatus.BAD_REQUEST);
}
}
MemberController 클래스에 @ExceptionHandler
애너테이션을 통해 예외를 처리할 수 있게 handleException()
메서드를 추가한다.
유효성 검증에 실패할 경우 예외 처리 과정
클라이언트 쪽에서 회원 등록을 위해
MemberController
의postMember()
핸들러 메서드에 요청을 전송한다.
RequestBody
에 유효하지 않은 요청 데이터가 포함되어 있어 유효성 검증에 실패하면,MethodArgumentNotValidException
이 발생하게 된다.
MemberController
에는@ExceptionHandler
애너테이션이 추가된 예외 처리 메서드handleException()
이 있기 때문에 유효성 검증 과정에서 내부적으로 던져진MethodArgumentNotValidException
을handleException()
메서드가 전달 받는다.
(1)과 같이MethodArgumentNotValidException
객체에서getBindingResult().getFieldErrors()
를 통해 발생한 에러 정보를 확인할 수 있다.
(1)에서 얻은 에러 정보를 (2)에서ResponseEntity
를 통해Response Body
로 전달한다.
이제 postman에서 회원 등록 요청을 전송해보자.
회원 등록 정보에서 유효하지 않은 이메일 주소를 포함하여 요청을 전송해보면 이전과는 다른 응답 메시지를 전달 받게 될 것이다.
MemberController의 handleException()
메서드에서 유효성 검사 실패에 대한 에러 메시지를 구체적으로 전송해주기 때문에 클라이언트 입장에서는 어느 곳에 문제가 있는지를 구체적으로 알 수 있게 되었다.
다만, 의미를 알 수 없는 정보를 모두 포함한 Response Body
의 전체 정보를 다 전달 받게 되므로, 요청 전송 시 Request Body
의 JSON 프로퍼티 중 문제가 된 프로퍼티가 무엇인지 정도만 전달 받아도 될 것이다.
위에서 확인한 에러 정보를 기반으로 Error Response
클래스를 만들어서 필요한 정보만 담은 후에 클라이언트 쪽에 전달해주면 된다.
@Getter
@AllArgsConstructor
public class ErrorResponse {
// (1)
private List<FieldError> fieldErrors;
@Getter
@AllArgsConstructor
public static class FieldError {
private String field;
private Object rejectedValue;
private String reason;
}
}
확인했던 Response Body
를 보면 JSON 응답 객체가 배열()로 되어있었는데, 그 이유는 DTO 클래스에서 검증해야 할 멤버 변수에서 유효성 검증에 실패하는 변수가 하나 이상 될 수 있기 때문이다. 즉, 유효성 검증 실패 에러 역시 하나 이상 될 수 있다는 의미다.
그래서 하나 이상의 윻성 검증에 실패한 필드의 에러 정보를 담기 위해 List 객체를 이용했다.
@RestController
@RequestMapping("/v7/members")
@Validated
@Slf4j
public class MemberController {
...
@ExceptionHandler
public ResponseEntity handleException(MethodArgumentNotValidException e) {
// (1)
final List<FieldError> fieldErrors = e.getBindingResult().getFieldErrors();
// (2)
List<ErrorResponse.FieldError> errors =
fieldErrors.stream()
.map(error -> new ErrorResponse.FieldError(
error.getField(),
error.getRejectedValue(),
error.getDefaultMessage()))
.collect(Collectors.toList());
return new ResponseEntity<>(new ErrorResponse(errors), HttpStatus.BAD_REQUEST);
}
}
위에서 (1)의 List<FieldError>
를 통째로 ResponseEntity
클래스에 실어서 전달했었다.
이번에는 (2)와 같이 필요한 정보들만 ErrorResponse.FieldError
클래스에 담아서 List로 변환하고 List<ErrorResponse.FieldError>
를 ResponseEntity
클래스에 실어서 전달한다.
여기까지 하면 유효성 검증에 실패한 필드가 2개일 경우 에러 정보 역시 2개를 보여주고, 필요한 정보만을 표시하고 있음을 알 수 있다.
@ExceptionHandler
애너테이션과 ErrorResponse
클래스를 이용하여 Request Body
에 대한 유효성 검증 실패 시 필요한 에러 정보만 담아서 클라이언트에게 응답으로 전송할 수 있게 되었다.
하지만, @ExceptionHandler
애너테이션을 사용해 Request Body
에 대해 유효성 검증 실패에 대한 에러 처리를 해야하므로 각각의 Controller 클래스마다 코드 중복이 발생한다.
또 Controller에서 처리해야 할 예외가 유요성 검증 실패에 대한 것만 있지 않기 때문에 하나의 Controller 클래스 내에 @ExceptionHandler
를 추가한 에러 처리 핸들러 메서드가 증가한다.
Key Summary!
Controller 클래스 레벨에서 @ExceptionHandler
애너테이션을 사용해 해당 Controller에서 발생하는 예외를 처리할 수 있다.
필요한 에러 정보만 담을 수 있는 Error 전용 Response 객체를 사용하여 클라이언트에게 친절한 에러 정보를 제공할 수 있다.
@ExceptionHandler
애너테이션을 사용한 방법은 Controller마다 동일하게 발생하는 예외 처리에 대한 중복 코드가 발생할 수 있다.
또한, 다양한 유형의 예외를 처리하기엔 적합하지 않다.
@ExceptionHandler
를 사용했을 때 예외 처리를 위한 코드에 중복이 발생하는 문제를 개선하는 방법을 알아보자.
특정 클래스에 @RestControllerAdvice
애너테이션을 추가하면 여러 Controller 클래스에서 @ExceptionHandler
, @InitBinder
, @ModelAttribute
가 추가된 메서드를 공유해서 사용할 수 있다.
즉, @RestControllerAdvice
애너테이션을 추가한 클래스를 이용하면 예외 처리를 공통화 할 수 있다는 것이다.
@RestControllerAdvice
public class GlobalExceptionAdvice {
// (1)
@ExceptionHandler
public ResponseEntity handleMethodArgumentNotValidException(
MethodArgumentNotValidException e) {
final List<FieldError> fieldErrors = e.getBindingResult().getFieldErrors();
List<ErrorResponse.FieldError> errors =
fieldErrors.stream()
.map(error -> new ErrorResponse.FieldError(
error.getField(),
error.getRejectedValue(),
error.getDefaultMessage()))
.collect(Collectors.toList());
return new ResponseEntity<>(new ErrorResponse(errors), HttpStatus.BAD_REQUEST);
}
// (2)
@ExceptionHandler
public ResponseEntity handleConstraintViolationException(
ConstraintViolationException e) {
// TODO should implement for validation
return new ResponseEntity<>(HttpStatus.BAD_REQUEST);
}
}
위 코드는 MemberController에 추가했던 Exception 핸들러 메서드의 로직을 그대로 가져온 것이다.
@RestControllerAdvice 애너테이션을 이용해서 예외 처리를 공통화 하면 각 Controller마다 추가되는 @ExceptionHandler 로직에 대한 중복 코드를 제거하고, Controller의 코드를 단순화 할 수 있다.
GlobalExceptionAdvice를 통해 Controller 클래스에서 발생하는 Request Body
의 유효성 검증에 대한 에러는 유연한 처리가 가능해졌다.
그러나 URI 변수로 넘어오는 값의 유효성 검증에 대한 에러 처리는 아직 구현되지 않았다.
이 부분을 처리하기 전에 ErrorResponse 클래스가 ConstraintViolationException
에 대한 Error Response
를 생성할 수 있도록 ErrorResponse 클래스를 수정한다.
// 코드 생략
기능이 늘어남에 따라 ErrorResponse 클래스의 구현 복잡도가 늘어날 수 있지만, 에러 유형에 따른 에러 정보 생성 역할을 분리함으로써 ErrorResponse
를 사용하는 입장에서 한층 더 사용하기 편리해졌다.
of()
메서드
of()
메서드는 Java8의 API에서도 흔히 볼 수 있는 Naming Convention이다.
주로 객체 생성시에 어떤 값의 객체를 생성한다는 의미에서 of()
메서드를 사용한다.
@RestControllerAdvice
public class GlobalExceptionAdvice {
@ExceptionHandler
@ResponseStatus(HttpStatus.BAD_REQUEST)
public ErrorResponse handleMethodArgumentNotValidException(
MethodArgumentNotValidException e) {
final ErrorResponse response = ErrorResponse.of(e.getBindingResult());
return response;
}
@ExceptionHandler
@ResponseStatus(HttpStatus.BAD_REQUEST)
public ErrorResponse handleConstraintViolationException(
ConstraintViolationException e) {
final ErrorResponse response = ErrorResponse.of(e.getConstraintViolations());
return response;
}
}
수정된 ErrorResponse 클래스를 사용할 수 있게 수정한 GlobalExceptionAdvice 클래스 코드이다.
이전 코드와 비교했을 때, Error Response
정보를 만드는 역할을 ErrorResponse 클래스가 대신 해주므로 코드가 간결해졌다.
또한 이전 코드에서는 Error Response
객체를 ResponseEntity
로 래핑하여 리턴했었는데, 수정한 코드에서는 ResponseEntity
가 사라지고 ErrorResponse
객체를 바로 리턴하고 있다.
@ResponseStatus
애너테이션을 이용해 HTTP Status를 HTTP Response에 포함하고 있다.
@RestControllerAdvice
vs@ControllerAdvice
@RestControllerAdvice
와 @ControllerAdvice
의 차이점은 아래와 같다.
@RestControllerAdvice
= @ControllerAdvice
+ @ResponseBody
@RestControllerAdvice
애너테이션은 @ControllerAdvice
와 @ResponseBody
의 기능을 포함한다.
JSON 형식의 데이터를 Response Body로 전송하기 위해 ResponseEntity
로 데이터를 래핑할 필요가 없다.
Key Summary!
@RestControllerAdvice
애너테이션을 추가한 클래스를 사용하면 예외 처리를 공통화 할 수 있다.
또한, JSON 형식의 데이터를 Response Body로 전송하기 위해ResponseEntity
로 래핑할 필요가 없다.
@ResponseStatus
애너테이션으로 HTTP Status를 대신 표현할 수 있다.