@Validated
@RestController
@RequestMapping("/v1/users")
@RequiredArgsConstructor
public class UserController {
private final UserService userService;
private final UserMapper mapper;
@PostMapping
public ResponseEntity postUser(@Valid @RequestBody UserPostDto userPostDto) {
User user = mapper.userPostDtoToUser(userPostDto);
User response = userService.userCreate(user);
return new ResponseEntity<>(mapper.userToUserResponseDto(response), HttpStatus.CREATED);
}
...
@ExceptionHandler
public ResponseEntity handleException(MethodArgumentNotValidException e) {
final List<FieldError> fieldErrors = e.getBindingResult().getFieldErrors();
return new ResponseEntity<>(fieldErrors, HttpStatus.BAD_REQUEST);
}
}
Controller 단에서 @ExcetpionHandler
어노테이션을 이용하여 예외처리하도록 handleException() 메서드를 추가하였다.
해당 메서드는 회원 등록을 하는데 있어서 필요한 데이터를 Request Body로 받아오는 UserPostDto를 검증하는데 발생하는 예외를 처리하는 메서드이다.
[응답 메시지]
handleException() 메서드에서 유효성 검사 실패에 대한 에러 메시지를 구체적으로 전송해주기 때문에 클라이언트 입장에서는 이제 어느 곳에 문제가 있는지를 구체적으로 알 수 있게 되었다. 하지만 클라이언트 입장에서는 문제가 발생한 곳, 에러 메시지 정도만 충분해 보인다. 이 때 ResponseDto에 담아서 보내면 좀 더 깔끔해진다.
@Getter
@AllArgsConstructor
public class ErrorResponse {
private List<FieldError> fieldErrors;
@Getter
@AllArgsConstructor
public static class FieldError {
private String field;
private Object rejectedValue;
private String reason;
}
}
@Validated
@RestController
@RequestMapping("/v1/users")
@RequiredArgsConstructor
public class UserController {
private final UserService userService;
private final UserMapper mapper;
@PostMapping
public ResponseEntity postUser(@Valid @RequestBody UserPostDto userPostDto) {
User user = mapper.userPostDtoToUser(userPostDto);
User response = userService.userCreate(user);
return new ResponseEntity<>(mapper.userToUserResponseDto(response), HttpStatus.CREATED);
}
...
@ExceptionHandler
public ResponseEntity handleException(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);
}
}
handleException() 메서드에 ErrorResponseDto를 적용시켰다.
다음과 같이 전에 비해서 더 깔끔한 Response를 받을 수 있다.
각각의 Controller 클래스에서 @ExceptionHandler 애너테이션을 사용하여 Request Body에 대한 유효성 검증 실패에 대한 에러 처리를 해야되므로 각 Controller 클래스마다 코드 중복이 발생한다.
Controller에서 처리해야 되는 예외(Exception)가 유효성 검증 실패에 대한 예외(MethodArgumentNotValidException)만 있는것이 아니기 때문에 하나의 Controller 클래스 내에서 @ExceptionHandler
를 추가한 에러 처리 핸들러 메서드가 늘어난다.
특정 클래스에 @RestControllerAdvice
어노테이션을 추가하면 여러개의 Controller 클래스에서 @ExceptionHandler
, @InitBinder
또는 @ModelAttribute
가 추가된 메서드를 공유해서 사용할 수 있다.
예외처리 관점에서 본다면 @RestControllerAdvice
어노테이션을 이용한다면 예외처리를 공통화할 수 있다. 이는 AOP와 관련이 있다.
@RestControllerAdvice
에 속성값을 넣어 어노테이션 적용 범위를 다음과 같이 설정할 수 있다.
@RestControllerAdvice(basePackageClasses = TestController.class)
@RestControllerAdvice(basePackages = "com.demo.controller")
@InitBinder
와@ModelAttribute
어노테이션
@InitBinder
와@ModelAttribute
어노테이션은 JSP, Thymeleaf 같은 서버 사이드 렌더링(SSR, Server Side Rendering) 방식에서 주로 사용되는 방식이다.
@InitBinder
: 해당 Controller로 들어오는 요청에 대해 추가적인 설정을 하고 싶을때 사용@ModelAttribute
:@RequestParam
과 동일한 역할을 수행하며 클라이언트가 요청시 전달하는 값을 오브젝트 형태로 매핑해준다.
@RestControllerAdvice
public class GlobalExceptionAdvice {
@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);
}
@ExceptionHandler
public ResponseEntity handleConstraintViolationException(
ConstraintViolationException e) {
return new ResponseEntity<>(HttpStatus.BAD_REQUEST);
}
}
Controller 단에서 선언하였던 ExceptionHandler 메서드가 GlobalExceptionAdvice 클래스 내에 추가되었다. 이렇게 함으로써 예외처리를 공통화 할 수 있게 된다.
@RestController
VS@ControllerAdvice
@RestController
=@Controller
+@ResponseBody
이다.
즉,@RestControllerAdvice
는@ControllerAdvice
의 기능을 포함하면서 JSON 형식의 데이터를 Response Body로 전송할 수 있다.
또한@RestController
는 Restful API에 적합하고@Controller
는 MVC에 적합하다.
@Getter
public class ErrorResponse {
private List<FieldError> fieldErrors;
private List<ConstraintViolationError> violationErrors;
private ErrorResponse(List<FieldError> fieldErrors,
List<ConstraintViolationError> violationErrors) {
this.fieldErrors = fieldErrors;
this.violationErrors = violationErrors;
}
public static ErrorResponse of(BindingResult bindingResult) {
return new ErrorResponse(FieldError.of(bindingResult), null);
}
public static ErrorResponse of(Set<ConstraintViolation<?>> violations) {
return new ErrorResponse(null, ConstraintViolationError.of(violations));
}
@Getter
public static class FieldError {
private String field;
private Object rejectedValue;
private String reason;
private FieldError(String field, Object rejectedValue, String reason) {
this.field = field;
this.rejectedValue = rejectedValue;
this.reason = reason;
}
public static List<FieldError> of(BindingResult bindingResult) {
final List<org.springframework.validation.FieldError> fieldErrors =
bindingResult.getFieldErrors();
return fieldErrors.stream()
.map(error -> new FieldError(
error.getField(),
error.getRejectedValue() == null ?
"" : error.getRejectedValue().toString(),
error.getDefaultMessage()))
.collect(Collectors.toList());
}
}
@Getter
public static class ConstraintViolationError {
private String propertyPath;
private Object rejectedValue;
private String reason;
private ConstraintViolationError(String propertyPath, Object rejectedValue,
String reason) {
this.propertyPath = propertyPath;
this.rejectedValue = rejectedValue;
this.reason = reason;
}
public static List<ConstraintViolationError> of(
Set<ConstraintViolation<?>> constraintViolations) {
return constraintViolations.stream()
.map(constraintViolation -> new ConstraintViolationError(
constraintViolation.getPropertyPath().toString(),
constraintViolation.getInvalidValue().toString(),
constraintViolation.getMessage()
)).collect(Collectors.toList());
}
}
}
ErrorResponse에 DTO 유효성 검증에서 발생하는 MethodArgumentNotValidException 에러를 담고 있을 fieldErrors와 URI 검증에서 발생하는 ConstraintViolationException 에러를 담고 있을 violationErrors를 선언하고 of 메서드를 이용하여 에러를 추출하고 가공하는 역할을 각각의 static class에게 위임하고 있다.
이러한 패턴을 정적 팩토리 메서드라고 한다.
정적 팩토리 메서드
- 생성자의 역할을 수행, 역할 분리
생성자와의 차이점
- 이름을 가질 수 있다.
- 호출할 때마다 새로운 객체를 생성할 필요가 없다.
- 하위 자료형 객체를 반환할 수 있다.
- 객체 생성을 캠슐화 할 수 있다.
@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.of() 메서드를 호출하여 예외처리 기능을 위임하고 있다.
개발자가 직접 상황에 맞게(비즈니스 로직에 따른) 예외 메시지를 던져 예외 처리를 할 수 있다.
public class BusinessLogicException extends RuntimeException {
@Getter
private ExceptionCode exceptionCode;
public BusinessLogicException(ExceptionCode exceptionCode) {
super(exceptionCode.getMessage());
this.exceptionCode = exceptionCode;
}
}
public enum ExceptionCode {
USER_NOT_FOUND(404, "User Not Found"),
METHOD_NOT_ALLOWED(405, "Method Not Allowd");
@Getter
private int status;
@Getter
private String message;
ExceptionCode(int status, String message) {
this.status = status;
this.message = message;
}
}
비즈니스 로직을 구현하기 위해 RuntimeException을 상속받았다. 이를 이용하여 언체크 예외 처리를 할 수 있다. 예외가 발생하는 시점에 throw new BusinessLogicException(ExceptionCode.USER_NOT_FOUND);
등을 코드에 작성하여 해당 비즈니스에 관한 예외처리를 진행할 수 있게 된다.
ExceptionCode enum 같은 경우에는 사용자 지정 예외 메시지와 상태 응답코드를 지정하여 비즈니스 로직 예외 처리에 사용할 수 있다.