본문 바로가기

Spring Boot

Dto Validation 실패 시 예외처리

우리는 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 객체를 생성하여 사용할 수 있다. 이렇게 생성된 FieldErrorObjectError 객체는 유효성 검사 결과를 담는 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를 반환할 수 있다.