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개의 댓글

관련 채용 정보