개발자의 길

[Spring] java - 유용한 Custom Validation 본문

4. JAVA

[Spring] java - 유용한 Custom Validation

자르르 2024. 7. 8. 16:19


Package Tree

패키지 구성에 대한 내용입니다.

유효성 검증 기능의 시작은 “validator”라는 패키지를 시작으로 구성됩니다.

└── validator   # validation 구현의 시작이며, 프로젝트 전용 파일들
    └── common  # 공통적으로 사용될 수 있는 모듈
        ├── extension  # 다른 패키지에서 사용하는 공통적인 확장 모듈
        ├── format     # 날짜, URL, IP와 같은 포맷 기반의 모듈
        └── match      # 특수문자, 영어, 한글과 같은 문자열 매칭 기반의 모듈
        └── utils      # 정규식 활용을 위한 유틸리티들

패키지는 위와 같은 기준으로 분리 구분하여, 실질적으로 common 내부의 모듈은 프로젝트에서 건들이지 않고

사용이 가능하게 됩니다.

아래는 샘플로 구현된 목록입니다.

 

└── validator    # validation 구현의 시작이며, 프로젝트 전용 파일들
    ├── EmailValid.java
    ├── EmailValidator.java
    ├── LoginIdValid.java
    ├── MovieCategoryValid.java
    ├── MovieCategoryValidator.java
    └── common   # 공통적으로 사용될 수 있는 모듈
        ├── extension  # 다른 패키지에서 사용하는 공통적인 확장 모듈
        │   ├── StringValid.java
        │   └── StringValidator.java
        ├── format     # 날짜, URL, IP와 같은 포맷 기반의 모듈
        │   ├── IsDateValid.java
        │   ├── IsDateValidator.java
        │   ├── IsIpValid.java
        │   ├── IsIpValidator.java
        │   ├── IsUrlPathValid.java
        │   ├── IsUrlPathValidator.java
        │   ├── IsUrlValid.java
        │   ├── IsUrlValidator.java
        │   ├── IsUuidValid.java
        │   ├── IsUuidValidator.java
        │   └── IsYnValid.java
        └── match     # 특수문자, 영어, 한글과 같은 문자열 매칭 기반의 모듈
            ├── NoSpecialValid.java
            ├── NoSpecialValidator.java
            ├── OnlyAlphabetValid.java
            ├── OnlyAlphabetValidator.java
            ├── OnlyDownerValid.java
            ├── OnlyDownerValidator.java
            ├── OnlyKoreanValid.java
            ├── OnlyKoreanValidator.java
            ├── OnlyNumericValid.java
            ├── OnlyNumericValidator.java
            ├── OnlyNumericWithAlphabetValid.java
            ├── OnlyNumericWithAlphabetValidator.java
            ├── OnlyUpperValid.java
            └── OnlyUpperValidator.java
        └── utils      # 정규식을 위한 유틸리티
            └── RegexUtils.java

설명과 동시에 정리하면 아래와 같은 구조가 될 것 입니다.

  • 프로젝트 전용 (validator)
    • @EmailValid
    • @LoginIdValid
    • @MovieCategoryValid
    • 공통 모듈 (common)
      • 확장 가능 (extension)
        • @StringValid
      • 포맷 기반 (format)
        • @IsDateValid (날짜 포맷)
        • @IsIpValid (IP 포맷)
        • @IsUrlValid (URL 포맷)
        • @IsUrlPathValid (URL 경로 포맷)
        • @IsUuidValid (UUID 포맷)
        • @IsYnValid (단일 문자열 Y, N 포맷)
      • 문자열 검사 기반 (match)
        • @NoSpecialValid (특수문자 검사)
        • @OnlyAlphabetValid (영문 검사)
        • @OnlyDownerValid (영문 소문자 검사)
        • @OnlyUpperValid (영문 대문자 검사)
        • @OnlyKoreanValid (한글 검사)
        • @OnlyNumericValid (숫자 검사)
        • @OnlyNumericWithAlphabetValid (한글과 숫자 검사)
      • 정규식 유틸리티
        • RegexUtils

Implementation (공통 패키지)

