Spring 공식문서 개선까지 이끈 메서드 검증(Method Validation) 동작 원리 분석

 

Spring Boot 프로젝트에서 Controller 검증을 구현하던 중, Container Parameter(예: `List<@Valid ItemCreateDto>`)를 검증할 때 예상과 다른 예외가 발생하는 것을 발견했습니다. 일반적인 `@Valid @RequestBody` 검증에서는 `MethodArgumentNotValidException`이 발생하는데, Container Parameter에서는 `HandlerMethodValidationException`이 발생했습니다.

 

공식문서를 확인해봤지만 Container Parameter에 대한 명확한 설명을 찾을 수 없었고, "왜 다른 예외가 발생할까?"라는 궁금증이 생겼습니다. 이 의문을 해결하기 위해 Spring Framework의 소스코드를 직접 분석하기 시작했습니다.

 

분석 결과, HandlerMethod의 validateArguments 값과 methodValidator의 동작 방식을 이해하게 되었고, Container Parameter가 메서드 검증의 대상이 되는 이유를 명확히 파악할 수 있었습니다. 이 과정에서 공식문서에 Container Parameter 관련 설명이 부족하다고 판단하여 개선 내용을 담은 PR을 작성했습니다.

 

비록 해당 PR은 직접 머지되지 않았지만, Spring 팀에서 이를 바탕으로 공식문서 개선 ISSUE를 오픈하여 문서가 실제로 수정되었습니다. 단순한 궁금증에서 시작했지만, 오픈소스 문서 개선에까지 기여할 수 있는 뜻깊은 경험이었습니다.

이 글에서는 제가 분석한 Spring Method Validation의 동작 원리를 공유하고자 합니다.

 

 

`생성 했던 PR`

 

Add explanation for @Valid container parameters in ann-validation.adoc by HeeChanN · Pull Request #35186 · spring-projects/spr

Added a section explaining that container parameters annotated with @Valid

github.com

 

`PR 기반 병합된 문서 수정 Issue`

 

Improve Java Bean Validation documentation for controller methods · Issue #35759 · spring-projects/spring-framework

Based on #35186, make it clear that even with @RequestBody, @ModelAttribute, method validation is necessary if the method parameter is Map or Collection rather than a command object.

github.com

 

 

0. 학습 자료

Validation과 관련된 문서는 아래와 같이 여러 종류가 있었는데 이중 Spring Framework의 공식문서와 소스코드 기반으로 학습을 진행하였다.

`1. 스프링 공식 문서 :`

https://docs.spring.io/spring-framework/reference/web/webmvc/mvc-controller/ann-validation.html 

`2. Jakarta Validation 공식문서:`

https://jakarta.ee/specifications/bean-validation/3.1/jakarta-validation-spec-3.1.html#constraintsdefinitionimplementation

`3. Hibernate Validator 공식문서:`

https://docs.jboss.org/hibernate/validator/8.0/reference/en-US/html_single/#validator-gettingstarted

Jakarta Validation은 제약 조건(Constraint)과 Validation 동작 규칙을 정의한 표준 사양이라면 Hibernate Validator는 이를 구현한 구현체라고 볼 수 있다. Spring Boot는 gradle 빌드 스크립트에 아래처럼 추가하면 컨텍스트에 애플리케이션 시작시점에 LocalValidatorFactoryBean과 MethodValidationPostProcessor를 등록한다.

implementation 'org.springframework.boot:spring-boot-starter-validation'

 

http://github.com/spring-projects/spring-boot/blob/main/module/spring-boot-validation/src/main/java/org/springframework/boot/validation/autoconfigure/ValidationAutoConfiguration.java

 

spring-boot/module/spring-boot-validation/src/main/java/org/springframework/boot/validation/autoconfigure/ValidationAutoConfigur

Spring Boot helps you to create Spring-powered, production-grade applications and services with absolute minimum fuss. - spring-projects/spring-boot

github.com

 

`LocalValidatorFactoryBean` 같은 경우 Bean Validation과 관련된 모든 제약에 대해 검증할 수 있고 Hibernate Validator가 추가로 제공하는 확장 제약까지도 검증 할 수 있다.

`MethodValidationPostProcessor`의 경우 class에 @Validated를 붙였을 때 AOP로 검증을 진행하는데 사용한다.

 

`jakarta.validation.constraints` :

https://jakarta.ee/specifications/bean-validation/3.0/apidocs/jakarta/validation/constraints/package-summary

`hibernate.validator.constraints` : https://docs.jboss.org/hibernate/validator/8.0/api/org/hibernate/validator/constraints/package-summary.html

 

