Validation 검증 순서 정하기

kms·2024년 1월 13일
0

Bean 유효성 검사는 Java 생태계에서 유효성 검사 논리를 구현하기 위한 사실상의 표준이다.

Jakarta Bean Validation 은 단순한 명세이기 때문에 이를 구현한 구현체가 필요한데 대표적인 것이 바로 Hibernate Validator 이다. 실제로 Jakarta Bean Validation 만 의존성 추가하면 에러가 난다. 사실상 Hibernate Validator 를 쓴다고 보면 된다.

직접 오브젝트의 유효성을 검증하는 것도 좋지만, Hibernate Validation 을 이용하면 Object의 유효성을 쉽게 검증할 수 있다.

많이 사용하는 Constraint 제약 조건(Built-in constraints)이다.

@NotNull: to say that a field must not be null.
@NotEmpty: to say that a list field must not empty.
@NotBlank: to say that a string field must not be the empty string (i.e. it must have at least one character).
@Pattern: to say that a string field is only valid when it matches a certain regular expression.
@Email: to say that a string field must be a valid email address.

그 외에도 @Min, @Max, @AssertTrue 등이 있다. 자세한 내용은 공식 홈페이지 를 참고할 것.

Custom Validator

hibernate 에서 제공하는 에노테이션 말고 필요하다면 커스텀한 Validator 를 만들 수 있다.
만드는 방법은 간단하다.

  1. 제약조건 어노테이션 만들기
  2. Validator 구현하기(implements)
  3. 기본 에러 메시지 정의하기

Custom Validator 를 활용하여 유저로 부터 패스워드 그리고 패스워드 확인 입력 값을 받아 두 값이 같은 지를 확인하는 경우를 예시로 들어본다.

최종 코드는 아래와 같다.

@Getter
@NoArgsConstructor
@PasswordMatchesCheck(groups = PasswordMatchesCheckGroup.class)
public class AddMemberRequest {

  @Email(message = "이메일 형식 필수")
  @NotEmpty
  private String email;

  @NotEmpty
  @Pattern(
      regexp = "^(?=.*[A-Za-z])(?=.*\\d)[A-Za-z\\d]{5,20}$",
      message = "영문자, 숫자를 포함하여 5자 이상 20자 이하",
      groups = PatternCheckGroup.class
  )
  private String password;

  @NotEmpty
  @Pattern(
      regexp = "^(?=.*[A-Za-z])(?=.*\\d)[A-Za-z\\d]{5,20}$",
      message = "영문자, 숫자를 포함하여 5자 이상 20자 이하",
      groups = PatternCheckGroup.class
  )
  private String passwordConfirm;

  @AssertTrue(message = "이메일 중복체크 필수")
  private boolean emailCheck;

  public AddMember toAddMember() {
    return AddMember.builder()
        .email(email)
        .password(password)
        .build();
  }
}

1. Anotation 만들기

@Target({ TYPE, ANNOTATION_TYPE })
@Retention(RUNTIME)
@Constraint(validatedBy = { PasswordMatchesValidator.class })
@Documented
public @interface PasswordMatchesCheck {

  String message() default "default error message"; 

  Class<?>[] groups() default { };

  Class<? extends Payload>[] payload() default { };
}

PasswordMatchesCheck 어노테이션의 경우 Class 레벨에 적용한다. Class 에 적용하는 이유는 우리가 검증하려고 하는 필드는 두 개(passwordpasswordConfirm)이기 때문이다.

참고로 Field 이외에도 Class, Property 등에도 Validation 을 적용할 수 있다. 다만 이 경우 어노테이션을 만들 때 맞는 @Target Value를 적용해야 한다.

@Target : 제약조건에 대해 지원되는 대상 요소 유형을 정의한다. 만약에 Field 에 적용한다면 FIELD 를 사용하면 된다.Class 래벨에서 사용한다면 TYPE 을 사용한다.

@Constraint : @PasswordSimilarCheck 어노테이션이 달린 요소의 유효성을 검사하는 데 사용할 Validator 를 지정합니다. 여러 데이터 유형에 제약 조건을 사용할 수 있는 경우 각 데이터 유형마다 하나씩 Validator 를 지정합니다.
(만드는 방법은 밑에 있다)

