{
"timestamp": "2023-06-14T02:40:38.768+00:00",
"status": 404,
"error": "Not Found",
"path": "/v4/coffees/"
}
기존 작성했던 코드는 유효성 검사를 통과하지 못하면 위와 같이 응답하여 클라이언트 요청 데이터 중에서 어떤 항목이 유효성 검증에 실패했는지 알 수 없음
유효성을 검사하고, 예외가 발생할 경우 예외 처리를 통해 클라이언트에게 유효성 검사 실패를 알려 잘못된 입력을 방지
위의 Response Body는 예외 발생시 Spring에서 전달해주는 에러 응답 메세지
MethodArgumentNotValidException
ConstraintViolationException
@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);
}
}
{
"email":"jungseo@",
"name":"jungseo",
"phone":"010-1111-1111"
}
[
{
"codes": [
"Email.memberPostDto.email",
"Email.email",
"Email.java.lang.String",
"Email"
],
"arguments": [
{
"codes": [
"memberPostDto.email",
"email"
],
"arguments": null,
"defaultMessage": "email",
"code": "email"
},
[],
{
"arguments": null,
"codes": [
".*"
],
"defaultMessage": ".*"
}
],
"defaultMessage": "must be a well-formed email address",
"objectName": "memberPostDto",
"field": "email",
"rejectedValue": "jungseo@",
"bindingFailure": false,
"code": "Email"
}
]
@Getter
@AllArgsConstructor
public class ErrorResponse {
List<FieldError> fieldErrors;
@Getter
@AllArgsConstructor
public static class FieldError {
private String field;
private Object rejectedValue;
private String reason;
}
}
@RestController
@RequestMapping("/v6/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);
}
}
{
"email":"jungseo@",
"name":"",
"phone":"010-1111-1111"
}
{
"fieldErrors": [
{
"field": "name",
"rejectedValue": "",
"reason": "이름은 공백이 아니어야 합니다."
},
{
"field": "email",
"rejectedValue": "jungseo@",
"reason": "must be a well-formed email address"
}
]
}
import com.codestates.Response.ErrorResponse;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import javax.validation.ConstraintViolationException;
@RestControllerAdvice
public class GlobalExceptionAdvice {
// (1) 필드의 유효성 검사에 대한 에러 처리
@ExceptionHandler
@ResponseStatus(HttpStatus.BAD_REQUEST)
public ErrorResponse hadleMethodArgumentNotValidException(
MethodArgumentNotValidException e) {
final ErrorResponse response = ErrorResponse.of(e.getBindingResult());
return response;
}
// (2) URI 변수로 넘어오는 값의 유효성 검증에 대한 에러 처리
@ExceptionHandler
@ResponseStatus(HttpStatus.BAD_REQUEST)
public ErrorResponse handleConstraintViolationException(
ConstraintViolationException e) {
final ErrorResponse response = ErrorResponse.of(e.getConstraintViolations());
return response;
}
}
import lombok.Getter;
import org.springframework.validation.BindingResult;
import javax.validation.ConstraintViolation;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
@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)
public static ErrorResponse of(BindingResult bindingResult) {
return new ErrorResponse(FieldError.of(bindingResult), null);
}
// (5)
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으로 발생하는 에러 정보를 담을 변수
(2) ConstraintViolationException으로 발생하는 에러 정보를 담을 변수
(3) 생성자의 접근 제어자를 private으로 지정
정적 팩토리 메서드 패턴
외부에서 객체를 생성할 수 없게 하여 생성자의 의미와 용도를 명확하게 함
객체 생성에 필요한 로직을 해당 메서드 내부에 캡슐화
객체 생성과 관련된 복잡한 로직이 있을 경우 코드의 가독성과 유지보수성을 향상
(4) MethodArgumentNotValidException에 대한 ErrorResponse 객체 생성
(5) ConstrainViolationException에 대한 ErrorResponse 객체 생성
(6) 필드의 유효성 검사에서 발생하는 에러 정보 생성
(7) URI 변수 값에 대한 에러 정보 생성
예시 상황
throw 키워드
Service 클래스에서 예외 던지기
@Service
public class MemberService {
// 생략
public Member findMember(long memberId) {
// TODO should business logic
// (1)
throw new RuntimeException("Not found member");
}
// 생략
}
@RestControllerAdvice
public class GlobalExceptionAdvice {
// 생략
// (1)
@ExceptionHandler
@ResponseStatus(HttpStatus.NOT_FOUND)
public ErrorResponse handleResourceNotFoundException(RuntimeException e) {
System.out.println(e.getMessage());
return null;
}
}
위 예시 처럼 서비스 계층에서 RuntimeException을 그대로 던지고 ExceptionAdvice에서 RuntimeExveption을 그대로 잡는 것은 예외의 의도가 명확하지 않고 어떤 예외가 발생했는지에 대한 예외 정보를 얻기 어려움
추상적인 RuntimeException 같은 예외가 아닌 예외를 구체적으로 표현할 수 있는 Custom Exception을 만들어 예외 던지기 가능
ExceptionCode Enum
import lombok.Getter;
public enum ExceptionCode {
MEMBER_NOT_FOUND(404, "Member Not Found");
@Getter
private int status;
@Getter
private String message;
ExceptionCode(int status, String message) {
this.status = status;
this.message = message;
}
}
서비스 계층에서 던질 Custom Exception에 사용할 ExceptionCode를 enum으로 정의
ExceptionCode를 enum으로 정의하면 비즈니스 로직에서 발생하는 다양한 유형의 예외를 enum에 추가해서 사용 가능
import lombok.Getter;
public class BusinessLogicException extends RuntimeException {
@Getter
private ExceptionCode exceptionCode;
public BusinessLogicException(ExceptionCode exceptionCode) {
super(exceptionCode.getMessage());
this.exceptionCode = exceptionCode;
}
}
BusinessLogicException은 RuntimeException을 상속하고 있으며 ExceptionCode를 멤버 변수로 지정하여 생성자를 통해서 조금 더 구체적인 예외 정보들을 제공
상위 클래스인 RuntimeException의 생성자(super)로 예외 메시지를 전달
@Service
public class MemberService {
// 생략
public Member findMember(long memberId) {
// TODO should business logic
// (1)
throw new BusinessLogicException(ExceptionCode.MEMBER_NOT_FOUND);
}
// 생략
}
@RestControllerAdvice
public class GlobalExceptionAdvice {
// 생략
// (1) 정의했던 예외 처리하기
@ExceptionHandler
public ResponseEntity handleBusinessLogicException(BusinessLogicException e) {
System.out.println(e.getExceptionCode().getStatus());
System.out.println(e.getMessage());
ErrorResponse response = ErrorResponse.of(e.getExceptionCode());
return new ResponseEntity<>(response, HttpStatus.valueOf(e.getExceptionCode()
.getStatus()));
}
// (2) HttpStatus 클래스를 이용하여 예외 처리하기
@ExceptionHandler
public ResponseEntity handleHttpRequestMethodNotSupportedException(HttpRequestMethodNotSupportedException e) {
ErrorResponse response = ErrorResponse.of(HttpStatus.METHOD_NOT_ALLOWED);
return new ResponseEntity(response, HttpStatus.METHOD_NOT_ALLOWED);
}
// (3) 구현 상 실수로 발생하는 전반적인 예외 처리하기
@ExceptionHandler
public ResponseEntity handleException(Exception e) {
ErrorResponse response = ErrorResponse.of(ExceptionCode.INTERNAL_SERVER_ERROR);
return new ResponseEntity(response, HttpStatus.INTERNAL_SERVER_ERROR);
}
}
}
@Getter
public class ErrorResponse {
@JsonInclude(JsonInclude.Include.NON_NULL)
private int status;
@JsonInclude(JsonInclude.Include.NON_NULL)
private String message;
@JsonInclude(JsonInclude.Include.NON_NULL)
private List<FieldError> fieldErrors;
@JsonInclude(JsonInclude.Include.NON_NULL)
private List<ConstraintViolationError> violationErrors;
private ErrorResponse(final List<FieldError> fieldErrors,
final List<ConstraintViolationError> violationErrors) {
this.fieldErrors = fieldErrors;
this.violationErrors = violationErrors;
}
// (1) 정의했던 예외
private ErrorResponse(ExceptionCode exceptionCode) {
this.fieldErrors = null;
this.violationErrors = null;
this.status = exceptionCode.getStatus();
this.message = exceptionCode.getMessage();
}
// (2) HttpStatus 클래스 이용
private ErrorResponse(HttpStatus httpStatus) {
this.status = httpStatus.value();
this.message = httpStatus.getReasonPhrase();
}
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));
}
// (3)
public static ErrorResponse of(ExceptionCode exceptionCode) {
return new ErrorResponse(exceptionCode);
}
// (4)
public static ErrorResponse of(HttpStatus httpStatus) {
return new ErrorResponse(httpStatus);
}
@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());
}
}
}