ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [Spring] Validation 체크를 커스텀 개발해보자 (ConstraintValidator, 초기화에 대한 궁금증)
    백엔드/Spring 2022. 11. 7. 22:33

    자바에서는 Java Community Process (JCP)에서 “Bean Validation”이라는 스펙을 공개를 했습니다.
    이는 Validation 체크를 위해서 많은 자바 진영의 개발자들이 사용하고 있습니다.
     
    참고로 실제 우리가 사용하고 있는 @NotNull, @NotBlank와 같은 Annotation은 Bean Validation의 Implementation인 “Hibernate Validator”입니다.
     
     
    위 내용은 이전의 포스팅에서 설명했습니다.
     

    [Java] Bean Validation에 대해서 알아보자 (JSR-303, JSR-380, 파라미터 유효성 체크)

    서비스에서 중요한 기술 중 하나가, Validation을 체크하는 기술입니다. 클라이언트에서 서버사이드로 값을 전달할 때, 올바른 값이 전달되었는지, Validation을 체크 할 수 있어야 합니다. 이러한 기

    redcoder.tistory.com

     

    🔆 Situation

    Application에서 Validation을 체크하는 과정에서, 이미 정해진 열거형 타입이나, 형식이 있다면 어떻게 할까요?
    형식과 타입을 벗어나면, 오류를 뱉도록 개발을 해야만 하는 경우를 말하는 바입니다.
     
    이전 포스팅에서 설명한 영화 추가 API의 요청 DTO의 경우, 아래와 같았습니다.
    @Data
    public class MovieAddRequest {
        @NotBlank(message = "MovieAddRequest.name은 빈 값일 수 없습니다")
        private String name;
    
        @NotBlank(message = "MovieAddRequest.category 빈 값일 수 없습니다")
        private String category;
    
        @NotBlank(message = "MovieAddRequest.useYn 빈 값일 수 없습니다")
        private String useYn;
    }
    
     
    실제로 주어진 타입은 아래와 같다고 가정합니다. 
    (MovieAddRequest의 category가 아래 값을 가져야 합니다)
    public enum MovieCategory {
        ROMANCE("로맨스"),
        SF("SF"),
        ANIMATION("애니메이션"),
    
        private String value;
    }
     
    이러한 경우, Java의 Annotation을 적절하게 사용하며 
    Custom Exception까지 발생시킬 수 있는 방법이 필요합니다.
     

    ⚠️ Fault Case

    우리는 단순히 아래와 같은 코드를 많이 접합니다.
    @Data
    public class MovieAddRequest {
        @NotBlank(message = "MovieAddRequest.name은 빈 값일 수 없습니다")
        private String name;
    
        // MovieCategory에 적용할 수 있는 Validator이 없기 때문에, Annotation이 없음
        private MovieCategory category;
    
        @NotBlank(message = "MovieAddRequest.useYn 빈 값일 수 없습니다")
        private String useYn;
    }
    올바르지 않은 요청 데이터에 Exception이 발생하며, 문제가 없어보입니다.
    심지어 Best Practice의 경우, 요청-응답에 문제가 없습니다.
     
    curl --location --request POST 'http://localhost:8080/' \
    --header 'Content-Type: application/json' \
    --data-raw '{
        "name": "제목",
        "category": "A",
        "useYn": "N"
    }'
    하지만, 데이터를 잘못 보내서 오류가 발생했을 때
    사실 우리가 정의한 Custom Exception이 아닙니다. 
    파싱 오류입니다.
     
    Resolved [org.springframework.http.converter.HttpMessageNotReadableException: 
    JSON parse error: Cannot deserialize value of type `kr.rojae.valid.enums.MovieCategory` from String "A": not one of the values accepted for Enum class: [SF, ANIMATION, ROMANCE];
    이 마저를 Custom Exception 처리를 하기야 할 수 있습니다.
    하지만, 그 모든 경우를 Handling 하기에는 어려움이 많습니다.
     
     
    왜냐면 요청 데이터가 DTO 객체의 역-직렬화 된 값으로 전이되는 과정에서 Parsing Error가 발생하기 때문이고 
    이 모든 값의 역-직렬화에 개입하기에는 프로젝트의 Case가 너무 많습니다. 
     

    ✏️ Improvement method

    저는 우선 Enum과 같은 Parsing Error를 유발하는 Type은 사용하지 않았습니다. (상당히 번거롭다고 판단)
    “Bean Validation”에서 제공하는 “ConstraintValidator” 인터페이스를 구현하였으며, 
    Annotation을 구현하여 DTO에서 개발자가 자체적으로 설정해줄 수 있는 Validation의 유연하게 하여 
    비즈니스 로직에 집중하도록 하였습니다.
    API에서 제공하고 있는 메소드입니다.
    • initialize
    • isValid
     
    물론 특정 요청에서만 체크하는 경우, 별도의 메소드로 빼는 것이 좋겠습니다만, 그런 경우가 없을 것으로 판단되었습니다. 😁
     
     

    ✏️ Better Case

    우선 “ConstraintValidator”을 구현하여, 실제 검증 부분을 먼저 개발하였습니다.
    또한 “알 수 없는 타입" 자체를 하나의 열거형으로 관리하도록 하였습니다. (MovieCategory 수정)
    그리고 이를 체킹하는 부분을 상호간에 연결했습니다.
     
    MovieCategory
    public enum MovieCategory {
        ROMANCE("로맨스"),
        SF("SF"),
        ANIMATION("애니메이션"),
        UNKNOWN("알 수 없음");
    
        private String value;
    
        MovieCategory(String value) {
            this.value = value;
        }
    
        public static MovieCategory valueOfName(String name){
            for(var e : MovieCategory.values()){
                if(e.name().equals(name)){
                    return e;
                }
            }
            return UNKNOWN;
        }
    }
    
    
     
     
     
    MovieCategoryValidator
    public class MovieCategoryValidator implements ConstraintValidator<MovieCategoryValid, String> {
    
        @Override
        public boolean isValid(String s, ConstraintValidatorContext constraintValidatorContext) {
            if (MovieCategory.UNKNOWN.equals(MovieCategory.valueOfName(s))) {
                return false;
            } else {
                return true;
            }
        }
    }
    
    
     
    그리고 Custom Annotation인 @MovieCategoryValid를 추가하였습니다.
     
    @MovieCategoryValid
    @Target(ElementType.FIELD)
    @Retention(RetentionPolicy.RUNTIME)
    @Constraint(validatedBy = MovieCategoryValidator.class)
    public @interface MovieCategoryValid {
        String message() default "Invalid Movie Category Type";
        Class[] groups() default {};
        Class[] payload() default {};
    }
    
     
    이제 개발이 되었으니, DTO에 연결만 해주면 됩니다.
     
    MovieAddRequest (수정됌)
    @Data
    public class MovieAddRequest {
        @NotBlank(message = "MovieAddRequest.name은 빈 값일 수 없습니다")
        private String name;
    
        @MovieCategoryValid(message = "MovieAddRequest.category이 유효한 값이 아닙니다")
        private String category;
    
        @NotBlank(message = "MovieAddRequest.useYn은 빈 값일 수 없습니다")
        private String useYn;
    }
    

    🧩 Run And Test

    일부러 올바르지 못한 값을 던져 보았습니다.
     
    요청 값
    curl --location --request POST 'http://localhost:8080/' \
    --header 'Content-Type: application/json' \
    --data-raw '{
        "name": "제목",
        "category": "A",
        "useYn": "N"
    }'
    
     
    응답 값
    {
        "message": "MovieAddRequest.category이 유효한 값이 아닙니다",
        "response": ""
    }
    
     
    개발자의 의도대로 올바르게 Validation Checking이 이루어졌고, Custom Exception 발생으로 인해서
    Body도 Control이 가능해졌습니다.
     

    💬 Extension Case

    MovieCategoryValidator에서 initialize 함수를 제대로 사용하지 못했습니다.
    어떤 경우 유용하게 사용할 수 있을까요? 🤔
     
    바로 Annotation에서 초기화를 위한 값이 있는 경우, 사용이 가능합니다.
    (애초에 얘는, 데이터 전달을 해주는 친구가 Annotation 밖에 없거든요 😄)
     
    설명을 위해서 DTO의 “useYn”에 Custom Validation Checking을 적용해 보았습니다.
     
    @StringValid
    @Target(ElementType.FIELD)
    @Retention(RetentionPolicy.RUNTIME)
    @Constraint(validatedBy = StringValidator.class)
    public @interface StringValid {
        String[] acceptedList();
        String message() default "Invalid Movie Category Type";
        Class[] groups() default {};
        Class[] payload() default {};
        boolean ignoreLetterCase() default false;
    }
    
     
    설명
    • List<String> acceptedList : 변수가 받을 수 있는 문자열의 종류
    • boolean ignoreLetterCase : 대소문자 무시 여부
     
    실제 DTO에 연결을 해보면, 아래처럼 바뀝니다.
     
     
    MovieAddRequest (수정됌)
    @Data
    public class MovieAddRequest {
        @NotBlank(message = "MovieAddRequest.name은 빈 값일 수 없습니다")
        private String name;
    
        @MovieCategoryValid(message = "MovieAddRequest.category이 유효한 값이 아닙니다")
        private String category;
    
        @StringValid(acceptedList = {"Y", "N"}, message = "MovieAddRequest.useYn이 유효한 값이 아닙니다")
        private String useYn;
    }
    
     
    자 이제, Validation 체크를 위한 로직 구현만 하면 됩니다.
     
    StringValidator
    public class StringValidator implements ConstraintValidator<StringValid, String> {
    
        private List<String> valueList;
        private StringValid stringValid;
    
        @Override
        public void initialize(StringValid constraintAnnotation) {
            valueList = new ArrayList<>();
            valueList.addAll(List.of(constraintAnnotation.acceptedList()));
            this.stringValid = constraintAnnotation;
        }
    
        @Override
        public boolean isValid(String s, ConstraintValidatorContext constraintValidatorContext) {
            boolean ignoreLetterCase = stringValid.ignoreLetterCase();
    
            if (ignoreLetterCase) {
                for (String value : valueList) {
                    if (value.equalsIgnoreCase(s))
                        return true;
                }
                return false;
            } else {
                return valueList.contains(s);
            }
        }
    }
    
     
    이를 통해서 Validation 체크가 가능하게 되었습니다.
     
    요청
    curl --location --request POST 'http://localhost:8080/' \
    --header 'Content-Type: application/json' \
    --data-raw '{
        "name": "제목",
        "category": "A",
        "useYn": "A"
    }'
    
     
    응답
    {
        "message": "MovieAddRequest.category이 유효한 값이 아닙니다, MovieAddRequest.useYn이 유효한 값이 아닙니다",
        "response": ""
    }
    
     

    👀 Validator 초기화에 대한 부연설명 (TMI)

        private List<String> valueList;
        private StringValid stringValid;
    
        @Override
        public void initialize(StringValid constraintAnnotation) {
            valueList = new ArrayList<>();
            valueList.addAll(List.of(constraintAnnotation.acceptedList()));
            this.stringValid = constraintAnnotation;
        }
    
    isValid()함수는 요청을 던질 때마다, 과정을 거칩니다.
    (당연히 그래야만 합니다)
     
    그렇다면, 초기화는 수시로 이루어질까요?
    (정답은 첫 호출 1회만 일어납니다)
     

     
    “Hibernate Validator”의 내부의  “ConstraintTree<>” 를 참고하면 답을 찾을 수 있습니다.
     
     
    특히 final로 정의되어 있는 “descriptor” 이 친구를 타보면
     
     
     
    List 형식으로, Annotation의 정보가 저장되고 있습니다.
    (final이기 때문에 사용 중이라면, GC도 발생하지 않을겁니다)
     
     
    요청이 들어오고, Annotation에 대한 정보가 JVM 메모리에 있다면, 
    추가적으로 초기화를 진행하지 않기 때문에, 우리가 개발한 초기화도 일어나지 않는 것입니다.
    (123번째 줄)

    즉, 메모리에 적재되지 않은 Validation 정보를 가진, API들은 대상으로 Annotation의 정보를 JVM 메모리에 저장하는 첫 과정이 필요하게 됩니다.
    (첫번째 API 요청과 두번째 API 요청이.. 매우... 미세하게  차이가 있을지도 모르겠네요.)
     
     
     

    GitHub - rojae/Spring-Validation-Check-Sample

    Contribute to rojae/Spring-Validation-Check-Sample development by creating an account on GitHub.

    github.com

     
     
     
     
    👉 다음 포스팅에는 유용하게 사용할 수 있는 Custom Validation에 대해서 작성하겠습니다.
     
     
    반응형
Designed by Tistory.