우리는 Dto를 사용하면서 여러 Validation 어노테이션을 사용한다.
학생을 저장하는 요청의 Dto를 살펴보자
@Getter
public class ApplicationSaveDto {
@NotNull(message = "학번이 누락되었습니다.")
@Pattern(regexp = APPLICATION_STUDENT_ID_PATTERN, message = "학번이 형식에 맞지 않습니다.")
private String studentId;//학번
@NotNull(message = "이름이 누락되었습니다.")
@Pattern(regexp = APPLICATION_NAME_PATTERN, message = "이름이 형식에 맞지 않습니다.")
private String name;//이름
@NotNull(message = "전화번호가 누락되었습니다.")
@Pattern(regexp = APPLICATION_PHONE_NUMBER_PATTERN, message = "전화번호가 형식에 맞지 않습니다.")
private String phoneNumber;//전화번호
@NotNull(message = "이메일이 누락되었습니다.")
@Pattern(regexp = APPLICATION_EMAIL_PATTERN, message = "이메일이 형식에 맞지 않습니다.")
@Length(max = 100, message = "이메일은 100자를 넘을 수 없습니다.")
private String email;//이메일
}
@NotNull, @Pattern, @Length 어노테이션을 사용하고 있다.
이 어노테이션은 Dto를 검증하는 역할을 하며, 검증 실패 시 예외를 발생시키고, 400 Bad Request를 반환한다.
그럼 이 어노테이션에서 발생한 Exception은 어떻게 커스텀할 수 있을까?
우선, @NotNull, @Pattern, @Length과 같은 jakarta.validation.constraints의 어노테이션들은 Valid 하지 않을 때(해당 어노테이션 조건을 충족시키지 못했을 때) MethodArgumentNotValidException을 발생시킨다. 왜일까?
MethodArgumentNotValidException에 대해 살펴보자.
public class MethodArgumentNotValidException extends BindException implements ErrorResponse {
.....
}
MethodArgumentNotValidException은 Spring Framework에서 발생하는 예외로, @Valid 어노테이션에서 데이터의 유효성 검사가 실패하면 발생한다. Dto는 @Valid 어노테이션으로 검증하므로, Dto 검증 실패 시 발생하는 Exception이라는 것을 알 수 있다.
MethodArgumentNotValidException은 BindException을 상속받고 있다.
BindException은 뭘까?
public class BindException extends Exception implements BindingResult {
private final BindingResult bindingResult;
.....
}
BindException은 Spring Framework에서 발생하는 예외로, 데이터 바인딩(request body를 Dto의 변수에 넣는 것) 과정에서 문제가 발생했을 때 보통 발생한다. 우리가 만든 Dto의 검증에 실패했을 때 발생한다.
이제 이해가 간다..
Dto에 있는 @NotNull, @Pattern, @Length 등의 어노테이션이 붙어 있는 변수에 값을 바인딩하는 과정에서 @NotNull, @Pattern, @Length 등의 조건을 충족시키지 못하면 바인딩이 실패한 것이고, 우리가 사용한 @Valid 어노테이션이 이를 캐치해 BindException을 상속받는 MethodArgumentNotValidException을 발생시키는 것이다.
그럼 @Valid를 붙인 Dto에서 MethodArgumentNotValidException예외가 발생하는 것까진 알았다.
그럼 어떤 어노테이션에 의해 MethodArgumentNotValidException이 발생했는지는 어떻게 알 수 있을까? 우리는 프론트엔드 개발자에게 어떤 Field가 문제인 건지 알려주어야 한다.
MethodArgumentNotValidException의 부모 클래스인 BindException은 BindingResult를 멤버변수로 가진다.
public class BindException extends Exception implements BindingResult {
private final BindingResult bindingResult;
.....
}
BindingResult 는 뭘까?
BindingResult는 Spring Framework에서 데이터 바인딩 및 유효성 검사 결과를 저장하는 객체이다. Binding이 실패한 원인과 같은 데이터를 담고 있다.
BindingResult를 살펴보자
public interface BindingResult extends Errors {
.....
}
Errors라는 인터페이스를 상속받고 있다.
public interface Errors {
.....
List<FieldError> getFieldErrors();
@Nullable
default FieldError getFieldError() {
return (FieldError)this.getFieldErrors().stream().findFirst().orElse((Object)null);
}
.....
}
Errors 인터페이스는 Spring Framework에서 데이터 바인딩 및 유효성 검사 결과를 저장하는 인터페이스로, 데이터 바인딩 및 유효성 검사를 수행한 후에 해당 결과를 담는 데 사용된다.
Errors인터페이스를 보면, List<FieldError>가 있다.
FieldError를 보자
public class FieldError extends ObjectError {
.....
}
Spring Framework에서 데이터 바인딩 및 유효성 검사 결과를 담는 객체 중 하나로, 특정 필드에 대한 오류 정보를 담고 있다.
FieldError는 ObjectError를 상속받고 있다.
ObjectError는 무엇일까?
public class ObjectError extends DefaultMessageSourceResolvable {
.....
}
ObjectError는 DefaultMessageSourceResolvable를 상속받고 있다. 유효성 검사에 실패하면 해당 전역 오류 정보를 ObjectError 객체에 담아 BindingResult 객체에 저장된다.
ObjectError는 DefaultMessageSourceResolvable를 상속받고 있다.
DefaultMessageSourceResolvable는 다음과 같다.
public class DefaultMessageSourceResolvable implements MessageSourceResolvable, Serializable {
@Nullable
private final String[] codes;
@Nullable
private final Object[] arguments;
@Nullable
private final String defaultMessage;
.....
@Nullable
public String getCode() {
return this.codes != null && this.codes.length > 0 ? this.codes[this.codes.length - 1] : null;
}
.....
}
DefaultMessageSourceResolvable은 Spring Framework에서 메시지 소스로부터 메시지를 검색할 때 사용되는 클래스이다.
Spring Framework에서 데이터 바인딩 및 유효성 검사를 수행할 때 발생하는 오류 메시지를 처리하는 데 사용된다. 예를 들어, FieldError나 ObjectError 객체를 생성할 때 해당 객체의 오류 메시지를 작성하기 위해 DefaultMessageSourceResolvable 객체를 생성하여 사용할 수 있다. 이렇게 생성된 FieldError나 ObjectError 객체는 유효성 검사 결과를 담는 BindingResult 객체에 저장된다.
이 클래스의 getCode() 함수를 사용하면 @NotNull, @Pattern, @Length 등에서 사용했던 어노테이션의 이름을 얻을 수 있다.
예를 들어, @Length 어노테이션의 조건을 충족시키지 못했다면, getCode() 함수는 “Length”라는 문자열을 반환한다.
이 복잡한 설명을 한 이유는 무엇일까?
DefaultMessageSourceResolvable의 getCode() 함수를 통해 어떤 어노테이션에서 MethodArgumentNotValidException 이 발생했는지 알 수 있다.
그럼 이 MethodArgumentNotValidException을 전역 Exception Handler에 추가하고, DefaultMessageSourceResolvable의 getCode()로 에러 메시지 분기를 하면 될 것이다.
코드로 보자.
기존의 ErrorCode를 그대로 사용해도 되지만, DefaultMessageSourceResolvable의 getCode()로 에러 메시지 분기를 하는 특별한 Enum이라고 판단해 새로운 Enum클래스를 만들었다.
@Getter
@AllArgsConstructor
public enum ValidationErrorCode {
NOT_NULL("9001", "필수값이 누락되었습니다."),
NOT_BLANK("9002", "필수값이 빈 값이거나 공백으로 되어있습니다."),
REGEX("9003", "형식에 맞지 않습니다."),
LENGTH("9004", "길이가 유효하지 않습니다.");
private final String code;
private final String message;
//Dto의 어노테이션을 통해 발생한 에러코드를 반환
public static ValidationErrorCode resolveAnnotation(String code) {
return switch (code) {
case "NotNull" -> NOT_NULL;
case "NotBlank" -> NOT_BLANK;
case "Pattern" -> REGEX;
case "Length" -> LENGTH;
default -> throw new IllegalArgumentException("Unexpected value: " + code);
};
}
}
resolveAnnotation은 ExceptionHandler에서 호출할 것이다. 이제 ExceptionHandler를 보자.
@Slf4j
@RestControllerAdvice
public class ExceptionController {
//MethodArgumentNotValidException 예외를 처리하는 핸들러(Body(dto)의 Validation에 실패한 경우)
@ResponseStatus(HttpStatus.BAD_REQUEST)
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ResponseDto<Void>> handleMethodArgumentNotValidException(
MethodArgumentNotValidException methodArgumentNotValidException) {
FieldError fieldError = methodArgumentNotValidException.getBindingResult().getFieldError();
if (fieldError == null) {
return new ResponseEntity<>(
ResponseDto.res(HttpStatus.BAD_REQUEST.toString(), methodArgumentNotValidException.getMessage()),
HttpStatus.BAD_REQUEST);
}
ValidationErrorCode validationErrorCode = ValidationErrorCode.resolveAnnotation(fieldError.getCode());
String code = validationErrorCode.getCode();
String message = validationErrorCode.getMessage() + " : " + fieldError.getDefaultMessage();
log.error("MethodArgumentNotValidException: {}", message);
return new ResponseEntity<>(ResponseDto.res(code, message), HttpStatus.BAD_REQUEST);
}
}
우선, MethodArgumentNotValidException의 getBindingResult()를 호출해 BindingResult를 얻는다. 그리고, getFieldError()를 호출해 FieldError를 얻는다.
fieldError의 getCode() (DefaultMessageSourceResolvable의 getCode())로 어노테이션명을 얻고, ValidationErrorCode의 resolveAnnotation()을 통해 적절한 ValidationErrorCode로 반환한다. fieldError.getDefaultMessage()를 통해 더 자세한 메시지(우리가 Dto에서 설정한 message)를 얻을 수 있다.
이제 Dto Validation에 실패해도 커스텀 Response를 반환할 수 있다.
'Spring Boot' 카테고리의 다른 글
Spring Boot에서 Redis를 활용해 데이터 캐싱하기(feat. Look Aside) (1) | 2024.06.17 |
---|---|
Interceptor를 활용한 JWT 토큰 검증 (1) | 2024.06.07 |
Spring Boot에선 예외를 어떻게 처리할까?(전역, 커스텀 예외처리) (0) | 2024.03.15 |
Spring Boot의 ResponseEntity (1) | 2024.03.15 |
Dto에 Validation 적용하기 (2) | 2024.01.21 |