[Spring] Bean Validation 1.x(Hibernate Validator 4, 5)에서 Custom Validator로 list element validation하기

lsjbh45·2022년 10월 6일
0

Bean Validation 1.x와 list element validation

Spring에서는 기본적으로 Bean Validation이라는 specification으로 field의 유효성을 annotation 기반으로 검증한다. 하지만 우리 팀에서 관리하던 레거시 체계들에는 client side에서 request로 들어오는 각종 data를 server side에서 검증하는 공통적인 convention이 갖춰져 있지 않았다. 개발 중이던 웹 어플리케이션에서는 request service 단에 흩어져 있는 검증 로직을 통합해 효율적으로 validation을 구현하고, 발생하는 exception들을 효과적으로 처리하기 위해서 Bean Validation을 적용해 보고자 했다.

Bean Validation의 구현체로 많이 사용하는 Hibernate Validator 6.2.0 Final 버전을 dependency로 추가했더니 실행 시에 오류가 발생했다. 문제는 프레임워크 Spring 4를 사용하고 있다는 것이었는데, 공식 문서에 따르면 Spring Framework 4.0은 Bean Validation 1.0(JSR-303)과 Bean Validation 1.1(JSR-349)를 지원한다. Hibernate Validator 6.0은 Bean Validation 2.0에 대한 구현이기 때문에 Spring 4에서 사용할 수 없었다.

6.2.0 Final 버전을 사용할 수 없었기 때문에 인트라넷에서 하위 버전 library를 찾다가 Bean Validation 1.0을 구현한 Hibernate Validator 4.3.1을 구해서 dependency로 추가해서 사용하게 되었다. 하지만 Bean Validation 1.0에서는 2.0의 중요한 기능 중 하나인 container element constraints를 사용할 수 없다는 문제가 있었는데, 공식 문서를 보면 해당 기능이 2.0 버전에서 새로 생긴 부분임을 확인할 수 있다. 따라서 type argument로 사용된 class에 대해서는 validation이 불가능했는데, List<@Min(1) Integer> positiveNumbers와 같이 List를 구성하는 각 element에 대해서 검증하는 것도 당연히 불가능했다. field 자체에 대해서만 validation을 지정할 수 있었기 때문에 custom validator를 구현해 collection field 내부의 element들을 검증하는 방식을 사용해야 했다.

Custom Validator를 구현해 list element validation하기

custom validator를 구현하기 위해서는 1. field에 명시해서 validation을 적용하는 annotation interface, 2. 실제로 validity를 판단하는 ConstraintValidator의 구현체를 정의해 주어야 한다. annotation interface에는 @Constraint annotation의 validatedBy 인자에 validator 구현체 class를, validator 구현체에는 generic interface의 타입 변수에 annotation interface를 각각 명시해서 두 객체를 연결한다.

