Spring Boot Validation 심화 (feat. ConstraintValidator)

최민길(Gale)·2023년 6월 19일
1

Spring Boot 적용기

목록 보기
25/46

안녕하세요 오늘은 ConstraintValidator를 이용한 Validation 방식에 대해 알아보겠습니다. 이번 벨리데이션 방식의 경우 어노테이션을 이용하여 검증이 필요한 값에 설정한 후 어노테이션이 참조하는 커스텀한 ConstraintValidator를 이용하여 로직을 검증하고 이를 @Valid 어노테이션을 통해 검증하는 방식으로 진행합니다.

ConstraintValidator에 대해 알아보기 위해 Validator 동작 순서에 대해 알아보겠습니다. 가장 먼저 @Valid를 이용하기 위해 LocalValidatorFactoryBean 클래스가 필요합니다. LocalValidatorFactoryBean 클래스는 Spring에서 데이터 유효성 검증을 위한 클래스이며 @Valid 어노테이션을 검증을 원하는 변수에 붙이는 방식으로 사용하면 Spring에서 해당 변수의 유효성을 검증하여 결과를 출력합니다.

아래 코드는 LocalValidatorFactoryBean 클래스 중 일부입니다. LocalValidatorFactoryBean의 경우 ValidationFactory를 부팅하여 Validator 및 ValidatorFactory 인터페이스를 이용하여 데이터를 바인딩합니다.

public class LocalValidatorFactoryBean extends SpringValidatorAdapter
		implements ValidatorFactory, ApplicationContextAware, InitializingBean, DisposableBean {
        ...
        
	...
	/**
	 * Specify a custom ConstraintValidatorFactory to use for this ValidatorFactory.
	 * <p>Default is a {@link SpringConstraintValidatorFactory}, delegating to the
	 * containing ApplicationContext for creating autowired ConstraintValidator instances.
	 */
	public void setConstraintValidatorFactory(ConstraintValidatorFactory constraintValidatorFactory) {
		this.constraintValidatorFactory = constraintValidatorFactory;
	}
    ...
    
    ...
    		ConstraintValidatorFactory targetConstraintValidatorFactory = this.constraintValidatorFactory;
		if (targetConstraintValidatorFactory == null && this.applicationContext != null) {
			targetConstraintValidatorFactory =
					new SpringConstraintValidatorFactory(this.applicationContext.getAutowireCapableBeanFactory());
		}
		if (targetConstraintValidatorFactory != null) {
			configuration.constraintValidatorFactory(targetConstraintValidatorFactory);
		}
        ...

LocalValidatorFactoryBean 클래스를 사용하기 위해 수동으로 빈을 주입해줍니다. 만약 이 과정이 생략된다면 ConstraintValidatorFactory가 초기화되지 않아 검증을 진행할 targetValidator가 없어 오류가 발생합니다.

@Configuration
public class ValidationConfig {
    @Bean
    public LocalValidatorFactoryBean validatorFactoryBean() {
        return new LocalValidatorFactoryBean();
    }
}

ConstraintValidatorFactory 클래스의 경우 팩토리 메서드 패턴으로 ConstraintValidator를 생성하는 객체로 팩토리 메서드 패턴의 특성상 하위 클래스와의 결합도가 낮아 자유롭게 커스텀하여 적용할 수 있습니다. 이렇게 커스텀한 ConstraintValidator 클래스를 생성한 어노테이션과 연결한 후 검증을 원하는 변수에 생성한 어노테이션을 추가합니다.

아래 예시는 이메일을 검증하는 코드입니다. 여기서 주의할 점은 String.matches 대신 Pattern.matcher를 이용하는 것입니다. String.matches의 경우 내부에서 생성되는 Pattern 인스턴스가 한 번 쓰고 버려져 가비지 컬렉터의 대상이 되어 메모리 누수가 발생합니다. 따라서 Pattern 인스턴스를 직접 static으로 만들어 객체를 재사용할 수 있도록 하여 성능을 향상시킵니다.

@RequiredArgsConstructor
public class EmailValidator implements ConstraintValidator<ValidEmail,String> {
    private static final Pattern EMAIL = Pattern.compile("^[0-9a-zA-Z]([-_.]?[0-9a-zA-Z])*@[0-9a-zA-Z]([-_.]?[0-9a-zA-Z])*.[a-zA-Z]{2,3}$");

    @Override
    public void initialize(ValidEmail constraintAnnotation) {
        ConstraintValidator.super.initialize(constraintAnnotation);
    }

    @Override
    public boolean isValid(String value, ConstraintValidatorContext context) {
        return EMAIL.matcher(value).matches();
    }
}

아래는 새롭게 생성한 어노테이션입니다. @Constraint(validatedBy = EmailValidator.class)를 통해 현재 어노테이션이 붙어 있는 변수를 EmailValidator에서 처리하도록 합니다. 이 때 어노테이션 내부에 message, groups, payload 값은 LocalValidatorFactoryBean이 상속하는 SpringValidatorAdapter에서 검증하는 과정에서 필요한 데이터이기 때문에 비어있는 값으로라도 설정을 해주어야 합니다.

@Documented
@Constraint(validatedBy = EmailValidator.class)
@Target({ElementType.FIELD, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface ValidEmail {
    String message() default "Valid Email";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
}

이후 검증을 원하는 클래스 내부의 변수에 어노테이션을 추가합니다.

@ValidEmail
    public String email;

이후 컨트롤러의 리퀘스트로 넘어오는 클래스 앞에 @Valid 어노테이션을 추가하면 빈을 주입받은 LocalValidatorFactoryBean이 @Valid 어노테이션을 통해 클래스 내부의 어노테이션을 체크합니다. 생성된 어노테이션에서는 생성한 커스텀 ConstraintValidator에서 로직을 처리해 결과를 반환하면 통과 여부에 따라 BindingResult에 결과가 저장되어 이 결과값을 토대로 검증을 진행할 수 있습니다.

    public HashMap<String, Object> validateEmail(
            @RequestBody @Valid ValidateEmailDTO validateEmailDTO,
            BindingResult bindingResult)
    {
        if(bindingResult.hasErrors()) throw new ValidationException(404,"잘못된 요청값입니다.");
profile
저는 상황에 맞는 최적의 솔루션을 깊고 정확한 개념의 이해를 통한 다양한 방식으로 해결해오면서 지난 3년 동안 신규 서비스를 20만 회원 서비스로 성장시킨 Software Developer 최민길입니다.

0개의 댓글