실질적인 구현 부분이지만, 크게 설명하는 부분보다 소스코드를 공유하면 다들 이해가 되실 거라고 생각합니다.

이전에 설명한 글과 동일하게 “ConstraintValidator”를 구현하여, Annotation에 validatedBy를 붙이는 방식입니다.

정규식 기반으로 동작하기 때문에, “RegexUtils”라는 클래스를 추가 구현했습니다.

 


1. validator.common.utils

 

RegexUtils

해당 유틸리티를 사용하여, Annotation 구현부에서 Validation Check를 합니다.
public class RegexUtils {

    public static boolean hasSpecialChar(String str){
        return str.matches ("[0-9|a-z|A-Z|ㄱ-ㅎ|ㅏ-ㅣ|가-힝]*");
    }

    public static boolean onlyNumericWithAlphabet(String str){
        return str.matches("^[a-zA-Z0-9]*");
    }

    public static boolean isNumeric(String str){
        return str.matches("^[0-9]*$");
    }

    public static boolean isAlphabet(String str){
        return str.matches("^[a-zA-Z]*$");
    }

    public static boolean isKorean(String str){
        return str.matches("[가-힣]*$");
    }

    public static boolean isUpper(String str){
        return str.matches("^[A-Z]*$");
    }

    public static boolean isDowner(String str){
        return str.matches("^[a-z]*$");
    }

    public static boolean isUrl(String str){
        return str.matches("(http[s]?:\\/\\/)([a-zA-Z0-9]+)\\.[a-z]+([a-zA-Z0-9.?#]+)?");
    }

    public static boolean isUrlPath(String str){
        return str.matches("(http[s]?:\\/\\/)([^\\/\\s]+\\/)(.*)");
    }

    public static boolean isIp(String str){
        return str.matches("([0-9]{1,3})\\.([0-9]{1,3})\\.([0-9]{1,3})\\.([0-9]{1,3})");
    }

    public static boolean isDate(String str){
        return str.matches("^\\d{4}.\\d{2}.\\d{2}$");
    }

    public static boolean isUUID(String str){
        return str.matches("[a-f0-9]{8}(?:-[a-f0-9]{4}){4}[a-f0-9]{8}");
    }
}

 


2. validator.common.extension

 

@StringValid

정의된 String과 일치하는지 체크합니다. 대소문자 무시를 위해서 ignoreLetterCase라는 boolean 변수도 추가하였습니다.

 

StringValid.java

@Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER, TYPE_USE })
@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;
}


 

StringValidator.java

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);
        }
    }
}

 


3. validator.common.format

@IsDateValid

정의된 날짜 포맷과 일치하는 지 검사합니다. (YYYY.MM.DD)

 

IsDateValid.java

@Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER, TYPE_USE })
@Retention(RUNTIME)
@Constraint(validatedBy = IsDateValidator.class)
public @interface IsDateValid {
    String message() default "Required only date Format (yyyy.mm.dd)";
    Class[] groups() default {};
    Class[] payload() default {};
}

 

IsDateValidator.java

public class IsDateValidator implements ConstraintValidator<IsDateValid, String> {
    @Override
    public boolean isValid(String value, ConstraintValidatorContext context) {
        return RegexUtils.isDate(value);
    }
}

 


@IsIpValid

정의된 IP형식과 일치하는지 검사합니다. (숫자 1~3개 사이마다 '.' 포함)

 

IsIpValid.java

@Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER, TYPE_USE })
@Retention(RUNTIME)
@Constraint(validatedBy = IsIpValidator.class)
public @interface IsIpValid {
    String message() default "Required only IP Address Format";
    Class[] groups() default {};
    Class[] payload() default {};
}

 

IsIpValidator.java

public class IsIpValidator implements ConstraintValidator<IsIpValid, String> {
    @Override
    public boolean isValid(String value, ConstraintValidatorContext context) {
        return RegexUtils.isIp(value);
    }
}

 


@IsUrlPathValid

URL Path 형식이 맞는지 확인합니다.

 

IsUrlPathValid.java

@Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER, TYPE_USE })
@Retention(RUNTIME)
@Constraint(validatedBy = IsUrlPathValidator.class)
public @interface IsUrlPathValid {
    String message() default "Required only url path Format";
    Class[] groups() default {};
    Class[] payload() default {};
}

 

IsUrlPathValidator.java

public class IsUrlPathValidator implements ConstraintValidator<IsUrlPathValid, String> {
    @Override
    public boolean isValid(String value, ConstraintValidatorContext context) {
        return RegexUtils.isUrlPath(value);
    }
}

 


@IsUrlValid

URL 형식이 맞는지 확인합니다. (Path는 혀용하지 않습니다)
https://google.com -> O
https://google.com/books -> X

 

IsUrlValid.java

@Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER, TYPE_USE })
@Retention(RUNTIME)
@Constraint(validatedBy = IsUrlValidator.class)
public @interface IsUrlValid {
    String message() default "Required only URL Format";
    Class[] groups() default {};
    Class[] payload() default {};
}

IsUrlValidator.java

public class IsUrlValidator implements ConstraintValidator<IsUrlValid, String> {
    @Override
    public boolean isValid(String value, ConstraintValidatorContext context) {
        return RegexUtils.isUrl(value);
    }
}

 


@IsUuidValid

UUID 규약을 준수하여, 포맷을 검사합니다.

IsUuidValid.java

@Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER, TYPE_USE })
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = IsUuidValidator.class)
public @interface IsUuidValid {
    String message() default "Required only UUID Format";
    Class[] groups() default {};
    Class[] payload() default {};
}

IsUuidValidator.java

public class IsUuidValidator implements ConstraintValidator<IsUuidValid, String> {
    @Override
    public boolean isValid(String value, ConstraintValidatorContext context) {
        return RegexUtils.isUUID(value);
    }
}

 


@IsYnValid

StringValid Annotation을 사용하여, Y 혹은 N 문자열만 허용하는 Annotation입니다.

 

IsYnValid.java

StringValid Annotation을 활용하여 개발하기 때문에, 실제 구현부는 공백으로 둡니다.

@Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER, TYPE_USE })
@Retention(RetentionPolicy.RUNTIME)
@StringValid(acceptedList = {"Y", "N"})
@Constraint(validatedBy = {})
public @interface IsYnValid {
    String message() default "Required only character (Y, N)";
    Class[] groups() default {};
    Class[] payload() default {};
}

 


3. validator.common.match

 

@NoSpecialValid

특수문자가 아닌 경우, 허용되는 Annotation입니다.

 

NoSpecialValid.java

@Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER, TYPE_USE })
@Retention(RUNTIME)
@Constraint(validatedBy = NoSpecialValidator.class)
public @interface NoSpecialValid {
    String message() default "Required no Special Character";
    Class[] groups() default {};
    Class[] payload() default {};
}

 

NoSpecialValidator.java

public class NoSpecialValidator implements ConstraintValidator<NoSpecialValid, String> {

    @Override
    public void initialize(NoSpecialValid constraintAnnotation) {
        ConstraintValidator.super.initialize(constraintAnnotation);
    }

    @Override
    public boolean isValid(String value, ConstraintValidatorContext context) {
        return !RegexUtils.hasSpecialChar(value);
    }

}

 


@OnlyAlphabetValid

영문자 알파벳으로만 이루어진 경우 허용되는 Annotation입니다.

 

OnlyAlphabetValid.java

@Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER, TYPE_USE })
@Retention(RUNTIME)
@Constraint(validatedBy = OnlyAlphabetValidator.class)
public @interface OnlyAlphabetValid {
    String message() default "Required only Alphabet";
    Class[] groups() default {};
    Class[] payload() default {};
}

 

OnlyAlphabetValidator.java

public class OnlyAlphabetValidator implements ConstraintValidator<OnlyAlphabetValid, String> {
    @Override
    public boolean isValid(String value, ConstraintValidatorContext context) {
        return RegexUtils.isAlphabet(value);
    }
}

 


@OnlyDownerValid

영어 소문자인 경우만 허용되는 Annotation입니다.

 

OnlyDownerValid.java

@Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER, TYPE_USE })
@Retention(RUNTIME)
@Constraint(validatedBy = OnlyDownerValidator.class)
public @interface OnlyDownerValid {
    String message() default "Required only alphabet downercase";
    Class[] groups() default {};
    Class[] payload() default {};
}

 

OnDownerValidator.java

public class OnlyDownerValidator implements ConstraintValidator<OnlyDownerValid, String> {
    @Override
    public boolean isValid(String value, ConstraintValidatorContext context) {
        return RegexUtils.isDowner(value);
    }
}

 


@OnlyKoreanValid

한글인 경우만 허용되는 Annotation입니다.

 

OnlyKoreanValid.java

@Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER, TYPE_USE })
@Retention(RUNTIME)
@Constraint(validatedBy = OnlyKoreanValidator.class)
public @interface OnlyKoreanValid {
    String message() default "Required only Korean";
    Class[] groups() default {};
    Class[] payload() default {};
}

 

OnlyKoreanValidator.java

public class OnlyKoreanValidator implements ConstraintValidator<OnlyKoreanValid, String> {
    @Override
    public boolean isValid(String value, ConstraintValidatorContext context) {
        return RegexUtils.isKorean(value);
    }
}

 


@OnlyNumericValid

문자열이 숫자인 경우만 허용되는 Annotation입니다.

 

OnlyNumericValid.java

@Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER, TYPE_USE })
@Retention(RUNTIME)
@Constraint(validatedBy = OnlyNumericValidator.class)
public @interface OnlyNumericValid {
    String message() default "Required only Number";
    Class[] groups() default {};
    Class[] payload() default {};
}

 

OnlyNumericValidator.java

public class OnlyNumericValidator implements ConstraintValidator<OnlyNumericValid, String> {

    @Override
    public boolean isValid(String value, ConstraintValidatorContext context) {
        return RegexUtils.isNumeric(value);
    }
}

 


@OnlyNumericWithAlphabetValid

문자열이 숫자이거나 영문자인 경우에만, 허용되는 Annotation입니다.

 

OnlyNumericWithAlphabetValid.java

@Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER, TYPE_USE })
@Retention(RUNTIME)
@Constraint(validatedBy = OnlyNumericWithAlphabetValidator.class)
public @interface OnlyNumericWithAlphabetValid {
    String message() default "Required only digit or alphabet";
    Class[] groups() default {};
    Class[] payload() default {};
}

 

OnlyNumericWithAlphabetValidator.java

public class OnlyNumericWithAlphabetValidator implements ConstraintValidator<OnlyNumericWithAlphabetValid, String>{
    @Override
    public void initialize(OnlyNumericWithAlphabetValid constraintAnnotation) {
        ConstraintValidator.super.initialize(constraintAnnotation);
    }

    @Override
    public boolean isValid(String value, ConstraintValidatorContext context) {
        return RegexUtils.onlyNumericWithAlphabet(value);
    }
}

 


@OnlyUpperValid

영문자가 대문자로만 이루어진 경우 허용되는 Annotation입니다.

 

OnlyUpperValid.java

@Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER, TYPE_USE })
@Retention(RUNTIME)
@Constraint(validatedBy = OnlyUpperValidator.class)
public @interface OnlyUpperValid {
    String message() default "Required only alphabet uppercase";
    Class[] groups() default {};
    Class[] payload() default {};
}

 

OnlyUpperValidator.java

public class OnlyUpperValidator implements ConstraintValidator<OnlyUpperValid, String> {
    @Override
    public boolean isValid(String value, ConstraintValidatorContext context) {
        return RegexUtils.isUpper(value);
    }
}

 

 


Implementation (for project)

위에서 구현한 common 패키지를 상속받아서, 구현하였습니다.

또한 유효성 체크를 위한 열거형 (enum) 같은 값이 있는 경우, 별도로 구현하였습니다.

프로젝트마다 “Validation Check”는 상이하기 때문에, 이 부분은 샘플로서 참고해주시길 바랍니다.

 


validator (패키지명)

@EmailValid

프로젝트에서 사용할 이메일 유효성 체크 Annotation 입니다.
acppectedRegexList는 정규식을 가지고 있어
이메일 중에서 gmail.com와 naver.com만 허용하도록 하였습니다.

 