@Target({ElementType.METHOD, ElementType.FIELD, ElementType.ANNOTATION_TYPE, ElementType.CONSTRUCTOR, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Constraint(validatedBy=ListEachMinValidator.class)
public @interface ListEachMin {
	String message() default "";

	Class<?> groups() default {};

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

	long value();
}

public class ListEachMinValidator implements ConstraintValidator<ListEachMin, List<Integer>> {
	private long value;

	@Override
	public void initialize(ListEachMin listEachMin) {
		this.value = listEachMin.value();
	}

	@Override
	public boolean isValid(List<Integer> l, ConstraintValidatorContext constraintValidatorContext) {
		if(l == null) { return true; }

		boolean isValid = true;

		for(Integer i : l) {
			if(i == null) { continue; }

			if(i < value) {
				constraintValidatorContext
					.buildConstraintViolationWithTemplate(
						format("{javax.validation.constraints.Min.message} (In List): %d", i)
					)
					.addConstraintViolation()
					.disableDefaultConstraintViolation();

				isValid = false;
			}
		}

		if(!isValid) {
			constraintValidatorContext.disableDefaultConstraintViolation();
		}

		return isValid;
	}
}

List의 각 element들이 not null인지를 검증하는 ListEachNotNull, regex로 List 내의 사용자 아이디들이 모두 유효한 형태인지를 검증하는 ListEachUserId 등의 custom validator들을 정의해 사용했지만, 여기에서는 List의 각 element들에 대해 Min constraint를 검증하는 ListEachMin validator를 대표로 서술하고자 한다. @ListEachMin(1) List<Integer> positiveNumbers는 Bean Validation 2.0 이상에서 List<@Min(1)> positiveNumbers와 같이 validation을 지정하는 것과 동일한 검증을 수행한다.

  • annotation interface의 기본 field들인 message, groups, payload 이외에도 annotation의 인자로 value 값을 지정할 수 있도록 정의했다. 여기에서는 검증하고자 하는 최소값을 의미하게 된다. validator 구현체에서는 initialize method에서 이 값을 가져와서 검증에 사용한다.
  • annotation interface의 message field에는 ConstraintViolationException이 발생했을 때 표기할 default message를 지정할 수 있다. 여기에서는 validator 구현체에서 직접 message를 지정하도록 했기 때문에 따로 default message를 지정하지는 않았다.
  • client side에서 request로 들어온 list field 자체가 null이라면 server side에서 list field에 해당하는 정보를 갱신하지 않는 것으로 API 명세를 합의했기 때문에 list 자체가 null인 경우에도 isValid method가 true를 반환하도록 정의했다.
  • list field의 각 element들을 iteration하면서 각각의 validity를 판단한다. 만약 유효하지 않은 element value라면 전체의 validity를 false로 설정하고, constraintValidatorContext에 constraint violation을 build해 추가해 준다. 이때 원래 Min constraint에 사용하는 javax.validation.constraints.Min.message로 메시지를 설정했다.
@Target({ElementType.METHOD, ElementType.FIELD, ElementType.ANNOTATION_TYPE, ElementType.CONSTRUCTOR, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Constraint(validatedBy=NullOrNotBlankValidator.class)
public @interface NullOrNotBlank {
	String message() default "must be null or not blank";

	Class<?> groups() default {};

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

public class NullOrNotBlankValidator implements ConstraintValidator<NullOrNotBlank, String> {
	private long value;

	@Override
	public void initialize(NullOrNotBlank nullOrNotBlank) {

	}

	@Override
	public boolean isValid(String s, ConstraintValidatorContext constraintValidatorContext) {
		if(s == null) { return true; }

		return !Pattern.matches("^\\s*$", s);
	}
}

마찬가지로 list의 각 element들이 null이거나, null이 아니라면 blank가 아니어야 한다는 복잡한 조건을 custom validator로 구현할 수도 있다.

public static class Test {
	@ListEachNotNull
	@ListEachMin(1)
	private List<Integer> testIds;
}
{
  "timestamp": "2022-06-27T09:38:27.204",
  "status": 400,
  "code": "C001",
  "message": "잘못된 입력값입니다.",
  "errors": [
    {
      "field": "testIds",
      "value": "[3, -1, -2, null]",
      "reason": "must be greater than or equal to 1 (In List): -1"
    },
    {
      "field": "testIds",
      "value": "[3, -1, -2, null]",
      "reason": "must be greater than or equal to 1 (In List): -2"
    },
    {
      "field": "testIds",
      "value": "[3, -1, -2, null]",
      "reason": "may not be null (In List)"
    }
  ]
}

이렇게 정의한 custom validator들은 기본으로 제공되는 validator들과 마찬가지로 여러 annotation을 함께 명시하는 방식으로 다중 validation이 가능하다. 위 예시와 같이 @ListEachNotNull@ListEachMin(1) annotation을 함께 명시해서 list의 각 element들이 null도 아니어야 하고, 자연수여야 한다는 복합적인 유효성을 확인할 수도 있다. 이렇게 유효성을 확인했을 때 list element들 중 여러 개가 invalid한 값을 가진다면, 오류가 발생하는 각 element에 대해 error를 발생시키도록 validator를 정의한다면 한 번에 오류들을 명료하게 확인할 수 있다.

profile
개발을 공부하며 깊게 고민했던 트러블슈팅 과정을 공유하고자 합니다.

0개의 댓글