기존에
@Valid
를 통해서 HTTP request를 통해 body에 담긴 값들을 DTO에서 String, Integer와 같은 클래스에@NotNull
과 같은 애너테이션을 활용하여 값을 검증해줄 수 있었다.Enum 클래스의 값을 검증해주는것은 처음 알게 되었고 사용자 정의 애너테이션을 만들어 값을 검증해는것이 서비스 코드에서 따로 값을 검증해줄 필요도 없어 한단계 검증 작업을 줄여주기 때문에 편리하고 유용하다는 사실을 깨달았기에 공부한 내용을 정리한다.
참고) @Valid
를 통해 값을 검증한 결과 유요하지 않으면 MethodArgumentNotValidException가 발생한다.
@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를 통해 지정해줄 수 있다.
예시)
@EnumValue(enumClass = ScheduleType.class, message = "유효하지 않은 일정 타입입니다. TRAVEL, DATE, ANNIVERSARY, PERSONAL, ETC 중 입력하세요", ignoreCase = true)
예) 하나의 객체에 대해서 저장 시와 업데이트 시 다른 검증 규칙을 적용하고 싶다.
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,
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()));
}
결과적으로 isValid메서드가 true를 반환하면 유효성 검증에 성공한것이고 그렇지 않을 경우 MethodArgumentNotValidException가 발생한다.