Spring Boot Validation(2)

강서진·2023년 12월 7일
0
post-custom-banner

커스텀 유효성 검증

앞에서 본 내용은 개별적인 조건을 가지고 있었지만, 실제로는 단일한 필드에서 복합적인 유효성 검사를 해야 하는 경우가 많다.

복합적 유효성 검사
이름과 닉네임을 입력해야 한다고 하자.

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로 진행하고 있다보니 바뀐 걸지도 모르겠다.


실습: @YearMonthDay 애너테이션 만들기

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":[
    ]
  }
}

응답:

post-custom-banner

0개의 댓글