MemberController.java
@RestController
@RequestMapping("/v5/members")
@Validated
public class MemberController {
private final MemberService memberService;
private final MemberMapper mapper;
public MemberController(MemberService memberService, MemberMapper mapper) {
this.memberService = memberService;
this.mapper = mapper;
}
@PostMapping
public ResponseEntity postMember(@Valid @RequestBody MemberPostDto memberDto) {
Member member = mapper.memberPostDtoToMember(memberDto);
Member response = memberService.createMember(member);
return new ResponseEntity<>(mapper.memberToMemberResponseDto(response),
HttpStatus.CREATED);
}
...
}
MemberController 클래스의 postMember() 핸들러 메서드의 post 요청을 통해 회원 등록 시
Postman을 통해 유효성 검증에 실패하도록 요청 데이터를 send 하면 아래와 같은 응답 메시지를 확인할 수 있다.
실패 가정 상황: email의 요청 데이터에 null 값을 입력
🙁 위 응답 메시지의 아쉬운 부분은 무엇일까?
- Response Body 내용만으로 요청 데이터 중에 어떤 항목이 유효성 검증에 실패했는지 알기 어려움
😎 에러 메시지를 조금 더 구체적으로 바꾸는 방법은?
- @ExceptionHandler 이용하자 !
MemberController에 @ExceptionHandler 적용하기
public class MemberController {
...
@ExceptionHandler
public ResponseEntity handleException(MethodArgumentNotValidException e) {
final List<FieldError> fieldErrors = e.getBindingResult().getFieldErrors(); // (1)
return new ResponseEntity<>(fieldErrors, HttpStatus.BAD_REQUEST); // (2)
}
}
- 클라이언트: 회원 등록을 위해 MemberController의 postMember() 핸들러 메서드에 요청
- RequestBody <== 유효하지 않은 요청 데이터 포함 ==> 유효성 검증 실패 ==> MethodArgumentNotValidException 발생
- MethodArgumentNotValidException 예외 ==> handleException() 메서드가 전달 받음 == > (1) (2) 과정 수행
(1) MethodArgumentNotValidException 객체에서 getBindingResult().getFieldErrors() 통해 에러 정보 확인
(2) ResponseEntity 통해 Response Body로 전달
MemberController의 handleException() 메서드에서 유효성 검사 실패에 대한 에러 메시지를 전송하여
에러 메시지를 구체화하였다. 이를 통해 클라이언트에게 명시적으로 에러가 발생한 위치를 알려줄 수 있게 되었다.
🙁 위 응답 메시지의 아쉬운 부분은 무엇일까?
- 클라이언트 입장에서는 Response Body 전체 정보를 굳이 다 전달받을 이유가 없음
- 본질은 JSON property 중 문제가 된 property, 에러 메시지 정보만 전달받아도 충분함
😎 에러 메시지를 조금 더 효율적으로 바꾸는 방법은?
- 에러 정보를 기반으로 한 Error Response 클래스 생성 => 필요한 정보만 담고 => 클라이언트 쪽에 전달
ErrorResponse.java
import lombok.AllArgsConstructor;
import lombok.Getter;
import java.util.List;
@Getter
@AllArgsConstructor
public class ErrorResponse {
private List<FieldError> fieldErrors; // (1)
@Getter
@AllArgsConstructor
public static class FieldError {
private String field;
private Object rejectedValue;
private String reason;
}
}
- Error 정보만 담아서 응답으로 전송하기 위핸 ErrorResponse 클래스
- DTO 클래스의 유효성 검증 실패 시, 실패한 필드(멤버 변수)에 대한 Error 정보만 담아서 응답으로 전송
(1) JSON 응답 객체가 배열인 이유?
- DTO 클래스에서 검증해야 하는 멤버 변수에서 ==> 유효성 검 검증에 실패하는 멤버 변수는 여러개가 될 수 있음
==> 결국 유효성 검증 실패 에러 역시 여러개가 될 수 있다!
MemberController.java
public class MemberController {
...
@ExceptionHandler
public ResponseEntity handleException(MethodArgumentNotValidException e) {
final List<FieldError> fieldErrors = e.getBindingResult().getFieldErrors(); // (1)
List<ErrorResponse.FieldError> errors = // (2)
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); // (3)
}
}
(1) List<FieldError> 통쨰로 ResponseEntity 클래스에서 실어서 전달
(2) 필요한 정보만 선택적으로 골라 ==> ErrorResponse.FieldError 클래스에 담은 뒤 ==> List로 변환
(3) List<ErrorResponse.FieldError> 를 ResponseEntity 클래스에 실어서 전달
유효하지 않은 이메일 주소(email)와 휴대폰 번호(phone)를 요청한 결과 ==> 유효성 검증에 실패한 필드가 두개인 상황
ErrorResponse 를 적용하고 보니 두 개의 에러 정보와 함께 필요한 정보만 명시하고 있다. Good.
🙁 위 응답 메시지의 아쉬운 부분은 무엇일까?
1. 각각의 Controller 클래스에서 @ExceptionHandler 애너테이션을 통해 Request Body에 대한 유효성 검증 실패 처리
==> 코드 중복 발생
2. Controller에서 처리해야 하는 예외는 유효성 검증 실패(MethodArgumentNotValidException)에 대한 예외만 있지 않음
==> @ExceptionHandler 추가한 에러 처리 핸들러 메서드가 늘어남
😎 예외 처리 방식을 어떻게 개선할 수 있을까?
- @RestControllerAdvice 사용한 예외 처리 공통화
@RestControllerAdvice 애너테이션 추가 시 좋은 점은 ?
- Controller 클래스에서 @ExceptionHandler @InitBinder 또는 @ModelAttribute가 추가된 메서드를 공유해서 사용할 수 있음
==> 결국 @RestControllerAdvice 애너테이션 추가한 클래스를 이용하면 예외 처리 공통화 가능하다는 뜻.!!
GlobalExceptionAdvice.java
// Controller 클래스의 예외를 처리할 GlobalExceptionAdvice 클래스 정의
// 이제 Controller 클래스에서 발생하는 예외를 도맡아서 처리하게 된다.
@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) {
return new ResponseEntity<>(HttpStatus.BAD_REQUEST);
}
}
이처럼 @RsetControllerAdvice 애너테이션을 사용하면 예외 처리를 공통화하여
각 Controller마다 추가되는 @ExceptionHandler 로직에 대한 중복 코드를 제거하고, Controller의 코드를 단순화할 수 있다.
📢 ErrorResponse 수정
- handleMethodArgumentNotValidException에 대한 예외 처리는 성공
- 아직 ConstraintViolationException 에 대한 예외 처리는 구현되지 않았다. ==> 구현해보자.
ErrorResponse.java
@Getter
public class ErrorResponse {
private List<FieldError> fieldErrors; // (1)
private List<ConstraintViolationError> violationErrors; // (2)
// (3)
private ErrorResponse(List<FieldError> fieldErrors, List<ConstraintViolationError> violationErrors) {
this.fieldErrors = fieldErrors;
this.violationErrors = violationErrors;
}
// (4) BindingResult에 대한 ErrorResponse 객체 생성
public static ErrorResponse of(BindingResult bindingResult) {
return new ErrorResponse(FieldError.of(bindingResult), null);
}
// (5) Set<ConstraintViolation<?>> 객체에 대한 ErrorResponse 객체 생성
public static ErrorResponse of(Set<ConstraintViolation<?>> violations) {
return new ErrorResponse(null, ConstraintViolationError.of(violations));
}
// (6) Field Error 가공
@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());
}
}
// (7) ConstraintViolation Error 가공
@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());
}
}
}
(1) MethodArgumentNotValidException으로부터 발생하는 에러 정보를 담는 멤버 변수
==> DTO 멤버 변수 필드의 유효성 검증 실패로 발생한 에러 정보를 담는 멤버 변수
(2) ConstraintViolationException으로부터 발생하는 에러 정보를 담는 멤버 변수
==> URI 변수 값의 유효성 검증에 실패로 발생한 에러 정보를 담는 멤버 변수
(3) ErrorResponse 클래스의 생성자: 생성자 앞에 private 접근 제한자(Access Modifier)를 지정
==> new ErrorResponse(…,…) 방식으로 ErrorResponse 객체를 생성할 수 없음
(4) (5)에서 대신 of() 메서드 이용해서 ErrorResponse 객체 생성 가능!
==> ErrorResponse의 객체를 생성함과 동시에 ErrorResponse의 역할을 명확하게 함
(4) MethodArgumentNotValidException에 대한 ErrorResponse 객체를 생성
==> MethodArgumentNotValidException에서 에러 정보를 얻기 위해 필요한 것: BindingResult 객체
==> of() 메서드를 호출하는 쪽에서 BindingResult 객체를 파라미터로 넘겨주자.
FieldError 클래스: BindingResult 객체를 가지고 에러 정보를 추출하고 가공하는 역할
(5) ConstraintViolationException에 대한 ErrorResponse 객체를 생성
==> ConstraintViolationException에서 에러 정보를 얻기 위해 필요한 것: Set<ConstraintViolation<?>> 객체
==> of() 메서드를 호출하는 쪽에서 Set<ConstraintViolation<?>> 객체를 파라미터로 넘겨주자.
ConstraintViolationError 클래스: Set<ConstraintViolation<?>> 객체를 가지고 에러 정보를 추출하고 가공하는 역할
(4)와 (5)를 통해서 ErrorResponse 객체에 에러 정보를 담는 역할을 명확하게 분리
(6) 필드(DTO 클래스의 멤버 변수)의 유효성 검증에서 발생하는 에러 정보를 생성
(7) URI 변수 값에 대한 에러 정보를 생성
📢 Exception 핸들러 메서드 수정
- 수정된 ErrorResponse 클래스의 메서드를 사용하도록 GlobalExceptionAdvice 클래스를 수정해보자.
GlobalExceptionAdvice.java
@RestControllerAdvice
public class GlobalExceptionAdvice {
// (1)
@ExceptionHandler
@ResponseStatus(HttpStatus.BAD_REQUEST)
public ErrorResponse handleMethodArgumentNotValidException(MethodArgumentNotValidException e){
final ErrorResponse response = ErrorResponse.of(e.getBindingResult());
return response;
}
// (2)
@ExceptionHandler
@ResponseStatus(HttpStatus.BAD_REQUEST)
public ErrorResponse handleConstraintViolationException(ConstraintViolationException e) {
final ErrorResponse response = ErrorResponse.of(e.getConstraintViolations());
return response;
}
}
- ResponseEntity가 사라지고 ErrorResponse 객체를 바로 리턴
- @ResponseStatus 애너테이션을 이용해서 HTTP Status를 HTTP Response에 포함
❓ @RestControllerAdvice vs @ControllerAdvice
• @RestControllerAdvice = @ControllerAdvice + @ResponseBody
@RestControllerAdvice 애너테이션은 @ControllerAdvice의 기능을 포함하고 있으며,
@ResponseBody의 기능 역시 포함하고 있기 때문에 JSON 형식의 데이터를 Response Body로 전송하기 위해서 ResponseEntity로 데이터를 래핑 할 필요가 없다는 사실을 기억하자.
'Study' 카테고리의 다른 글
[Cloud] 운영 환경 구성 (0) | 2023.08.28 |
---|---|
[Spring MVC] JDBC 기반 데이터 엑세스 계층 (0) | 2023.07.13 |
[Spring MVC] 서비스 계층 (0) | 2023.06.24 |
[STUDY] 스프링 핵심 원리 - 기본편 (1) (0) | 2023.05.27 |
[STUDY] 스프링부트와 AWS로 혼자 구현하는 웹 서비스 (2) (0) | 2023.04.07 |