[Spring] 커스텀 Validation 어노테이션 만들기

 

java나 hibernate에서 예외 검증을 위한 Validation(검증) 어노테이션들을 제공한다.

아래는 자바에서 제공하는 검증 어노테이션들이다.

많은 어노테이션들을 제공하지만 우리가 원하는 검증을 위한 어노테이션이 없을 수도 있다.

이를 위해 커스텀 검증 어노테이션 만드는 법을 알아보자.


특정 클래스에만 적용되는 커스텀 Validator

 

상황

클라이언트 요청을 받을 때 시작 날짜, 종료 날짜를 받아 기간이 30일 내의 요청인지 확인하는 상황.

커스텀 어노테이션 생성

@Constraint(validatedBy = DateRangeValidator.class) // 필드 값을 검증할 검증 클래스를 지정
@Target(ElementType.TYPE) // 어노테이션을 적용할 수 있는 위치 설정 (TYPE : 클래스에 어노테이션 선언)
@Retention(RetentionPolicy.RUNTIME)// 어노테이션의 유지 범위 설정 (RUNTIME : 애플리케이션 실행 동안)
public @interface ValidDateRange {

    String message() default "최대 30일 까지만 조회가능합니다."; // 예외 발생 시 출력될 기본 메세지 값

    Class<?>[] groups() default {}; // validation group 지정

    Class<? extends Payload>[] payload() default {}; // 추가 정보를 전달할 수 있는 값
}

 

중요한 부분은 @Constraint 부분이다.

해당 부분에 필드 값을 검증할 검증 클래스를 지정해야 한다.

검증 클래스에는 사용자가 원하고자 하는 검증 로직을 작성하면 된다.

 

참고로 @Constraint 선언 하면 message() 뿐 아니라, groups(), payload() 메서드도 꼭 선언이 필요하다.

(선언하지 않으면 해당 어노테이션에서 오류 발생)

클라이언트 요청을 받는 DTO 클래스

@ValidDateRange
public record ReservationSearchConditionRequest(Long themeId, Long memberId, LocalDate dateFrom, LocalDate dateTo) {
}

아래 검증 클래스 내부 구현 설명을 위해 어떠한 요청 데이터들이 있는지 보여주려 추가했다.

그리고 해당 DTO에 위에서 정의한 커스텀 어노테이션을 선언해논 것을 볼 수 있다.

 

ConstraintValidator를 구현하는 검증 클래스 생성

public class DateRangeValidator implements ConstraintValidator<ValidDateRange, ReservationSearchConditionRequest> {

    @Override
    public boolean isValid(ReservationSearchConditionRequest request, ConstraintValidatorContext context) {
        return ChronoUnit.DAYS.between(request.dateFrom(), request.dateTo()) <= 30;
    }
}

 

위에서 정의한 커스텀 어노테이션에서 @Constraint(validatedBy = ) 안에 명시될 클래스로

어떠한 검증을 할 것인지 정의하는 클래스이다.

 

ConstraintValidator<>의 첫 번째 인자로는 유효성 검사를 적용할 어노테이션

두 번째 인자로는 유효성 검사를 적용할 클래스를 선언해주면 된다.

 

그리고 해당 어노테이션을 구현하면 위 두 메서드를 오버라이딩 할 수 있고 특징은 다음과 같다.

  • initialize() : 어노테이션 정보를 가져와 초기화 한다. (default로 필요할 때 오버라이딩)
  • isValid() : 실질적인 검증을 담당하며 내부 로직에서 참 거짓을 판단한다. false면 검증에 걸려 MethodArgumentNotValidException예외가 반환된다. (필수로 오버라이딩)

 

위 코드에서는 isValid()를 오버라이딩해

ReservationSearchConditionRequest Dto의 dateFrom 필드와 dateTo 필드의 날짜 차이가 30일 보다 작거나 같은지 확인한다.

만약 작거나 같다면 검증에 통과하고, 아니라면 MethodArgumentNotValidException 예외가 발생하게 된다.


모든 클래스에 적용되는 커스텀 Validator

 

상황

요청으로 LocalDate와 LocalTime을 받아 현재 날짜, 시간 이후에 대한 요청인지 확인하는 상황.

커스텀 어노테이션 생성

@Constraint(validatedBy = FutureOrPresentDateTimeValidator.class) // 필드 값을 검증할 검증 클래스를 지정
@Target({ElementType.TYPE}) // 어노테이션을 적용할 수 있는 위치 설정 (TYPE : 클래스에 어노테이션 선언)
@Retention(RetentionPolicy.RUNTIME) // 어노테이션의 유지 범위 설정 (RUNTIME : 애플리케이션 실행 동안)
public @interface FutureOrPresentDateTime {

    String message() default "날짜와 시간은 현재 이후여야 합니다."; // 예외 발생 시 출력될 기본 메세지 값

    Class<?>[] groups() default {}; // validation group 지정

    Class<? extends Payload>[] payload() default {}; // 추가 정보를 전달할 수 있는 값

    String dateFieldName();

    String timeFieldName();
}

특정 클래스에만 적용되는 것과 다르게 해당 어노테이션은 모든 클래스에 적용하기 위해서 

dateFieldName(), timeFieldName()이라는 검사할 날짜와 시간 필드의 이름을 지정하는 속성을 추가했다.

 

클라이언트 요청을 받는 DTO 클래스

@FutureOrPresentDateTime(dateFieldName = "date", timeFieldName = "time")
public record MeetingSaveRequest(
        String name,
        LocalDate date,
        LocalTime time,
        String targetAddress,
        String targetLatitude,
        String targetLongitude,
        String nickname,
        String originAddress,
        String originLatitude,
        String originLongitude
) {

}

 

ConstraintValidator를 구현하는 검증 클래스 생성

public class FutureOrPresentDateTimeValidator implements ConstraintValidator<FutureOrPresentDateTime, Object> {

    private String dateFieldName;
    private String timeFieldName;

    @Override
    public void initialize(FutureOrPresentDateTime constraintAnnotation) {
        dateFieldName = constraintAnnotation.dateFieldName();
        timeFieldName = constraintAnnotation.timeFieldName();
    }

    @Override
    public boolean isValid(Object object, ConstraintValidatorContext constraintValidatorContext) {
        try {
            Class<?> objectClass = object.getClass();
            Method dateGetter = objectClass.getMethod(dateFieldName);
            Method timeGetter = objectClass.getMethod(timeFieldName);

            LocalDate dateInput = (LocalDate) dateGetter.invoke(object);
            LocalTime timeInput = (LocalTime) timeGetter.invoke(object);

            LocalDateTime dateTimeInput = LocalDateTime.of(dateInput, timeInput);
            LocalDateTime now = LocalDateTime.now();

            if (dateInput.isEqual(now.toLocalDate())) {
                return dateTimeInput.isAfter(now);
            }
            return dateInput.isAfter(LocalDate.now());
        } catch (Exception exception) {
            throw new OdyServerErrorException(exception.getMessage());
        }
    }
}

ConstraintValidator<>의 첫 번째 인자로는 유효성 검사를 적용할 어노테이션,

두 번째 인자로는 모든 클래스에 유효성 검사를 적용하기 위해 Object로 선언했다.

 

그리고 Object기 때문에 특정 클래스에서 검증에 사용할 필드가 뭔지 모르기 때문에 이전 Validator와 로직이 많이 차이가 있어

메서드 별로 하나씩 설명을 해보려한다.

 

initailize()

유효성 검사를 적용할 어노테이션에서 속성으로 지정한 필드 명을 가져와 필드 이름을 내부적으로 초기화 한다.

이 메서드는 isValid()가 호출되기전에 호출된다.

 

isValid()

검증 로직에 대한 설명이 아닌 리플렉션이 사용되어 생소한 코드에 대해서만 설명한다.

Class<?> objectClass = object.getClass();
Method dateGetter = objectClass.getMethod(dateFieldName);
Method timeGetter = objectClass.getMethod(timeFieldName);

LocalDate dateInput = (LocalDate) dateGetter.invoke(object);
LocalTime timeInput = (LocalTime) timeGetter.invoke(object);

리플렉션을 사용해 해당 검증이 적용되는 클래스를 가져오고

initailize()로 초기화된 해당 클래스의 변수를 이용해 getMethod()로 해당 클래스의 getter 메서드를 찾고,

invoke() 메서드로 해당 getter 메서드를 호출해 값을 얻는다.

 

그리고 isValid() 메서드 내부가 try ~ catch 문으로 감싸져있는데

Java 리플렉션 API인 getMethod()와 invoke()가 체크 예외로 선언되어 있어 언체크 예외로 변환해 예외를 던져주었다.