Spring Framework 6.1 이상 버전부터 method validator가 추가되었다. 이는 더이상 Controller에서 class에 @Validated를 붙이고 AOP 검증을 진행하는 방식을 사용하지 않아도 된다. 따라서, 앞으로 나올 검증에 관한 내용에서는 모두 LocalValidatorFactoryBean을 이용하여 검증하는 것으로 설명할 것이다.

 

1. 메서드 검증 살펴보기

다음 실습 코드를 바탕으로 각 상황에서 어떤 예외가 발생하는지 파악해 보았습니다.

https://github.com/HeeChanN/java-play-ground/tree/main/practice-validation

 

java-play-ground/practice-validation at main · HeeChanN/java-play-ground

java로 이것저것. Contribute to HeeChanN/java-play-ground development by creating an account on GitHub.

github.com

 

공식문서상에 나와있는 내용은 다음과 같다.

  1. `@ReqeustBody`, `@ModelAttribute`의 경우@Valid or @Validated가 파라미터에 붙어있다면, @Errors나 BindingResult 타입의 파라미터가 없으며 메서드 검증이 필요하지 않을 경우 오류가 난다면 `MethodArgumentNotValidException`이 던저진다.

  2. `@Min`, `@NotBlanck` 등 제약 조건이 메서드 파라미터 자체에 붙어있거나 return 값 검증에 붙어있으면 메서드 검증이 활성화된다. 메서드 검증이 활성화 되면 1번 조건보다 우선권을 갖는다. 이 경우 검증 실패시 `HandlerMethodValidationException`이 발생한다.

 

예시로 살펴보자.

@PostMapping("/api/item")
public Item createItem(@Valid @RequestBody ItemCreateDto itemCreateDto) {
    return itemService.createItem(itemCreateDto);
}

이 코드의 경우 1번 조건에 의해 `MethodArgumentNotValidException`가 발생한다.

 

@GetMapping("/api/items")
public Item getItemWithParam(@Min(1) @RequestParam(name = "id") long id){
    return itemService.getItem(id);
}

이 코드의 경우 2번 조건에 의해 `HandlerMethodValidationException`가 발생한다.

 

@PostMapping("/api/item2")
public Item createItemWithParam(@Valid @RequestBody ItemCreateDto itemCreateDto,
                                @Min(1) @RequestParam(name = "id") long id) {
    return itemService.createItem(itemCreateDto);
}

이 코드의 경우 2번 조건이 우선권을 갖어 ItemCreateDto의 필드값이 올바르지 않을 경우에도 `HandlerMethodValidationException`가 발생한다.

 

그럼 다음 코드는 어떤 예외를 던질까?

@PostMapping("/api/items")
public Item createItems(@Valid @RequestBody List<ItemCreateDto> itemCreateDto) {
    return itemService.createItem(itemCreateDto.get(0));
}

@Valid @ReqeustBody List<ItemCreateDto> 혹은 @ReqeustBody List<@Valid ItemCreateDto> 이 두개 모두 어떤 예외를 던질지 직접 확인해 보았을 때 `HandlerMethodValidationException`가 발생했다.

 

공식문서상에서는 Container parameter에 관해서는 설명이 나와있지 않아 어떤 과정을 거쳐서 이 예외가 발생하는지 궁금하여 직접 Spring MVC 코드를 살펴보았다.

 

2. Spring MVC 소스 코드로 살펴보기

메서드 검증이 적용되기 위해서 코드 상에서 제일 중요한 하나는 `methodValidator`다. 그리고 두 번째는 HandlerMethod 생성 시점에 결정되는 `validateArguments`값이다. 이 두개의 값을 통해 공식문서에서 나온 1번 규칙과 2번 규칙이 생겼고 Container Parameter가 왜 `HandlerMethodValidationException`을 던지는지 알 수 있다.

 

methodValidator와 관련된 코드가 간단하기 때문에 먼저 살펴보자.

`RequestMappingHandlerAdaptor`

private static final boolean BEAN_VALIDATION_PRESENT =
			ClassUtils.isPresent("jakarta.validation.Validator", HandlerMethod.class.getClassLoader());

@Override
public void afterPropertiesSet() {
    
    ...
    
    if (BEAN_VALIDATION_PRESENT) {
        List<HandlerMethodArgumentResolver> resolvers = this.argumentResolvers.getResolvers();
        this.methodValidator = HandlerMethodValidator.from(
                this.webBindingInitializer, this.parameterNameDiscoverer,
                methodParamPredicate(resolvers, ModelAttributeMethodProcessor.class),
                methodParamPredicate(resolvers, RequestParamMethodArgumentResolver.class));
    }
}

