Enum Validation

SexyWoong·2023년 11월 29일
0

spring

목록 보기
5/11

기존에 @Valid를 통해서 HTTP request를 통해 body에 담긴 값들을 DTO에서 String, Integer와 같은 클래스에 @NotNull과 같은 애너테이션을 활용하여 값을 검증해줄 수 있었다.

Enum 클래스의 값을 검증해주는것은 처음 알게 되었고 사용자 정의 애너테이션을 만들어 값을 검증해는것이 서비스 코드에서 따로 값을 검증해줄 필요도 없어 한단계 검증 작업을 줄여주기 때문에 편리하고 유용하다는 사실을 깨달았기에 공부한 내용을 정리한다.

참고) @Valid 를 통해 값을 검증한 결과 유요하지 않으면 MethodArgumentNotValidException가 발생한다.

EnumValue

@Target({METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER, TYPE_USE})
@Retention(RUNTIME)
@Documented
@Constraint(validatedBy = ValueOfEnumValidator.class)
public @interface EnumValue {

    Class<? extends Enum> enumClass();

    String message() default "";

    Class<?>[] groups() default {};

    Class<? extends Payload>[] payload() default {};

    boolean ignoreCase() default false;
}

위 코드는 DTO에서 @EnumValue애너테이션을 활용하여 값을 검증해줄 수 있게끔 사용자 정의 애너테이션 @EnumValue를 정의하는 코드이다.

한줄 한줄 공부하며 깨달은대로 정리해나가겠다.

  • @Target({METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER, TYPE_USE}) : 애너테이션이 적용될 수 있는 요소들을 지정해준다는 의미를 가지고 있다. 여기서는 메소드, 필드, 다른 애너테이션, 생성자, 매개변수, 타입 사용에 적용할 수 있음을 의미하고 있다.

  • @Retention(RUNTIME) : 애너테이션이 런타임에도 유지되어야 함을 의미한다. 런타임 중에도 리플렉션을 통해 이 애너테이션 정보를 검색할 수 있다.

  • @Constraint(validatedBy = ValueOfEnumValidator.class) : Bean Validation API의 일부이고 ValueOfEnumValidator클래스에 의해 검증되는 제약 조건을 나타낸다.

  • Class<? extends Enum> enumClass(); : 애너테이션이 적용되는 Enum클래스를 지정한다.

@EnumValue(enumClass = Category.class, message = "invalid category", ignoreCase = true)
    String category,

위 처럼 enumClass = Category.class를 통해 지정해줄 수 있다.

  • String message() default ""; : 유효성 검사에서 실패할 경우 반환되는 메시지를 정의한다. 여기서 "유효성 검사 실패" 와 같이 메시지를 정의해줄 수도 있지만 보통 애너테이션을 선언해주면서 message=""에 반환해줄 메시지를 적어주는것이 일반적이다.

예시)

@EnumValue(enumClass = ScheduleType.class, message = "유효하지 않은 일정 타입입니다. TRAVEL, DATE, ANNIVERSARY, PERSONAL, ETC 중 입력하세요", ignoreCase = true)
  • Class<?>[] groups() default {}; : 검증 그룹을 지정하는데 사용된다. 검증 그룹을 지정하게 되면 다양한 상황에서 다른 검증 규칙을 적용할 수 있다. 예를 들어보겠다.

예) 하나의 객체에 대해서 저장 시와 업데이트 시 다른 검증 규칙을 적용하고 싶다.

public interface OnCreate {}
public interface OnUpdate {}

메소드 없이 단순히 그룹을 나타내기 위해 위처럼 인터페이스를 정의한다.

그리고 DTO에서 각 필드마다 어떠한 과정에서 검증을 할것인지 다음과 같이 명시를 해준다.

@EnumValue(enumClass = Category.class, groups = OnCreate.class, message = "invalid category", ignoreCase = true)
String category,

@EnumValue(enumClass = ScheduleType.class, groups = {OnUpdate.class, OnCreate.class}, message = "유효하지 않은 일정 타입입니다. TRAVEL, DATE, ANNIVERSARY, PERSONAL, ETC 중 입력하세요", ignoreCase = true)
String schedule

category는 OnCreate 적용시에만 Bean Validation이 적용되고 schedule은 OnCreate, OnUpdate시에 Bean Validation이 적용된다.

이제 이를 어떻게 사용하는지에 대해서 설명하겠다.

