
컨트롤러 메소드 파라미터의 유효성 검사를 위해 컬렉션 타입(List)에 @Valid 어노테이션을 붙였는데, List 를 포함하여 List의 내부 필드 객체에 달린 Validation 어노테이션의 유효성 검사가 모두 무시되었다.
@PostMapping("/user/update")
public ResponseEntity<Object> updateUserInfo(
@RequestBody
@Valid List<UserInfoRequest> userInfoList, HttpServletRequest request) {
//...
}
그래서 구글링을 해보니 @Valid 는 컬렉션 타입에 대한 유효성 검사가 불가능 하다는 것을 알게 되었고,
@Valid 의 작동원리를 알아야 컬렉션 타입의 유효성 검사가 불가능한 이유를 이해하는데 도움이 될듯 하여 작동원리부터 찾아보았다.
@Valid 작동 원리컨트롤러 메소드의 파라미터는 ArgumentResolver가 생성하는데, 이때 @RequestBody 어노테이션이 붙은 파라미터에 대해서는 구현체인 RequestResponseBodyMethodProcessor가 동작하게 된다.
RequestResponseBodyMethodProcessor는 JSON 메시지를 객체로 변환해주는 작업을 하는데, 만약 @Valid어노테이션이 있을 경우 객체에 대한 유효성 검사를 진행한다.
따라서 @Valid는 ArgumentResolver 단계에서 처리되기때문에 Controller 계층에서만 실행된다.
(참고: Spring MVC 흐름)
@Valid 을 통해 유효성 검사 시 DataBinder클래스의 validate() 메소드로 이어지게 된다.
@Valid유효성 검사 호출 흐름
HandlerMethodArgumentResolver → RequestResponseBodyMethodProcessor → AbstractMessageConverterMethodArgumentResolver → DataBinder

DataBinder클래스의 validate 메소드를 보면 Validator인터페이스의 메소드인 validate()를 호출한다.
Validator인터페이스를 보면 2개의 메소드(supports, validate)가 존재하며, @Valid를 사용하면 Hibernate Validator가 내부적으로 작동하기 때문에 ValidatorImpl클래스의 validate() 메소드를 호출한다고 한다.
실제로 디버깅 해보니 Validator인터페이스의 구현체인 SpringValidatorAdapter 를 통해 ValidatorImpl클래스의 validate() 메소드를 호출하고 있다.
ValidatorImpl클래스의 validate() 메소드 내부 소스이다.
디버깅 해보니 ArrayList (컬렉션타입) 의 경우 JavaBeans 명세가 아니기 때문에 hasConstraints() 의 값은 false 이고, if 조건문에 부합하여 비어있는 Set 을 반환한다.
그렇기 때문에 @Valid를 통해 컬렉션 객체를 유효성 검사 시도하면 무시되는 것이었다.
1. @Validated 사용하기
class에 @Validated어노테이션을 붙이면 메소드 내부에서 객체의 유효성 검사가 가능하도록 메소드 레벨의 유효성 검사가 활성화 된다. 따라서 @Validated 로 활성화 후 @Valid 를 메소드 파라미터에 붙이면 컬렉션 타입과 내부 객체에 대한 유효성 검사가 가능하게 된다.
하지만 @Validated은 AOP 기반으로 작동하기 때문에 유효성 검사 실패 시ConstraintViolationException 예외를 발생하게 된다.
2. Validator 인터페이스 구현하기
나는 컨트롤러 계층에서 메소드의 파라미터에 대한 유효성 검사에 목적을 뒀기 때문에 @Valid로 컬렉션 타입 내부의 객체들에 대한 유효성 검사 진행하기로 했다.
위에서 @Valid의 작동 흐름을 보면 유효성 검사를 위해 Validator 인터페이스의 구현체인 validate 메소드를 호출하고 있다.
그러므로 Validator 인터페이스를 상속 받아 validate() 메소드를 오버라이딩하여 컬렉션 타입의 내부 객체들에 대한 유효성 검사를 할 수 있도록 로직을 작성하는 것이다.
@Component
public class CollectionValidator implements Validator {
private SpringValidatorAdapter validator;
public CollectionValidator() {
this.validator = new SpringValidatorAdapter(
Validation.buildDefaultValidatorFactory().getValidator()
);
}
}
ValidationUtils클래스의 invokeValidator메소드를 사용하여 컬렉션 내부 필드의 유효성 검사를 진행할 것이다.
invokeValidator메소드의 인자로 Validator객체가 필요하기 때문에 Validator의 구현체인 SpringValidatorAdapter를 선언하여 생성자 주입을 한다.
(참고로 @Valid의 유효성 검사 작동 순서에서 Validator → 구현체 SpringValidatorAdapter → ValidatorImpl의 validate() 메소드 순으로 호출한다.)
@Override
public boolean supports(Class<?> clazz) {
return true;
}
@Override
public void validate(Object target, Errors errors) {
if (target instanceof List) {
Collection collection = (Collection) target;
Assert.notEmpty(collection, "collection must not be null");
for (Object object : collection) {
ValidationUtils.invokeValidator(validator, object, errors);
}
}
}
invokeValidator메소드의 내부 소스를 보면 supports가 false 일 경우 예외를 발생시키고 있기 때문이다.
invokeValidator메소드 내부의 Assert.notNull 에서 검사 되지 않기 때문에 Assert.notEmpty 코드를 추가 했다.@RestControllerAdvice
public class ExceptionControllerAdvice {
@InitBinder
public void initBinder(WebDataBinder binder) {
binder.addValidators(new CollectionValidator());
}
}
컨트롤러에서 발생하는 유효성 검사에 대한 예외를 처리하기 위해 ControllerAdvice에 CollectionValidator를 추가하는 작업이 필요하다.
이때 WebDataBinder는 요청값으로 들어오는 데이터를 바인딩하는 역할을 하는데,
데이터 바인딩 시 유효성 검사를 위해 CollectionValidator 를 추가하고 @InitBinder을 통해 WebDataBinder를 초기화한다.
그럼 맨 위에서 유효성검사가 실행 되지 않았던 List 타입의 내부 객체 필드의 유효성 검사가 작동하게 된다!😖
import javax.validation.Validation;
import java.util.Collection;
import java.util.List;
@Component
public class CollectionValidator implements Validator {
private SpringValidatorAdapter validator;
public CollectionValidator() {
this.validator = new SpringValidatorAdapter(
Validation.buildDefaultValidatorFactory().getValidator()
);
}
@Override
public boolean supports(Class<?> clazz) {
return true;
}
@Override
public void validate(Object target, Errors errors) {
if (target instanceof List) {
Collection collection = (Collection) target;
Assert.notEmpty(collection, "collection must not be null");
for (Object object : collection) {
ValidationUtils.invokeValidator(validator, object, errors);
}
}
}
}
컬렉션 타입은 @Valid를 통해 유효성 검사하는 예제가 잘 없길래 왜인지 궁금하여 계속 파고 들었더니 작동원리 까지 닿게 되었다.
생각보다 디버깅하면서 원리를 파악하는게 재밋었고, 다음에는 @Validated 를 이용한 유효성 검사도 구현 해 봐야겠다.
📌참고
https://medium.com/chequer/valid-애너테이션을-컬렉션-타입-requestbody에-사용하기-6aef15bb8dff
https://beanvalidation.org/1.0/spec/#d0e991 중 3.1.3. Graph validation 챕터