2. ConstraintValidator 인터페이스 구현(implements)

어노테이션을 정의한 후에는 @PasswordSimilarCheck 어노테이션을 적용한 요소의 Validation 을 검사할 수 있는 Custom Validator 를 만들어야 한다.

@Slf4j
public class PasswordMatchesValidator implements ConstraintValidator<PasswordMatchesCheck, AddMemberRequest> {

  public static final String MESSAGE_TEMPLATE = "패스워드가 같지 않습니다.";
  public static final String PASSWORD_NOT_EQUAL_FIELD = "passwordConfirm";

  @Override
  public void initialize(PasswordMatchesCheck constraintAnnotation) {}

  @Override
  public boolean isValid(AddMemberRequest value, ConstraintValidatorContext constraintValidatorContext) {
    if (value.getPassword() == null && value.getPasswordConfirm() == null) {
      return true;
    }

    boolean isValid = value.getPassword().equals(value.getPasswordConfirm());
    if (!isValid) {
      constraintValidatorContext.disableDefaultConstraintViolation();
      constraintValidatorContext
          .buildConstraintViolationWithTemplate(MESSAGE_TEMPLATE)
          .addPropertyNode(PASSWORD_NOT_EQUAL_FIELD).addConstraintViolation();
    }

    return isValid;
  }
}

ConstraintValidator 인터페이스를 구현하는 클래스는 두 가지 주요 메소드를 오버라이드해야 합니다:

public interface ConstraintValidator<A extends Annotation, T> {

	/**
	 * Initializes the validator in preparation for
	 * {@link #isValid(Object, ConstraintValidatorContext)} calls.
	 * The constraint annotation for a given constraint declaration
	 * is passed.
	 * <p>
	 * This method is guaranteed to be called before any use of this instance for
	 * validation.
	 * <p>
	 * The default implementation is a no-op.
	 *
	 * @param constraintAnnotation annotation instance for a given constraint declaration
	 */
	default void initialize(A constraintAnnotation) {
	}

	/**
	 * Implements the validation logic.
	 * The state of {@code value} must not be altered.
	 * <p>
	 * This method can be accessed concurrently, thread-safety must be ensured
	 * by the implementation.
	 *
	 * @param value object to validate
	 * @param context context in which the constraint is evaluated
	 *
	 * @return {@code false} if {@code value} does not pass the constraint
	 */
	boolean isValid(T value, ConstraintValidatorContext context);
}

initialize(A constraintAnnotation): 이 메소드는 검증기가 인스턴스화될 때 호출되며, 이를 통해 어노테이션의 인스턴스가 전달됩니다. 이 메소드에서는 주로 전달된 어노테이션을 분석하고, 검증 로직의 설정을 준비합니다.

isValid(T value, ConstraintValidatorContext context): 이 메소드는 실제 값이 유효한지 여부를 결정합니다. value는 검증할 객체이며, context를 사용하여 검증 과정에서 추가적인 정보를 제공할 수 있습니다. 이 메소드는 true나 false를 반환하여 객체가 유효한지 여부를 나타냅니다.

여기서는, @PasswordMatchesCheck 이라는 사용자 정의 어노테이션을 만들었고, ConstraintValidator<PasswordMatchesCheck, AddMemberRequest>과 같이 사용됩니다.

void initialize(...)boolean isValid(...) 보다 먼저 호출됩니다. 실질적인 검증이 이루어지는 isValid() 호출 전에 필요한 초기화 작업을 진행할 수 있습니다.

isValid() 는 검증 요소에 대한 실질적은 검증 작업이 이루어집니다.

@Override
  public boolean isValid(AddMemberRequest value, ConstraintValidatorContext constraintValidatorContext) {
    return value.getPassword().equals(value.getPasswordConfirm());
  }
}

AddMemberRequest 검증 객체(, 클래스 레벨에서 이루어졌기 때문에 객체를 받는다.) 와

주어진 Validator를 적용할 때 Context 데이터와 추가적인 작업(Operation) 을 할 수 있도록 하는 ConstraintValidatorContext 를 파라미터로 받는다.

