Spring

[Spring] Spring에서 Bean Validation이 동작하는 내부 원리

lumana 2025. 3. 11. 23:13

 

 

Spring에서 Bean Validation이 동작하는 내부 원리

#Spring/MVC


엔티티 클래스에 Bean Validation 애노테이션 사용

엔티티 클래스의 필드에 @NotNull, @Size, @Min, @Max 등과 같은 애노테이션을 붙이면, 해당 필드에 대한 제약 조건이 정의된다.


public class User {
    @NotNull(message = "이메일은 필수입니다.")
    @Email(message = "올바른 이메일 형식이 아닙니다.")
    private String email;

    @Size(min = 2, max = 20, message = "이름은 2자 이상 20자 이하여야 합니다.")
    private String name;
    
    // Getter, Setter 등 생략
}

애노테이션을 통해 선언된 제약 조건은 런타임에 Bean Validation Provider(대개 Hibernate Validator)를 통해 실제 검증 로직으로 연결된다.


Spring Controller에서 @Validated를 사용하는 경우 어떻게 동작할까?


컨트롤러 메서드의 파라미터에 @Validated 또는 @Valid를 붙이면, Spring은 해당 파라미터에 대해 자동으로 검증을 수행한다.


ex) @RequestBody 메시지 컨버터가 동작한 경우

@PostMapping("/users")
public ResponseEntity<?> createUser(@Validated @RequestBody User user) {
    // 만약 user 객체의 제약조건에 위배되는 값이 있다면,
    // Spring은 메서드 호출 전에 검증을 수행하고 예외를 발생시킴
    return ResponseEntity.ok("사용자 생성 성공");
}

이 경우, HTTP 바디로 전달된 JSON이 User 객체로 매핑될 때, 컨버터가 매핑을 완료한 후에 스프링이 User 객체에 선언된 제약 조건을 검사하게 된다.


검증 프로세스의 내부 동작 원리


Application 실행 시점

  1. Validator 선택과 초기화
    • Spring에서는 보통 LocalValidatorFactoryBean을 사용해 Bean Validation 구현체(예: Hibernate Validator)를 감싸서 Validator로 등록한다. 이 빈은 애플리케이션 시작 시점에 엔티티 클래스의 제약 애노테이션 정보를 읽어, 내부 메타데이터를 생성하고 캐싱해둔다.
    • 각 제약 애노테이션(@NotNull, @Size 등)은 관련된 ConstraintValidator 클래스를 갖고 있다. 이 클래스들은 애노테이션에 정의된 제약 조건을 실제로 검사하는 로직을 구현하며, initialize() 메서드를 통해 애노테이션에 설정된 옵션들을 읽어 초기화된다.
  2. 메타데이터 구축
    • Validator는 엔티티 클래스의 각 필드, 프로퍼티, 그리고 클래스 레벨의 애노테이션을 리플렉션을 통해 탐색한다. 이 과정에서 각 제약 조건과 관련된 메타데이터(예: 검증 그룹, 메시지, 제약 조건 옵션 등)를 수집한다.