jakarta.validation.Validator가 클래스패스에 올라가 있으면 True로 고정되는 BEAN_VALIDATION_PRESENT 값을 기준으로 methodValidator를 생성한다. 검증기를 생성할 때 webBindingInitailizer를 이용하는데 LocalValidatorFactoryBean를 사용한다고만 알고 넘어가자. 더 알아보고 싶다면 webBindingInitailizer가 생성되는 시점에 debuging으로 확인해볼 수 있다.

 

따라서, 우리는 `implementation 'org.springframework.boot:spring-boot-starter-validation'`를 gradle 빌드 스크립트에 추가하기만 한다면 Spring Boot AutoConfiguration을 바탕으로 클래스패스에 jakarta.validation.Validator가 올라가기 때문에 자동으로 메서드 검증에 필요한 조건 하나를 만족하게 된다.

 

다음은 HandlerMethod의 validateArguments값이다. 이값은 HandlerMethod 생성시점에 결정된다. @RequestMapping과 @Controller를 사용하는 환경에서 사용하는 Handler는 `RequestMappingHandlerMapping`이다. `RequestMappingHandlerMapping`에서는 어노테이션 기반으로 Controller의 메서드들을 HandlerMethod로 만들어 관리한다. HandlerMethod를 생성할 때 메서드의 파라미터에 존재하는 어노테이션을 바탕으로 validateArguments를 설정한다.

 

아래 코드의 조건들을 살펴보면 가장 먼저 `Jakarta.valdation.validator`가 클래스 패스에 있는지, Class에서 `@Validated`를 설정했는지 본다. 만약 Class 레벨에 @Validated를 설정했다면 무조건 이 값이 false가 들어간다. 따라서, 이 부분에서 공식문서에서 말한 AOP 프록시 검증을 활성화할 시 메서드 검증을 이용하지 못한다는 말을 확인할 수 있다. 

 

두 번째는, jakarta.validation.Constraint가 붙어있으면 true가 된다. @NotNull, @Min 같은 제약 조건이 파라미터에 붙어있으면 `CONSTRAINT_PREDICATE`에  들어가 true로 설정된다.

 

세번째와 네번째조건이 바로 Container Parameter와 관련된 조건이다. `@Valid`가 붙어있고 index 또는 key로 접근 가능한 Collection이라면 true, List<@Valid Foo>와 같이 제네릭 타입 인수에 @Valid나 Constraint가 달려있으면 true로 설정된다.

 

이렇게 생성된 HandlerMethod의 validateArguments를 바탕으로 이후 method 검증을 진행할지 말지를 결정한다. 

 

`HandlerMethod.MethodValidationInitializer`

public static boolean checkArguments(Class<?> beanType, MethodParameter[] parameters) {
    if (BEAN_VALIDATION_PRESENT && AnnotationUtils.findAnnotation(beanType, Validated.class) == null) {
        for (MethodParameter param : parameters) {
            MergedAnnotations merged = MergedAnnotations.from(param.getParameterAnnotations());
            if (merged.stream().anyMatch(CONSTRAINT_PREDICATE)) {
                return true;
            }
            Class<?> type = param.getParameterType();
            if (merged.stream().anyMatch(VALID_PREDICATE) && isIndexOrKeyBasedContainer(type)) {
                return true;
            }
            merged = MergedAnnotations.from(getContainerElementAnnotations(param));
            if (merged.stream().anyMatch(CONSTRAINT_PREDICATE.or(VALID_PREDICATE))) {
                return true;
            }
        }
    }
    return false;
}

 

 

https://github.com/spring-projects/spring-framework/blob/main/spring-web/src/main/java/org/springframework/web/method/HandlerMethod.java#L419

 

spring-framework/spring-web/src/main/java/org/springframework/web/method/HandlerMethod.java at main · spring-projects/spring-fr

Spring Framework. Contribute to spring-projects/spring-framework development by creating an account on GitHub.

github.com

 

지금까지 살펴본 내용을 정리하면 다음과 같다

  1. methodValidator는 Spring Boot에 Validation 관련 설정을 추가했다면 무조건 생성된다.
  2. HandlerMethod의 validateArguments 값은 생성 시점에 파라미터에 따라 결정된다.

이를 바탕으로, 아래 다이어그램을 살펴보자.