@Override
  public boolean isValid(AddMemberRequest value, ConstraintValidatorContext constraintValidatorContext) {
    boolean isValid = value.getPassword().equals(value.getPasswordConfirm());

    if (!isValid) {
      constraintValidatorContext.disableDefaultConstraintViolation(); // 비활성화
      
      constraintValidatorContext // 다른 ConstraintViolation 추가
          .buildConstraintViolationWithTemplate("비밀번호와 비밀번호 확인이 같아야 합니다.")
          .addPropertyNode("passwordConfirm")
          .addConstraintViolation();
    }

    return isValid;
  }

ConstraintValidatorContext 를 이용해 제약조건에서 선언된 메시지를 사용할 때 사용되는 기본 ConstraintViolation 을 비활성화 하거나, 기본 이외에 추가적인 ConstraintViolation 을 추가할 수 있습니다.

참고

@NotNull, @NotEmpty, @Min, @AssertTrue 등 기본으로 정의된 어노테이션 경우 역시 ConstraintValidator 인터페이스를 구현되어 사용되고 있습니다.


/**
 * Validate that the object is not {@code null}.
 *
 * @author Emmanuel Bernard
 */
public class NotNullValidator implements ConstraintValidator<NotNull, Object> {

	@Override
	public boolean isValid(Object object, ConstraintValidatorContext constraintValidatorContext) {
		return object != null;
	}
}

3. 기본 에러 메시지 정의하기

이미 기본 에러 메시지는 제약 어노테이션을 만들 때 이미 정의했다. 하지만 위에서 처럼 Validator 에서 ConstraintViolation을 추가해 기본 에러 메시지를 사용하지 않고 새로운 메시지를 사용할 수 도 있다.

@Target({ TYPE, ANNOTATION_TYPE })
@Retention(RUNTIME)
@Constraint(validatedBy = { PasswordSimilarValidator.class })
@Documented
public @interface PasswordSimilarCheck {

  String message() default "default error message"; 

  Class<?>[] groups() default { };

  Class<? extends Payload>[] payload() default { };
}

ConstraintValidatorContext 의 자세한 사용방법은 문서를 참고바랍니다.

검증 순서 정하기

드디어 검증 순서를 정해보자.
위의 Validator 의 문제점은 순서에 상관없이 모든 유효성을 체크한다는 것이다.

POST http://localhost:8080/api/account/singup
Content-Type: application/json

{
  "email": "kmss6905@naver.com",
  "password": "1",
  "passwordConfirm": "12",
  "emailCheck": true
}

먼저 @NotEmpty 조건은 패스했지만 @PasswordMatchesCheck 와 @Pattern 제약 조건은 통과하지 못했기 때문에 아래와 같은 응답이 나오는 건 당연하다.

=> Reseponse
HTTP/1.1 400 
Content-Type: application/json
Transfer-Encoding: chunked
Date: Fri, 12 Jan 2024 13:09:39 GMT
Connection: close

{
  "status": "BAD_REQUEST",
  "instance": "/api/account/singup",
  "invalids": [
    {
      "name": "passwordConfirm",
      "message": "패스워드가 같지 않습니다."
    },
    {
      "name": "passwordConfirm",
      "message": "영문자, 숫자를 포함하여 5자 이상 20자 이하"
    },
    {
      "name": "password",
      "message": "영문자, 숫자를 포함하여 5자 이상 20자 이하"
    }
  ]
}

하지만, 의도한 건 검증의 순서가 있어서 @PasswordMatechs 가기 전에 @Pattern 까지만 Validation 이 적용되기를 원했다.

따라서 이번에는 @NotEmpty -> @Pattern -> @PasswordMatechs 순으로 적용하도록 개선합니다.
즉, 모든 항목에 대해서 각각 검증기가 작동되는 것이 아니라, 검증 순서가 있어 미리 해당 순서에 Pass 하지 못하면 그 다음 Validator까지 통과하지 않도록 한다.

아래의 응답이 목표다.

{
  "status": "BAD_REQUEST",
  "instance": "/api/account/singup",
  "invalids": [
    {
      "name": "passwordConfirm",
      "message": "영문자, 숫자를 포함하여 5자 이상 20자 이하"
    },
    {
      "name": "password",
      "message": "영문자, 숫자를 포함하여 5자 이상 20자 이하"
    }
  ]
}