public class LocalValidatorFactoryBean extends SpringValidatorAdapter
    implements ValidatorFactory, ApplicationContextAware, InitializingBean,
               DisposableBean {
    // 중략
    @Override
    @SuppressWarnings({"rawtypes", "unchecked"})
    public void afterPropertiesSet() {
        Configuration<?> configuration;

		// 1. Validation Provider 설정
        if (this.providerClass != null) {
            ProviderSpecificBootstrap bootstrap =
                Validation.byProvider(this.providerClass);
            if (this.validationProviderResolver != null) {
                bootstrap =
                    bootstrap.providerResolver(this.validationProviderResolver);
            }
            configuration = bootstrap.configure();
        } else {
            GenericBootstrap bootstrap = Validation.byDefaultProvider();
            if (this.validationProviderResolver != null) {
                bootstrap =
                    bootstrap.providerResolver(this.validationProviderResolver);
            }
            configuration = bootstrap.configure();
        }

        // Try Hibernate Validator 5.2's externalClassLoader(ClassLoader) method
		// 하이버네이트
        if (this.applicationContext != null) {
            try {
                Method eclMethod = configuration.getClass().getMethod(
                    "externalClassLoader", ClassLoader.class);
                ReflectionUtils.invokeMethod(eclMethod, configuration,
                    this.applicationContext.getClassLoader());
            } catch (NoSuchMethodException ex) {
                // Ignore - no Hibernate Validator 5.2+ or similar provider
            }
        }

		// 2. MessageInterpolator(메시지 보간) 설정
		// 제약 조건 검증이 실패한 경우 오류 메시지를 생성하는 로직을 담당
        MessageInterpolator targetInterpolator = this.messageInterpolator;
        if (targetInterpolator == null) {
            targetInterpolator = configuration.getDefaultMessageInterpolator();
        }
        configuration.messageInterpolator(
            new LocaleContextMessageInterpolator(targetInterpolator));

        if (this.traversableResolver != null) {
            configuration.traversableResolver(this.traversableResolver);
        }

		// 3. ConstranintValidatorFactory 설정
		// ConstraintValidatorFactory 는 팩토리 메서드 패턴으로 ConstraintValidator를 생성
        ConstraintValidatorFactory targetConstraintValidatorFactory =
            this.constraintValidatorFactory;
        if (targetConstraintValidatorFactory == null
            && this.applicationContext != null) {
            targetConstraintValidatorFactory =
                new SpringConstraintValidatorFactory(
                    this.applicationContext.getAutowireCapableBeanFactory());
        }
        if (targetConstraintValidatorFactory != null) {
            configuration.constraintValidatorFactory(
                targetConstraintValidatorFactory);
        }

        if (this.parameterNameDiscoverer != null) {
            configureParameterNameProvider(
                this.parameterNameDiscoverer, configuration);
        }

        List<InputStream> mappingStreams = null;
        if (this.mappingLocations != null) {
            mappingStreams = new ArrayList<>(this.mappingLocations.length);
            for (Resource location : this.mappingLocations) {
                try {
                    InputStream stream = location.getInputStream();
                    mappingStreams.add(stream);
                    configuration.addMapping(stream);
                } catch (IOException ex) {
                    closeMappingStreams(mappingStreams);
                    throw new IllegalStateException(
                        "Cannot read mapping resource: " + location);
                }
            }
        }

        this.validationPropertyMap.forEach(configuration::addProperty);

        // Allow for custom post-processing before we actually build the
        // ValidatorFactory.
		// 사용자 정의 설정 처리
        if (this.configurationInitializer != null) {
            this.configurationInitializer.accept(configuration);
        }
        postProcessConfiguration(configuration);

        try {
			// 6. ValidatorFactory 생성 및 등록
            this.validatorFactory = configuration.buildValidatorFactory();
            setTargetValidator(this.validatorFactory.getValidator());
        } finally {
            closeMappingStreams(mappingStreams);
        }
    }
  • Validation Provider 선택
    • providerClass가 설정되어 있다면 해당 Provider를 사용하고, 없으면 기본 Provider(Validation.byDefaultProvider())를 사용한다.
  • MessageInterpolator 설정
    • 커스텀 MessageInterpolator가 지정되어 있으면 사용하고, 그렇지 않으면 기본값을 사용한다.
  • ConstraintValidatorFactory 설정
    • constraintValidatorFactory가 지정되어 있지 않다면, ApplicationContext로부터 자동 주입을 지원하는 SpringConstraintValidatorFactory를 사용한다.
  • ValidatorFactory 생성 및 Spring Validator로 등록
    • configuration.buildValidatorFactory()로 ValidatorFactory를 생성하고, setTargetValidator()를 통해 SpringValidatorAdapter에 등록한다.

마지막에 이 부분을 자세히 살펴보자.

		try {
			// 6. ValidatorFactory 생성 및 등록
            this.validatorFactory = configuration.buildValidatorFactory();
            setTargetValidator(this.validatorFactory.getValidator());
        } finally {
            closeMappingStreams(mappingStreams);
        }

public class SpringValidatorAdapter
    implements SmartValidator, jakarta.validation.Validator {
    private static final Set<String> internalAnnotationAttributes =
        Set.of("message", "groups", "payload");

    @Nullable private jakarta.validation.Validator targetValidator;

    /**
     * Create a new SpringValidatorAdapter for the given JSR-303 Validator.
     * @param targetValidator the JSR-303 Validator to wrap
     */
    public SpringValidatorAdapter(
        jakarta.validation.Validator targetValidator) {
        Assert.notNull(targetValidator, "Target Validator must not be null");
        this.targetValidator = targetValidator;
    }

    SpringValidatorAdapter() {}

    void setTargetValidator(jakarta.validation.Validator targetValidator) {
        this.targetValidator = targetValidator;
    }


setTargetValidator()에서 Bean Validation 구현체(예: Hibernate Validator)를 감싸서 Validator로 등록하는 것을 확인할 수 있다.


ConstraintValidatorInitialize()

public interface ConstraintValidator<A extends Annotation, T> {

	default void initialize(A constraintAnnotation) { }
  • initialize() 메서드는 검증기(Validator)를 초기화하는 역할을 한다.
    • 이 메서드는 Bean Validation이 ConstraintValidator 인스턴스를 사용하기 전에 한 번 호출된다.
    • 제약 조건(annotation)에 정의된 속성 값을 초기화하거나, 필요한 리소스를 준비하는 데 사용된다.
  • 매개변수 (constraintAnnotation):
    • 제약 조건(annotation) 인스턴스가 전달된다.
    • 이를 통해 사용자 정의 annotation에 설정된 속성 값을 가져올 수 있다.
  • 디폴트 구현:
    • 기본적으로 아무 동작도 하지 않는 no-op이다.
      • 제약 조건 애너테이션에서 특별히 값을 가져올 필요가 없고, 단순한 검증만 할 경우에는 생략해도 됩니다.
    • 필요한 경우에만 오버라이드해서 쓴다.
      • ex) Annotation에서 값 가져오기: 제약 조건 애너테이션에서 속성 값을 가져와 초기화하는 경우
public class LengthValidator implements ConstraintValidator<Length, String> {

    private int min;
    private int max;

    @Override
    public void initialize(Length constraintAnnotation) {
        this.min = constraintAnnotation.min();
        this.max = constraintAnnotation.max();
    }
}

매핑과 AOP 기반 검증 트리거

  1. 컨트롤러 메서드 매핑
    • 클라이언트 요청이 들어오면, Spring의 DispatcherServlet이 해당 요청을 해당 컨트롤러 메서드에 매핑한다.
  2. 요청 데이터 바인딩:
    • 클라이언트로부터 들어온 HTTP 요청의 바디(JSON 등)은 HttpMessageConverter를 통해 Java 객체(예: 엔티티 객체)로 변환된다. 이 시점에서 객체는 이미 생성되었지만, 아직 검증되지 않은 상태이다.
  3. 메서드의 파라미터에 @Validated 또는 @Valid가 있다면, Spring의 AOP 기반 처리 혹은 MethodValidationPostProcessor가 해당 객체에 대해 검증을 시작한다. 즉, 메서드 호출 전에 Validator가 호출된다.

@SuppressWarnings("serial")
public class MethodValidationPostProcessor
    extends AbstractBeanFactoryAwareAdvisingPostProcessor
    implements InitializingBean {
    private Class<? extends Annotation> validatedAnnotationType =
        Validated.class;

    private Supplier<Validator> validator = SingletonSupplier.of(
        () -> Validation.buildDefaultValidatorFactory().getValidator());

    private boolean adaptConstraintViolations;

	// 중략
	// ...

	// AOP 설정
    @Override
    public void afterPropertiesSet() {
        Pointcut pointcut =
            new AnnotationMatchingPointcut(this.validatedAnnotationType, true);
        this.advisor = new DefaultPointcutAdvisor(
            pointcut, createMethodValidationAdvice(this.validator));
    }

	protected Advice createMethodValidationAdvice(Supplier<Validator> validator) {
	    return new MethodValidationInterceptor(validator, 												this.adaptConstraintViolations);
	}


afterPropertiesSet() - AOP 설정

AnnotationMatchingPointcut

  • @Validated 애너테이션이 붙은 클래스를 찾는 포인트컷을 생성합니다.
  • this.validatedAnnotationType은 기본적으로 @Validated를 의미합니다.
  • 즉, @Validated가 적용된 클래스의 메서드만 AOP로 감싸겠다는 뜻입니다.

DefaultPointcutAdvisor

  • 위에서 만든 포인트컷과 Advice를 결합해 AOP Advisor로 등록합니다.
  • 이로써, @Validated가 붙은 클래스의 메서드가 호출되기 전에 Advice가 실행됩니다.

MethodValidationInterceptor

  • 실질적으로 메서드 호출 전에 Bean Validation을 수행하는 인터셉터입니다.
  • validator를 통해 검증기를 주입받고, adaptConstraintViolations가 true일 경우 ConstraintViolation을 MethodValidationResult로 변환합니다.

정리하면

  1. Spring은 MethodValidationPostProcessor를 BeanPostProcessor로 등록
  2. afterPropertiesSet()이 호출되면서, @Validated 애너테이션이 붙은 클래스를 포인트컷으로 지정하고, MethodValidationInterceptor를 Advice로 설정한다.
  3. 메서드가 호출되면 AOP가 작동하여 MethodValidationInterceptor가 먼저 호출된다.
  4. MethodValidationInterceptor가 Validator를 사용해 메서드 파라미터와 반환값을 검증한다.
  5. 검증에 실패하면 ConstraintViolationException 또는 MethodValidationException을 발생시킨다.


Validator 호출 - 검증 트리거

위에서 정리한 3, 4, 5번 과정이다.


  1. Validator(ConstraintValidator) 호출
    • ConstraintValidator 호출: Validator는 validate(Object target, Class<?>... groups) 메서드를 통해 대상 객체의 각 필드 값을 가져오고, 해당 값과 메타데이터를 바탕으로 각 제약 조건에 대해 대응하는 ConstraintValidatorisValid() 메서드를 호출한다.
    • 검증 결과 수집: 각 ConstraintValidatorisValid() 결과는 true 또는 false로 반환되며, false인 경우 해당 제약 조건 위반 정보를 담은 ConstraintViolation 객체가 생성되어 집합(Set)에 추가된다.
    • 컨트롤러에서 요청이 들어오고, 검증이 트리거될 때는 이미 캐싱된 메타데이터와 초기화된 ConstraintValidator를 사용하여 빠르게 검증 로직이 수행된다. 따라서 실제 컨트롤러 메서드 호출 시점에는 초기화 과정이 다시 발생하지 않고, 준비된 검증 로직이 실행되는 것이다.

public class LengthValidator implements ConstraintValidator<Length, String> {

    private int min;
    private int max;

	// initialize() 생략

    @Override
    public boolean isValid(String value, ConstraintValidatorContext context) {
        if (value == null) return false;
        return value.length() >= min && value.length() <= max;
    }
}
  1. 검증 결과 처리
    • 검증 결과, 하나 이상의 제약 조건에 위반되는 경우가 있으면, ConstraintViolationException 혹은 MethodArgumentNotValidException 등의 예외를 발생시켜 컨트롤러 메서드를 중단시킨다.
      • 만약 메서드 파라미터에 BindingResultErrors를 함께 선언한 경우, 예외를 발생시키지 않고 검증 결과를 해당 객체에 담아 개발자가 직접 처리할 수 있도록 해준다.
    • 예외 핸들링: 발생한 검증 예외는 글로벌 예외 처리기(@ControllerAdvice@ExceptionHandler) 혹은 개별 컨트롤러에서 처리되어, 클라이언트에게 어떤 필드가 어떤 이유로 유효하지 않은지 상세하게 피드백할 수 있도록 구성할 수 있다.

정리하면

  • LocalValidatorFactoryBean:
    • Bean Validation의 핵심 검증 로직(제약 애노테이션 메타데이터 구축 및 캐싱)을 담당하며, 전반적인 객체 검증(필드 검증 등)에 사용된다.
  • MethodValidationPostProcessor:
    • AOP를 이용하여 메서드 호출 전후에 파라미터 및 리턴 값 검증을 수행하도록 지원합니다.
    • 내부적으로 LocalValidatorFactoryBean에서 제공하는 Validator를 사용하여 메서드 레벨의 검증을 실행한다.