공부한 내용은 `@RequestMapping`과 `@Controller`를 이용한 Spring MVC 환경에서 학습하였기 때문에 Handler의 경우에는 RequestMappingHandlerMapping을 Adaptor의 경우 `RequestMappingHandlerAdapter` 코드를 살펴보았습니다.

 

HandlerMethod의 1번부터 5번의 경우 실습코드에 사용한 Controller 메서드들 입니다.

@RestController
@RequiredArgsConstructor
public class ItemController {

    private final ItemService itemService;

    @PostMapping("/api/items")
    public Item createItems(@Valid @RequestBody List<ItemCreateDto> itemCreateDto) {
        return itemService.createItem(itemCreateDto.get(0));
    }

    @PostMapping("/api/item")
    public Item createItem(@Valid @RequestBody ItemCreateDto itemCreateDto) {
        return itemService.createItem(itemCreateDto);
    }

    @GetMapping("/api/items/{id}")
    public Item getItemWithPath(@Min(1) @PathVariable(name = "id") long id){
        return itemService.getItem(id);
    }

    @GetMapping("/api/items")
    public Item getItemWithParam(@Min(1) @RequestParam(name = "id") long id){
        return itemService.getItem(id);
    }

    @PostMapping("/api/item2")
    public Item createItemWithParam(@Valid @RequestBody ItemCreateDto itemCreateDto,
                                    @Min(1) @RequestParam(name = "id") long id) {
        return itemService.createItem(itemCreateDto);
    }

}

 

여기서 checkArguments() 조건들을 이용하여 각 HandlerMethod의 validateArguments를 정리한 것이 위 그림의 빨간박스입니다.

 

DispatcherServlet은 handlerMappings에서 `RequestMappingHandlerMapping`을 가져오고 해당 handler에 맞는 adaptor를 handlerAdaptors에서 찾는다. 이 과정에서 `RequestMappingHandlerAdapter`를 사용하여 handle()을 시작한다.

 

Adaptor에서는 호출해야할 HandlerMethod를 실행가능하도록 변경한다.(HandlerMethod -> InvocableHandlerMethod). 이 때, 파라미터 검증에 필요한 WebDataBinder를 생성하는 WebDataBinderFactory를 설정하게 되는데 이 과정에서 다음 코드와 같이 메서드 검증과 관련된 필드를 설정한다.

https://github.com/spring-projects/spring-framework/blob/main/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/RequestMappingHandlerAdapter.java#L986

 

spring-framework/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/RequestMappingHandlerAdapter.

Spring Framework. Contribute to spring-projects/spring-framework development by creating an account on GitHub.

github.com

private WebDataBinderFactory getDataBinderFactory(HandlerMethod handlerMethod) throws Exception {
    ...

    factory.setMethodValidationApplicable(this.methodValidator != null && handlerMethod.shouldValidateArguments());

    ...
}

 

methodValidator는 앞에서 추가한 인스턴스이고 `handlerMethod.shouldValidateArguments()`에서 가져오는 값이 바로 validateArguments 값이다. 따라서, 이 부분에서 메서드 검증 대상에 해당하면 WebDataBinderFactory의  methodValidationApplicable값이 true로 설정된다. Controller의 메서드 검증 대상이 이 부분에서 결정된다고 볼 수 있다.

 

마지막, HandlerMethod를 실행하는 과정에서 검증하는 부분이 두 곳 존재한다.

https://github.com/spring-projects/spring-framework/blob/main/spring-web/src/main/java/org/springframework/web/method/support/InvocableHandlerMethod.java#L175

 

spring-framework/spring-web/src/main/java/org/springframework/web/method/support/InvocableHandlerMethod.java at main · spring-p

Spring Framework. Contribute to spring-projects/spring-framework development by creating an account on GitHub.

github.com

 

`InvocableHandlerMethod`

@Nullable
public Object invokeForRequest(NativeWebRequest request, @Nullable ModelAndViewContainer mavContainer,
        Object... providedArgs) throws Exception {

    Object[] args = getMethodArgumentValues(request, mavContainer, providedArgs);
    if (logger.isTraceEnabled()) {
        logger.trace("Arguments: " + Arrays.toString(args));
    }

    if (shouldValidateArguments() && this.methodValidator != null) {
        this.methodValidator.applyArgumentValidation(
                getBean(), getBridgedMethod(), getMethodParameters(), args, this.validationGroups);
    }

    Object returnValue = doInvoke(args);

    if (shouldValidateReturnValue() && this.methodValidator != null) {
        this.methodValidator.applyReturnValueValidation(
                getBean(), getBridgedMethod(), getReturnType(), returnValue, this.validationGroups);
    }

    return returnValue;
}

 