Controller클래스에서 @Validated애너테이션을 활용하여 적용해줄 수 있다.

@SneakyThrows
    @PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE)
    public ResponseEntity<ApiResponse<CreateCalendarResponse>> createSchedule(
            @LoginUser SessionUser sessionUser,
            @RequestBody @Validated(OnCreate.class) CreateCalendarRequest request) {
        CreateCalendarResponse response = calendarCommandService.createCalendar(sessionUser.coupleId(), sessionUser.memberId(), request.toServiceDto());

        return ApiResponse.created(
                response,
                response.id(),
                linkTo(methodOn(getClass()).createSchedule(sessionUser, request)).withSelfRel(),
                linkTo(getClass().getMethod(FIND_ALL_SCHEDULE_WITH_DATE, FindCalendarsWithDateRequest.class, SessionUser.class)).withRel(FIND_ALL_SCHEDULE_WITH_DATE)
        );
    }

참고로 @Valid에는 groups를 지정해줄 수 없기 때문에 groups를 지정해주려면 @Validated를 사용해야한다. groups말고는 둘의 차이는 없다.

하지만 이는 하나의 DTO를 여러 엔드포인트에서 함께 사용하려다보니 생기는 문제이다. 이는 가독성을 떨어트리고 유지보수에 용이하지 못한것 같다.

즉, 각각의 엔드포인트마다 각각의 DTO를 만들어주고 groups는 사용하지 않는것이 더 좋은 방법이라고 생각된다.

  • Class<? extends Payload>[] payload() default {}; : payload의 주요 목적은 검증 실패 시 제공되는 정보를 확장하는 것이다. 예를 들어, 검증 오류가 발생했을 때 오류의 심각도나 오류 코드 같은 추가 정보를 제공할 수 있다. 이를 통해 애플리케이션 또는 API 사용자에게 더 상세한 피드백을 제공할 수 있다.

  • boolean ignoreCase() default false; : 메서드명에서 알 수 있듯 대,소문자 구분에 대한 정보를 담고 있다. false일 경우 대소문자를 구분하지 않고, true일 경우 대소문자를 구분한다. 이는 애너테이션을 선언하면서 변경해줄 수 있다.

예시)

@EnumValue(enumClass = Category.class, message = "invalid category", ignoreCase = true)
String category,

ValueOfEnumValidator

public class ValueOfEnumValidator implements ConstraintValidator<EnumValue, String> {

    private EnumValue enumValue;

    @Override
    public void initialize(EnumValue constraintAnnotation) {
        this.enumValue = constraintAnnotation;
    }

    @Override
    public boolean isValid(String value, ConstraintValidatorContext context) {
        Enum<?>[] enumValues = this.enumValue.enumClass().getEnumConstants();

        if (enumValues == null) return false;

        if(value ==null) return false;

        return Arrays.stream(enumValues).anyMatch(eVal -> isValueValid(value, eVal));
    }

    private boolean isValueValid(String value, Enum<?> eVal) {
        return value.equals(eVal.toString())
                || (this.enumValue.ignoreCase() && value.equalsIgnoreCase(eVal.toString()));
    }
}

@EnumValue 애너테이션을 선언하면 ValueOfEnumValidator에 의해서 유효성 검증이 이루어진다.

  • public class ValueOfEnumValidator implements ConstraintValidator<EnumValue, String> : ValueOfEnumValidator가 @EnumValue 애너테이션에 대해 작동하고 검증 대상이 String임을 나타낸다.

  • public void initialize(EnumValue constraintAnnotation) {
    this.enumValue = constraintAnnotation;
    } : ValueOfEnumValidator가 초기화될 때 호출된다. 여기서는 @EnumValue애너테이션의 인스턴스(DTO에서 지정해준 Enum 클래스)를 멤버변수에 저장한다.

private boolean isValueValid(String value, Enum<?> eVal) {
        return value.equals(eVal.toString())
                || (this.enumValue.ignoreCase() && value.equalsIgnoreCase(eVal.toString()));
    }
  • value가 Enum의 요소들 중 하나와 일치하거나 enumValue의 ignoreCase가 true로 설정되어있을 경우 대소문자 구분없이 비교하였을때 일치하면 true를 반환한다.

결과적으로 isValid메서드가 true를 반환하면 유효성 검증에 성공한것이고 그렇지 않을 경우 MethodArgumentNotValidException가 발생한다.

profile
함께 있고 싶은 사람, 함께 일하고 싶은 개발자

0개의 댓글