-
[Spring] 유용한 Custom Validation을 구현해보자 (숫자, 특수문자, 영어, 한글, 날짜, url, ip, uuid, YN...)카테고리 없음 2022. 11. 16. 19:48
Jakarta Bean Validation 이전 글 까지, Spring에서 제공하는 “Bean Validation” 기능에 대해서 알아보고 Custom Validation을 구현하는 방법을 알아봤습니다.
이번 글에서는, 유용하게 사용할 수 있는 “Custom Validation”을 구현하여, 샘플을 정리 및 공유하겠습니다.
Github Link
해당 글을 작성하면서, 개발된 스프링 부트 기반의 프로젝트입니다.
https://github.com/rojae/Spring-Validation-Check-Sample/tree/v3
GitHub - rojae/Spring-Validation-Check-Sample
Contribute to rojae/Spring-Validation-Check-Sample development by creating an account on GitHub.
github.com
Package Tree
패키지 구성에 대한 내용입니다.
유효성 검증 기능의 시작은 “validator”라는 패키지를 시작으로 구성됩니다.
└── validator # validation 구현의 시작이며, 프로젝트 전용 파일들 └── common # 공통적으로 사용될 수 있는 모듈 ├── extension # 다른 패키지에서 사용하는 공통적인 확장 모듈 ├── format # 날짜, URL, IP와 같은 포맷 기반의 모듈 └── match # 특수문자, 영어, 한글과 같은 문자열 매칭 기반의 모듈 └── utils # 정규식 활용을 위한 유틸리티들
패키지는 위와 같은 기준으로 분리 구분하여, 실질적으로 common 내부의 모듈은 프로젝트에서 건들이지 않고
사용이 가능하게 됩니다.
아래는 샘플로 구현된 목록입니다.
(자세한 소스는 “아래 글”과 “Github Link”를 참고해주세요)
└── 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
- 확장 가능 (extension)
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 -> XIsUrlValid.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; }
ValidController.java
이제 AccountAddRequest를 요청 DTO로 가지는 Annotation Controller를 추가합니다.
@PostMapping("/account") public ResponseEntity<ApiBase> addAccount(@RequestBody @Valid AccountAddRequest request){ AccountAddResponse response = new AccountAddResponse(); response.setLoginId(request.getLoginId()); return ResponseEntity.status(HttpStatus.CREATED).body(ApiBase.builder().message("").response(response).build()); }
Test Run
curl --location --request POST 'http://localhost:8080/account' \ --header 'Content-Type: application/json' \ --data-raw '{ "loginId": "testid123", "email": "test@gmail.com", "name": "김철수", "birthDate": "2001.10.30", "age": "25", "profileUrl": "ns-profile.company.com/image/2f48f241-9d64-4d16-bf56-70b9d4e0e79a.jpg", "isEnable": "N", "clientIp": "203.133.203.103", "reqId": "2f48f241-9d64-4d16-bf56-70b9d4e0e79a" }'
localhost:8080에 서버를 띄우고 위처럼 요청을 날려보면, 오류가 발생합니다.
(profileUrl에 http, https와 같은 프로토콜이 빠졌기 때문입니다 😁)
결과는 아래와 같습니다.
{ "message": "profileUrl은 URL 형식만 가능합니다", "response": "" }
정상적으로 Exception Handle가 작동한 모습입니다.
혹시 Exception Handler를 구현하지 않았다면, 아래 코드를 확인하고 추가해보세요.
GlobalErrorHandler.java
@ControllerAdvice @Slf4j public class GlobalErrorHandler { @ExceptionHandler({MethodArgumentNotValidException.class}) public ResponseEntity<ApiBase> handleResponseBodyError(MethodArgumentNotValidException ex) { log.error("Exception Caught in handleRequestBodyError : {}", ex.getMessage()); var error = ex.getBindingResult().getAllErrors().stream() .map(DefaultMessageSourceResolvable::getDefaultMessage) .sorted() .collect(Collectors.joining(", ")); log.error("Error is : {}", error); return ResponseEntity.badRequest() .body(ApiBase.builder().message(error).response("").build()); } }
Next Step
이번 글에서는 "유용하게 사용할 수 있는 Custom Validation Annotation"을 구현했습니다.
다음 글은 열거형 Enum 형식이 아닌, 데이터베이스의 테이블에 저장되어 있는 컬럼 값을 참조하여, "Bean Validation Annotation"을 초기화가 시키고 이어서 "Validation Check"가 가능한지 검토하고 직접 구현하는 글을 작성하겠습니다. 🔆
(즉 유효성 체크의 기준이 DB 테이블이 가능한가에 대한 검토와 구현입니다)
긴 글 읽어주셔서 감사합니다. 😁
반응형 - 프로젝트 전용 (validator)