본문 바로가기
Web/BackEnd

[BackEnd] Enum 유효성 검사 구현기

by 희조당 2023. 4. 25.
728x90

🤔 들어가기 앞서

사실 enum에 대한 유효성 검사를 위해서 Custom Annotation을 구현할 필요는 없을 수 있다.

하지만 나는 DTO에서 enum 타입 자체를 가지고 있고, 구현한 enum 타입이 많아서 만들었다.

이를 통해서, Parse 오류도 잡고 클라이언트에서 어떤 값을 잘못 보냈는지 확인할 수 있게 되었다! 😋😋

 

Custom Annotation을 구현하는 방법은 이전에 작성한 글을 참고하기 바란다!


🪄 Enum용 CustomAnnotation 만들기

작성한 코드는 우선 다음과 같다. 핵심으로 표시한 부분은 다음에 설명하겠다.

@Target({ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = EnumFormatValidator.class)
public @interface EnumFormat {
    String message() default "해당 필드의 타입에서 지원하지 않는 값입니다.";

    Class[] groups() default {};

    Class<? extends Enum> enumClass(); // --- 핵심

    Class[] payload() default {};
}

🛠️ 유효성 검사기 구현하기

작성한 코드를 보면 다음과 같다. 직관성을 위해서 메서드를 최대한 분리하는 편이다 ㅎㅎ

public class EnumFormatValidator implements ConstraintValidator<EnumFormat, Enum> {
    private List<Object> enumValues;

    @Override
    public void initialize(EnumFormat constraintAnnotation) {
        enumValues = getEnumValues(constraintAnnotation);
    }

    @Override
    public boolean isValid(Enum value, ConstraintValidatorContext context) {
        return isNotNull(value) && isContainsValue(value);
    }

    private List<Object> getEnumValues(EnumFormat constraintAnnotation) {
        Class<? extends Enum> enumClass = constraintAnnotation.enumClass();
        return Arrays.stream(enumClass.getEnumConstants()).collect(Collectors.toList());
    }

    private boolean isContainsValue(Enum value) {
        return enumValues.contains(value);
    }

    private boolean isNotNull(Enum value) {
        return value != null;
    }
}

 

가장 먼저 초기화를 진행한다. Reflection API를 통해서 검사하는 enum의 값들을 가져온다.

@NotNull을 사용하지 않고 자체적으로 null에 대한 검사를 진행하고 싶어서 검사 메서드를 추가하였고,

포함 여부는 enum에 수백, 수천 가지의 값을 넣을 것 같진 않아서 contains()로 검사하였다.

 

처음에 고민했던 점은, 이 유효성 검사에 어떻게 다양한 enum에 모두 공통적으로 적용시킬 것인가였다.

Reflection API로 enum의 값들은 쉽게 가져올 수 있었지만 정작 enum이 어떤 녀석인지 알 수가 없었다.

따라서, 조금 원시적인 방법이지만 직접 어떤 enum을 검사할 것인지 넘겨주는 방식을 선택했다.

public class MyDto {
  @EnumFormat(enumClass = MyEnum.Class) // Enum의 클래스를 명시
  private MyEnum myEnum;
}

그 결과, 유효성 검사를 진행할 때 constraintAnnotation.enumClass()로 enum의 클래스를 가져올 수 있게 되었다.

모든 enum에 대한 유효성 검사를 할 수 있게 되었단 뜻이다. 😋😋


💣 새로운 문제 직면

"생각보다 너무 쉽네"라는 생각과 "당연히 잘되겠지"라는 생각으로 테스트를 진행해 봤다.

엥? 나를 반겨주는 것은 400 Error였다. 이게 무슨..

😨 원인 분석

우선, 내 의도는 클라이언트에서 null, 빈 값, 그리고 enum에 없는 값을 보내면 검사 실패를 반환할 생각이었다.

발생하는 에러는 Json parse Error로, 클라이언트에서 HTTP Body에 담아 보낸 Json 객체를 스프링에서 일치하는 Java 객체로 변환할 때 발생하는 에러였다.

따라서, 이 에러는 유효성 검사가 발생하기 이전에 발생해서 원하는 대로 예외를 핸들링할 수도 없다.

🥲 문제 해결하기

구글링과 ChatGPT의 도움으로 사방팔방으로 찾아봤지만 내가 원하는 해결법은 하나도 없었다.

찾은 해결법 중 하나는 @JsonCreator 사용하기였다.

 

@JsonCreator는 DTO 객체로 매핑할 때 사용할 생성자를 지정해 주는 방법이다.

분명히 내가 직면한 문제를 해결할 수 있지만 enum 타입이 매우 많다면 그건 또 다른 문제였다.

즉, 모든 enum 타입에 생성자를 작성해줘야 하는데 이건 개발자답지 않다고 생각했다.

 

그래서 근원적으로 접근하기로 생각했다. 

다시 Json parse Error가 발생 시점으로 돌아가면, Json 객체가 Java 객체로 변환될 때 문제이다.

변환되는 과정을 역직렬화라고 하는데, 역직렬화될 때 매핑되는 값을 수정하면 될 것 같았다.

 

유효성 검사기에서 null인 경우 바로 false를 리턴하도록 만들었으니, 

만약 DTO의 enum 필드에 들어온 값이 빈 값 혹은 다른 값이라면 null을 매핑하도록 만들면 될 것 같았다.


🔐 CustomEnumDeserializer 구현하기

구현한 CustomEnumDeserializer는 다음과 같다.

public class CustomEnumDeserializer<T extends Enum<T>> extends JsonDeserializer<T> {

    @Override
    public T deserialize(JsonParser p, DeserializationContext ctxt) throws IOException {
        final String value = p.getValueAsString();
        final Class<?> dtoClass = p.getCurrentValue().getClass();
        final String fieldName = p.currentName();

        try {
            final Class<T> enumClass = getEnumClass(dtoClass, fieldName);
            return Enum.valueOf(enumClass, value);
        } catch (IllegalArgumentException | NoSuchFieldException e) {
            return null;
        }
    }

    private static <T extends Enum<T>> Class<T> getEnumClass(Class<?> dtoClass, String fieldName) throws NoSuchFieldException {
        Field field = dtoClass.getDeclaredField(fieldName);
        return (Class<T>) field.getType();
    }
}

 

각 메서드의 리턴 값은 다음과 같다.

  • getValueAsString() : 클라이언트에서 넘어온 필드의 값을 String으로 변환 
  • getCurrentValue().getClass() : 역직렬화하는 DTO의 클래스
  • currentName() : 역직렬화하는 DTO의 필드의 이름 (ex. private MyEnum myEnum의 myEnum)

 

여기서 핵심은 Reflection APIEnum.valueOf()이다.

이 Custom 역직렬화 메서드는 모든 enum에 대해서 적용되야 하기 때문에 enum의 클래스를 가져와야 한다.

그렇다면, "위에서 작성한 것처럼 사용하면 되지 않을까?"라고 생각할 수 있지만 사용방법 자체가 다르다.

// CustomEnumDeserializer 적용법
@JsonDeserialize(using = CustomEnumDeserializer.class)
public enum MyEnum {
  // ...
}

 

역직렬화 시 파라미터로 들어오는 값을 통해서는 확인해야 할 enum의 클래스를 알 수 있는 방법이 없었다.

그래서 Reflection API를 사용했고, DTO의 클래스와 필드의 이름으로 enum의 클래스를 가져왔다.

따라서, 동적으로 확인하기 때문에 특정 타입을 명시하지 않아도, 수많은 코드를 작성할 수고도 덜었다.

 

그다음으론 어떤 enum인지 알았으니 클라이언트에서 넘어온 값으로 해당 타입으로 변환시킬 수 있는지 확인한다.

변환 메서드로 Enum.valueOf()를 사용하는데 변환할 수 없을 때는 IllegalArgumentException을 반환한다.

따라서, 이 예외가 발생하면 아까 말한 대로 null을 매핑시켜 주면되는 것이다.


😋 정리

enum 타입에 유효한 값이 들어왔는지 검사하는 어노테이션을 구현했다.

정해진 값이 들어오지 않았을 때 Json parse Error가 발생했는데, 해결방법 중 하나인 @JsonCreator는 너무 많은 코드를 작성해야 했다.

따라서, CustomEnumDeserializer를 구현했고 이를 통해서 매핑되는 값을 제어해 줬다.

나 같은 고민을 가진 분이 있다면 이런 방법을 사용해 보시는 것도 좋을 것 같다!

 

사실, Reflection API 이렇게 막 사용해도 되는지는 잘 모르겠다.

이 내용에 대해서는 다음에 제대로 한번 다뤄봐야겠다.


😋 지극히 개인적인 블로그지만 훈수와 조언은 제 성장에 도움이 됩니다 😋 

 

댓글