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 실행 시점
- Validator 선택과 초기화
- Spring에서는 보통
LocalValidatorFactoryBean
을 사용해 Bean Validation 구현체(예: Hibernate Validator)를 감싸서 Validator로 등록한다. 이 빈은 애플리케이션 시작 시점에 엔티티 클래스의 제약 애노테이션 정보를 읽어, 내부 메타데이터를 생성하고 캐싱해둔다. - 각 제약 애노테이션(
@NotNull
,@Size
등)은 관련된ConstraintValidator
클래스를 갖고 있다. 이 클래스들은 애노테이션에 정의된 제약 조건을 실제로 검사하는 로직을 구현하며,initialize()
메서드를 통해 애노테이션에 설정된 옵션들을 읽어 초기화된다.
- Spring에서는 보통
- 메타데이터 구축
- 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로 등록하는 것을 확인할 수 있다.
ConstraintValidator
의 Initialize()
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에서 값 가져오기: 제약 조건 애너테이션에서 속성 값을 가져와 초기화하는 경우
- 기본적으로 아무 동작도 하지 않는 no-op이다.
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 기반 검증 트리거
- 컨트롤러 메서드 매핑
- 클라이언트 요청이 들어오면, Spring의
DispatcherServlet
이 해당 요청을 해당 컨트롤러 메서드에 매핑한다.
- 클라이언트 요청이 들어오면, Spring의
- 요청 데이터 바인딩:
- 클라이언트로부터 들어온 HTTP 요청의 바디(JSON 등)은 HttpMessageConverter를 통해 Java 객체(예: 엔티티 객체)로 변환된다. 이 시점에서 객체는 이미 생성되었지만, 아직 검증되지 않은 상태이다.
- 메서드의 파라미터에
@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로 변환합니다.
정리하면
- Spring은 MethodValidationPostProcessor를 BeanPostProcessor로 등록
- afterPropertiesSet()이 호출되면서, @Validated 애너테이션이 붙은 클래스를 포인트컷으로 지정하고, MethodValidationInterceptor를 Advice로 설정한다.
- 메서드가 호출되면 AOP가 작동하여 MethodValidationInterceptor가 먼저 호출된다.
- MethodValidationInterceptor가 Validator를 사용해 메서드 파라미터와 반환값을 검증한다.
- 검증에 실패하면 ConstraintViolationException 또는 MethodValidationException을 발생시킨다.
Validator 호출 - 검증 트리거
위에서 정리한 3, 4, 5번 과정이다.
- Validator(
ConstraintValidator
) 호출ConstraintValidator
호출: Validator는 validate(Object target, Class<?>... groups) 메서드를 통해 대상 객체의 각 필드 값을 가져오고, 해당 값과 메타데이터를 바탕으로 각 제약 조건에 대해 대응하는ConstraintValidator
의isValid()
메서드를 호출한다.- 검증 결과 수집: 각
ConstraintValidator
의isValid()
결과는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;
}
}
- 검증 결과 처리
- 검증 결과, 하나 이상의 제약 조건에 위반되는 경우가 있으면,
ConstraintViolationException
혹은MethodArgumentNotValidException
등의 예외를 발생시켜 컨트롤러 메서드를 중단시킨다.- 만약 메서드 파라미터에
BindingResult
나Errors
를 함께 선언한 경우, 예외를 발생시키지 않고 검증 결과를 해당 객체에 담아 개발자가 직접 처리할 수 있도록 해준다.
- 만약 메서드 파라미터에
- 예외 핸들링: 발생한 검증 예외는 글로벌 예외 처리기(
@ControllerAdvice
의@ExceptionHandler
) 혹은 개별 컨트롤러에서 처리되어, 클라이언트에게 어떤 필드가 어떤 이유로 유효하지 않은지 상세하게 피드백할 수 있도록 구성할 수 있다.
- 검증 결과, 하나 이상의 제약 조건에 위반되는 경우가 있으면,
정리하면
- LocalValidatorFactoryBean:
- Bean Validation의 핵심 검증 로직(제약 애노테이션 메타데이터 구축 및 캐싱)을 담당하며, 전반적인 객체 검증(필드 검증 등)에 사용된다.
- ⠀MethodValidationPostProcessor:
- AOP를 이용하여 메서드 호출 전후에 파라미터 및 리턴 값 검증을 수행하도록 지원합니다.
- 내부적으로 LocalValidatorFactoryBean에서 제공하는 Validator를 사용하여 메서드 레벨의 검증을 실행한다.