`getMethodArgumentValues(...)`에서 공식문서에서 말한 1번 조건을 검증하고 `if(shouldValidatorArguments() && this.methodValidator != null)` 에서 2번 조건을 검증하게 된다.

 

`getMethodArgumentValues(...)` 에서는 파라미터에 존재하는 `@Valid`를 검증한다. 이를 위해 `RequestMappingHandlerAdapter`에서 설정한 WebDataBinderFactory를 이용하여 WebDataBinder를 생성하는데 이 때, Factory 생성 시점에 설정한 methodValidationApplicable를 이용한다.

 

methodValidationApplicable 값이 true로 설정되어 있다면 @Valid가 붙은 파라미터를 검증하는 validator를 제외시킨다. 이를 통해, 위 Controller 코드에서 @Valid와 Constraint가 모두 존재하는 `createItemWithParam()` 같은 경우에도 메서드 검증이 활성화 되어 `HandlerMethodValidationException`가 발생하게 되는 것이다.

 

만약, `@Valid @RequestBody ItemCreateDto dto`만 있으면 어떻게 될까? 해당 HandlerMethod의 경우 validateArguments값이 false이고 그에 따라 `WebDataBinderFactory`의 methodValidationApplicable 값이 false가 된다. 따라서, WebDataBinder의 검증기에서 @Valid가 붙은 파라미터를 검증할 수 있게 되고 그에 따라 `MethodArgumentNotValidException`가 발생한다.

 

아래 소스 코드는 methodValidationApplicable에 따른 validator 제외 여부를 결정하는 코드와 `getMethodArgumentValues(...)`에서 @Valid를 검증하는 코드이다. 

1. https://github.com/spring-projects/spring-framework/blob/main/spring-web/src/main/java/org/springframework/web/bind/support/DefaultDataBinderFactory.java#L149

2. https://github.com/spring-projects/spring-framework/blob/main/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/RequestResponseBodyMethodProcessor.java#L146

 

최종적으로, 아래 코드에서 메서드 검증을 진행한다.

`InvocableHandlerMethod.invokeForRequest(...)`

if (shouldValidateArguments() && this.methodValidator != null) {
    this.methodValidator.applyArgumentValidation(
            getBean(), getBridgedMethod(), getMethodParameters(), args, this.validationGroups);
}

 

HandlerMethod의 validateArguments를 한 번더 검사하고 methodValidator의 유무를 한 번더 검사한 후 검증을 진행하여 예외가 발생하면 HandlerMethodValidationException가 발생한다.

 

 

3. 메서드 검증과 AOP 프록시 검증의 관계

HandlerMethod를 등록할 때 파라미터를 기준으로 메서드 검증 활성화 or 비활성화를 결정하는 것을 살펴보았을 때, Class 레벨에 @Validated를 붙이게된다면 메서드 검증이 비활성화 된다는 사실을 확인할 수 있었다. 여기서 비활성화 되는 검증은 `HandlerMethodValidationException`을 던지는 메서드 검증만이다. 파라미터의 @Valid를 검증하는 로직의 경우 따로 @Valid를 검증하는 Validator를 제외하지 않았기 때문에 여전히 동작한다.

따라서, AOP 프록시 검증을 추가했더라도 `@Valid @RequestBody ItemCreateDto dto`를 파라미터로 갖는 메서드의 경우 AOP 프록시 검증이 호출 되기 이전에 `MethodArgumentNotValidException` 예외가 발생한다.

 

 

4. 마무리하며

처음에는 “왜 Container Parameter는 HandlerMethodValidationException을 던질까?”라는 단순한 궁금증에서 출발했지만, 과정은 매우 오래걸리고 여러가지 모듈들이 복잡하게 연결되어있는 구조였다. 사실 깊이 따지지 않고 @ExceptionHandler로 예외만 잡아 처리했다면 끝났을 수도 있다. 그럼에도 이렇게까지 열심히 소스코드를 보고 동작과정을 파악한 이유는 그냥 단순히 재밌어서였던 것 같다. 공식문서의 설명과 소스코드가 일치하는 부분을 찾아내며 이해하는 과정속에서 책이나 강의로 배울 때보다 10배, 아니 100배는 더 값진 학습 될 수 있었다.

 

또 하나 뜻깊었던 점은, 공식문서상의 Container Parameter와 관련한 설명이 부족했던 부분에 대해 설명을 추가해서 PR까지 날려보았는데 어쩌다 보니 docs 관련 오픈소스 기여까지도 시도해 볼 수 있는 값진 경험이었다.