인터페이스와 ConstraintValidator를 활용한 Enum 유효성 검사
- 웹 개발/Spring Boot
- 2021. 1. 23. 20:44
안녕하세요.
오늘은 인터페이스와 ConstraintValidator를 활용해서 Enum 타입으로 정리된 Integer 파라미터를 유효성 검사하는 패턴을 공유해보고자 합니다.
상황은 유저를 등록하는 시점이고, 파라미터로 Integer gender라는 값이(0: 남성, 1: 여성) 넘어올 때 아래와 같은 Enum 으로 정리된 클래스가 존재했습니다.
Controller Parameter로 매핑될 AddUserInfoParam 클래스
// lombok 어노테이션들.. 생략..
public class AddUserInfoParam {
// .. 기타 생략..
private Integer gender; // 남 0 여 1
}
GenderType Enum 클래스
public enum GenderType {
MALE(0, "남자"),
FEMALE(1, "여자");
private int type;
private String name;
GenderType(int type, String name) {
this.name = name;
this.type = type;
}
public boolean matched(int type) {
return this.type == type;
}
}
이제 실제로 개발을 할때 유효성검사를 통해
1. gender 라는 변수의 값이 null 인지 not null 인지
2. not null 이라면 개발자가 정의한 대로 0과 1의 값이 들어있는지
위의 두가지를 확인해야 하는데요. 1번의 경우 javax.validation.constraints의 @NotNull 어노테이션을 활용해서 흔히들 처리할 것입니다.
하지만 2번의 경우는 특정 로직에서 처리하던지 간에 직접적으로 값비교를 해야할텐데요. 아래와 같은 처리를 했던 것 같습니다.
// lombok 어노테이션들.. 생략..
public class AddUserInfoParam {
// .. 기타 생략..
private Integer gender; // 남 0 여 1
@JsonIgnore
@AssertTrue(message = "invalid parameter")
private boolean isGenderTypeValid() {
return Arrays.stream(GenderType.values()).anyMatch(type -> type.matched(gender));
}
}
바로 @AssertTrue를 활용해서 Param 클래스에 해당 처리를 했던 것 같아요.
근데 이제 User라 함은 여러가지 컬럼들이 비즈니스 로직에 의해서 추가되고 삭제되고 하는 중요한 부분일텐데.. 너무 클래스가 지저분해지거나 내용이 과다해질 것 같더라구요.
물론 클래스로 따로 분리해서 처리할 수도 있겠지만 이 또한 위의 문제와 크게 다르지 않을 것 같구요..(관련 내용이 많아진다는 것..)
그래서 Enum으로 정의한 Gender 타입 자체를 넘겨서 gender 라는 변수로 매핑된 값을 비교할 수는 없을까.. 고민을 많이 했고 많은 곳에서 레퍼런스를 얻었고..(물론 제가 생각한 방식은 많은 사람들이 포스팅을 해두셨긴 하더라구요...)
자바의 인터페이스를 이용하여 Enum의 Integer 값(타입값)을 정의하고 @NotNull과 같은 validation 어노테이션을 관찰한 결과 @EnumTypeValid 라는 어노테이션을 개발해 보았습니다.
@EnumTypeValid
@Constraint(validatedBy = EnumValidator.class)
@Documented
@Target(FIELD)
@Retention(RetentionPolicy.RUNTIME)
public @interface EnumTypeValid {
String message() default "invalid parameter!!";
Class<?>[] groups() default {};
Class<? extends InterfaceForEnum> target();
Class<? extends Payload>[] payload() default{};
class EnumValidator implements ConstraintValidator<EnumTypeValid, Integer> {
private List<Integer> types;
@Override
public void initialize(EnumTypeValid constraintAnnotation) {
types = Arrays.stream(constraintAnnotation.target().getEnumConstants())
.map(constant -> constant.getType())
.collect(Collectors.toList());
}
@Override
public boolean isValid(Integer value, ConstraintValidatorContext context) {
return types.contains(value);
}
}
}
(EnumValidator 클래스는 내부 클래스로 생성했으나 따로 빼셔도 됩니다!)
일단 설명은 뒤에서 진행하고 다음과 같이 Enum의 유효성을 잡을 어노테이션을 만들었습니다.
이제 만든 annotation을 활용해서 Param의 gender 변수에 대한 유효성 검사를 진행해보겠습니다.
gender 변수 유효성
// lombok 어노테이션들.. 생략..
public class AddUserInfoParam {
// .. 기타 생략..
@EnumTypeValid(target = GenderType.class, message = "invalid gender parameter")
@NotNull(message = "성별을 입력해주세요.")
private Integer gender; // 남 0 여 1
}
@EnumTypeValid 클래스 파일을 가서 확인해보면 target이라는 속성이 존재합니다. 이는 실제 @EnumTypeValid 어노테이션에 target 속성으로 GenderType.class 라는 타입을 지정해주면 실제 bean validator에 의해 ConstraintValidator를 구현한 클래스들을 실행하고 intialize(), isValid() 의 실행순서를 가지면서 실행됩니다.
initialize() : 파라미터로 실제 Param 클래스에 사용된 @EnumTypeValid 어노테이션이 매핑되며 target 에는 GenderType.class 타입이 존재하게 됩니다. 해당 타입을 얻었으니 전체 enum의 상수 constants 들을 얻을 수 있고 이를 통해서 모든 integer 타입들(0: 남성, 1: 여성) 을 얻을 수 있습니다.
isValid() : 실제 유효성 판단을 내리는 곳으로 initialize를 통해서 integer 타입들을 얻었으니 contains 메소드를 활용해서 현재 Param 클래스의 gender 변수로 들어온 값이 types에 존재하는지만 판단해주면 됩니다.
헌데 EnumTypeValid 클래스에서
Class<? extends InterfaceForEnum> target();
InterfaceForEnum은 무엇일까요??
이것은 target 속성에 오는 것은 InterfaceForEnum을 구현한 클래스타입만 올수 있게 제한을 걸어두는 것인데요. 해당 타입은 Enum 클래스만 구현하도록 진행해야 합니다.
InferfaceForEnum
public interface InterfaceForEnum {
int getType();
String getName();
}
즉, GenderType 의 enum은 아래와 같이 설정되있어야합니다.
GenderType
public enum GenderType implements InterfaceForEnum {
MALE(0, "남자"),
FEMALE(1, "여자");
private int type;
private String name;
GenderType(int type, String name) {
this.name = name;
this.type = type;
}
public String getName() {
return name;
}
public int getType() {
return type;
}
public boolean matched(int type) {
return this.type == type;
}
}
interface 포맷에 따라 getType과 getName을 선언해줍니다. Interface를 활용함으로써 Enum을 좀더 Class스럽게 접근할 수 있으며 Enum을 선언할 때 value, type, num 등과 같은 여러가지 integer 값을 지칭하는 이름을 하나로 합할 수도 있습니다.
enum에 굳이 Interface를 활용한 이유??!
실제로 Class<? extends Enum> 을 통해서도 처리할 수 있습니다.
하지만 enum을 좀더 클래스스럽게 사용하고 많은 사람들이 익숙한 타입으로 정의해두는게 좋겠다 싶어서 인터페이스를 활용하였습니다.
또한 enum의 다양한 변수명들을 하나로 통합 관리하기 편하다는 점(getType만 부르면 value로 하던 type으로하던 getType으로 해당 값을 리턴해주면 되므로 -> 물론 어떻게 구현하는지에 따라 다르지만 우리모두 약속을... ) Interface를 상속하는 비용이 들더라도 상기 문제를 해소했다는 점을 꼽습니다.