제약조건 그룹핑(Grouping)

검증 순서를 컨트롤 하기 위해 그룹핑을 사용합니다.
말 그대로 제약 조건을 그룹별로 묶을 수 있도록 합니다. 그룹핑을 하면 아래와 같은 장점이 있습니다.

장점

  1. 그룹핑을 통해 원하는 제약조건을 그룹화 하여 관리할 수 있도록 한다.
  2. @GroupSequence 어노테이션을 이용하여 그룹화된 제약조건들을 순서대로 검증이 이루어질 수 있도록 한다.

먼저 제약조건에 Group 지정하기

먼저 Group 으로 지정할 제약조건을 선정합니다.
@NotEmpty -> @Pattern -> @PasswordMatechs 순서대로 작동되길 기대하기때문에 @NotEmpty, @Pattern, @PasswordMateches 별 그룹을 정합니다.

password 와 passwordConfim에 있는 @Pattern 제약조건에 대해 PatternCheckGroup.class 그룹으로 정의한다.
또 @PasswordMatchesCheck 제약 조건에 대해서도 PasswordMatchesCheckGroup.class 그룹을 정의합니다.

그룹은 단순하게 인터페이스로 정의하면 됩니다. 그리고 각 제약 조건에 Tagging 합니다. 커스텀한 제약조건 역시 이전에 groups() 메서드를 정의했기 때문에 가능합니다.

@Getter
@NoArgsConstructor
@PasswordMatchesCheck(groups = PasswordMatchesCheckGroup.class)
public class AddMemberRequest {
	
  @NotEmpty
  @Pattern(
      regexp = "^(?=.*[A-Za-z])(?=.*\\d)[A-Za-z\\d]{5,20}$",
      message = "영문자, 숫자를 포함하여 5자 이상 20자 이하",
      groups = PatternCheckGroup.class
  )
  private String password;

  @NotEmpty
  @Pattern(
      regexp = "^(?=.*[A-Za-z])(?=.*\\d)[A-Za-z\\d]{5,20}$",
      message = "영문자, 숫자를 포함하여 5자 이상 20자 이하",
      groups = PatternCheckGroup.class
  )
  private String passwordConfirm;
  
  // ...  중략

}
public class PasswordValidationGroups {
    public interface PasswordMatchesCheckGroup {};
    public interface PatternCheckGroup {};
}

@GroupSequence({ Default.class, PasswordValidationGroups.PatternCheckGroup.class, PasswordValidationGroups.PasswordMatchesCheckGroup.class })
public interface ValidationChecks {

}
@PostMapping("/api/account/singup")
  public ResponseEntity<AddMemberResponse> signUp(
      @Validated(ValidationChecks.class) @RequestBody AddMemberRequest addMemberRequest) {
    return ResponseEntity.ok(AddMemberResponse.of(addMemberService.addMember(addMemberRequest.toAddMember())));
  }

그룹에 대한 검증을 사용하기 위해서는 @Valid 가 아닌 @Validated 를 사용해야한다. 그리고 값으로는 아까 정의한 커스텀 그룹 시퀀스(ValidationChecks.class) 를 넣습니다.
그룹에 대한 제약조건을 사용하기 위해 @Valid 가 아닌 @Validated 를 사용합니다.

@Validated vs @Valid

결론적으로 기본 유효성 검사를 위해 메서드 호출에 JSR @Valid 주석을 사용합니다. 반면, 그룹 시퀀스를 포함한 모든 그룹 유효성 검사의 경우 메서드 호출에서 Spring의 @Validated 주석을 사용해야 합니다. 또한 중첩된 속성의 유효성 검사를 트리거하려면 @Valid 어노테이션도 필요합니다.

중첩된 속성의 유효성 검사 예시

class User{

	@NotEmpty
    String name;

	@Valid
    Phone phone
}

class Phone{
	@NotEmpty
	String phone;
}

참고


https://www.baeldung.com/spring-valid-vs-validated
https://docs.jboss.org/hibernate/stable/validator/reference/en-US/html_single/#section-default-group-class
https://docs.jboss.org/hibernate/stable/validator/reference/en-US/html_single/#section-builtin-constraints

0개의 댓글