EmailValid.java

@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
@NotBlank(message = "email은 빈 공간을 가질 수 없습니다.")
@NoSpecialValid(message = "email에 특수문자가 들어갈 수 없습니다.")
@Constraint(validatedBy = EmailValidator.class)
public @interface EmailValid {
    String[] acceptedRegexList() default {
        "\\w+@gmail.com",
        "\\w+@naver.com"
    };

    String message() default "허용되지 않는 이메일 주소입니다";

    Class[] groups() default {};

    Class[] payload() default {};
}

 

EmailValidator.java

public class EmailValidator implements ConstraintValidator<EmailValid, String> {
    private List<String> acceptedRegexList;

    @Override
    public void initialize(EmailValid constraintAnnotation) {
        ConstraintValidator.super.initialize(constraintAnnotation);
        this.acceptedRegexList = new ArrayList<>();
        this.acceptedRegexList.addAll(List.of(constraintAnnotation.acceptedRegexList()));
    }

    @Override
    public boolean isValid(String value, ConstraintValidatorContext context) {
        for(String format : this.acceptedRegexList){
            if(value.matches(format))
                return true;
        }
        return false;
    }
}

 


@LoginIdValid

로그인 아이디에 대한 유효성 검사 Annotation입니다.
이미 공통에서 구현한 Annotation과 Hibernate Validation을 상속받아서 사용하기 때문에
구현부는 별도로 존재하지 않습니다.

 

LoginIdValid.java

이미 구현된 common 내부의 OnlyNumbericWithAlphabetValid를 사용하고 있기 때문에, 별도의 구현부는 존재하지 않습니다.

@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
@Length(min = 8, max = 15, message = "loginId는 8자 이상, 15자 이하로 가능합니다.")
@NotBlank(message = "loginId는 빈 공간을 가질 수 없습니다.")
@OnlyNumericWithAlphabetValid(message = "loginId는 숫자와 영어로만 이루어집니다.")
@Constraint(validatedBy = {})
public @interface LoginIdValid {
    String message() default "";
    Class[] groups() default {};
    Class[] payload() default {};
}

 


@MovieCategoryValid

이 Annotation은 MovieCategory라는 Enum의 Validation Check를 위해서 사용합니다.
클라이언트에서 입력받은 String 문자열이, 미리 정의 된 Enum에 존재하는 경우 허용하며, 없는 경우 허용하지 않습니다.

 

MovieCategoryValid.java

@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 {};
}

 

MovieCategoryValidator.java

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;
        }
    }
}

 

이제 계획한 모든 구현이 끝났습니다.

실제 Request DTO 파라미터에 “Validation Check”를 연결해봅시다. 📎

 

 


Usage

AccountAddRequest.java

message를 Annotation에서 정의를 했지만, DTO 단에서 Override가 가능합니다.

즉, 경우에 따라 한글로 잘 맞춰주어 경우마다 대응이 가능합니다. 😄

@Data
public class AccountAddRequest {
    @LoginIdValid(message = "loginId이 사용 불가능한 값입니다")
    private String loginId;

    @EmailValid(message = "email이 사용 불가능한 값입니다")
    private String email;

    @OnlyKoreanValid(message = "name은 한글만 입력이 가능합니다")
    private String name;

    @IsDateValid(message = "birthDate는 날짜 형식만 가능합니다 (yyyy.mm.dd)")
    private String birthDate;

    @OnlyNumericValid(message = "age는 숫자만 입력이 가능합니다")
    private String age;

    @IsUrlPathValid(message = "profileUrl은 URL 형식만 가능합니다")
    private String profileUrl;

    @IsYnValid(message = "isEnable이 유효한 값이 아닙니다. (Y, N)")
    private String isEnable;

    @IsIpValid(message = "clientIp는 IP 형식만 가능합니다")
    private String clientIp;

    @IsUuidValid(message = "reqId은 UUID 형식만 가능합니다")
    private String reqId;
}

 

 

출처: https://redcoder.tistory.com/321 [로재의 개발 일기:티스토리]



이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.
공유하기 링크
Comments