앞에서 본 내용은 개별적인 조건을 가지고 있었지만, 실제로는 단일한 필드에서 복합적인 유효성 검사를 해야 하는 경우가 많다.
복합적 유효성 검사
이름과 닉네임을 입력해야 한다고 하자.
private String name;
private String nickname;
둘 중 하나라도 있으면 통과라고 할 때, 이러한 애너테이션은 존재하지 않기 때문에 따로 만들어주어야 한다.
nameCheck라는 함수를 만든다.
@AssertTrue(message="name 이나 nickname이 하나라도 존재해야 합니다.")
public boolean isNameCheck(){
if (Objects.nonNull(name)&&!name.isBlank()){
return true;
}
if (Objects.nonNull(nickname)&&!nickname.isBlank()){
return true;
}
return false;
}
@AssertTrue:
특정 프로퍼티의 어떤 여부를 체크하는,is로 시작하는 이름의 boolean을 리턴하는 메서드에 꼭 붙여준다. 만약 이름이 is로 시작하지 않는 경우에는 제대로 동작하지 않는다.
반대로 @AssertFalse 애너테이션도 존재한다. 이 때는 false일 때 동작한다.
먼저 name이 (1) null 값이나 (2) 공백이 아닌지 확인하고, 두 조건을 충족하면 true를 반환한다. 만약 name이 이를 충족하지 못하면, nickname으로 넘어가 (1) null값인지 (2) 공백인지를 확인하고, 조건에 맞으면 true를 반환한다. 즉 둘 중 하나만이라도 조건을 충족하면 유효성 검사를 통과하는 것이다.
둘 다 조건을 충족하지 못하는 경우에는 false를 반환하여 예외가 발생한다.
추가로 애너테이션을 직접 만들 수도 있다. 예를 들어 정규표현식을 사용해 만든 유효성 검사를 여러 곳에서 사용해야 한다면, 이를 애너테이션으로 만들어 재사용성을 높일 수 있다.
예시로, 휴대폰 번호 유효성 검사를 애너테이션으로 만들 수 있다.
@Pattern(regexp = "^\\d{2,3}-\\d{3,4}-\\d{4}$")
@Target({ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
public @interface PhoneNumber {
String message() default "핸드폰 번호 양식에 맞지 않습니다. ex) 000-0000-0000";
String regexp() default "^\\d{2,3}-\\d{3,4}-\\d{4}$";
}
@Target
애너테이션을 붙일 대상을 지정한다. ElementType으로는 TYPE, CONSTRUCTOR, METHOD, FIELD 4가지가 올 수 있다.
ElementType.TYPE: 클래스, 인터페이스, enum에 붙일 수 있다.
ElementType.CONSTRUCTOR: 생성자에 붙일 수 있다.
ElementType.METHOD: 메서드에 붙일 수 있다.
ElementType.FIELD: 필드에 붙일 수 있다.
전화번호 필드에 붙일 것이라서 FIELD로 설정하였다.
배열로 한 번에 여러 대상을 지정해 줄 수 있다.
@Retention
애너테이션의 수명으로, 언제까지 살아서 역할을 할 지를 정한다. 속성으로 source, class, runtime 세 가지가 올 수 있다.
RetentionPolicy.SOURCE: 소스코드까지(=컴파일 시 x)
RetentionPolicy.CLASS: 클래스 파일까지(=바이트 코드)
RetentionPolicy.RUNTIME: 런타임까지 (=사라지지 않음)
패턴으로 사용할 정규표현식을 문자열로 정해주고, 만약 유효성 검사에 통과하지 못할 시 출력할 메시지도 만들어주었다.
그 다음에는 PhoneNumber 애너테이션으로 유효성 검사를 실행할 Validator 클래스를 만들어준다.
public class PhoneNumberValidator implements ConstraintValidator<PhoneNumber, String>
Validator를 만들 때는 ConstraintValidator 인터페이스를 사용한다. 전화번호는 String으로 받는다.
추상메서드를 재정의해준다.
public class PhoneNumberValidator implements ConstraintValidator<PhoneNumber, String> {
private String regexp;
@Override
public void initialize(PhoneNumber constraintAnnotation) {
this.regexp=constraintAnnotation.regexp();
}
@Override
public boolean isValid(String value, ConstraintValidatorContext context) {
boolean result = Pattern.matches(regexp,value);
return result;
}
}
initialize에서는 초기화할 때 애너테이션에서 설정해둔 정규표현식을 받아 저장한다.
isValid에서는 실제 유효성 검사를 실시한다. value로 전화번호 문자열을 받고, 정규표현식과 일치하는지 확인하여 true나 false를 반환한다.
Validator를 만든 후에는 애너테이션 파일에 Constraint를 추가해주어야 한다.
@Constraint(validatedBy = {PhoneNumberValidator.class})
PhoneNumberValidator로 검증을 진행하겠다고 연결해주는 역할을 한다.
이제 UserRegisterRequest 클래스에서 @Pattern을 사용했던 곳에 @PhoneNumber 애너테이션을 사용하여 서버를 시작해보면 애너테이션이 실제로 동작하는지 확인할 수 있다.
다만 이대로 하면 ConstraintDefinitionException이 발생한다. 오류 메시지를 보면 "~ contains Constraint annotation, but does not contain a groups parameter." 라고 Group 파라미터를 찾지 못하고 있다.
이럴 땐 다른 기존의 애너테이션에 들어가서 Groups와 payload를 복사해서 우리가 만든 애너테이션 파일에 추가해주면 된다.
Class<?>[] groups() default { };
Class<? extends Payload>[] payload() default { };
핸드폰 번호를 제대로 형식에 맞춰 입력해도 isValid가 계속 false가 나와서 고생했다. 이 오류는 Pattern.matches가 첫번째 인자로 정규표현식을, 두번째 인자로 문자열을 받는 데 강의에서는 그 반대로 작성되어서 그랬던 것으로 확인되었다...
Spring Initializer 가 java 17부터로 바뀌었기 때문에 강의에서와는 다르게 17로 진행하고 있다보니 바뀐 걸지도 모르겠다.
PhoneNumber 애너테이션과 PhoneNumberValidator를 참고하였다. 그런데 date는 LocalDate와 LocalDateTime이 있다보니 어떻게 포맷팅을 하는지 조금 막막해서...
처음에는 날짜를 파싱하여 유효성검사를 하고 그 형태로 저장된다고 착각하기도 했다.
어떻게 200 OK를 띄우기는 했지만, 조금 더 잘 만들 수 있지 않을까..
//<@YearMonthDay>
@Constraint(validatedBy = {YearMonthValidator.class})
@Target({ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
public @interface YearMonthDay {
String message() default "yyyyMMdd 형식에 맞지 않습니다.";
String pattern() default "yyyyMMdd";
Class<?>[] groups() default { };
Class<? extends Payload>[] payload() default { };
}
//<YearMonthDayValidator>
public class YearMonthDayValidator implements ConstraintValidator<YearMonthDay,String> {
private String pattern;
@Override
public void initialize(YearMonthDay constraintAnnotation) {
this.pattern = constraintAnnotation.pattern();
}
@Override
public boolean isValid(String value, ConstraintValidatorContext context) {
try{
LocalDate.parse(value, DateTimeFormatter.ofPattern(pattern));
return true;
} catch (Exception e){
return false;
}
}
}
파싱이 되면 true를 반환하고, 아니면 false를 반환한다.
이 애너테이션을 사용해 유효성 검증을 하는 registerDate 프로퍼티를 기존의 UserRegisterRequest 객체에 추가하였다.
@YearMonthDay
private String registerDate;
POST 요청으로 전달된 JSON:
{
"result_code":"",
"result_message":"",
"data":{
"name":"aaa",
"nickname":"",
"password":"12345678",
"age":24,
"email":"aaaa1@gmail.com",
"phone_number":"010-0111-0000",
"register_at":"2023-12-08T16:49:00",
"register_date":"20231208"
},
"error":{
"error_message":[
]
}
}
응답: