Controller에서 컬렉션 타입 요청 유효성 검사하기 (feat. @Valid는 컬렉션 타입 유효성 검사가 불가능한 이유)

yeahdy_:)·2024년 3월 20일

Spring

목록 보기
1/3
post-thumbnail

컨트롤러 메소드 파라미터의 유효성 검사를 위해 컬렉션 타입(List)에 @Valid 어노테이션을 붙였는데, List 를 포함하여 List의 내부 필드 객체에 달린 Validation 어노테이션의 유효성 검사가 모두 무시되었다.

@PostMapping("/user/update")
public ResponseEntity<Object> updateUserInfo(
             @RequestBody
             @Valid List<UserInfoRequest> userInfoList, HttpServletRequest request) {
    //...                                       
}

그래서 구글링을 해보니 @Valid 는 컬렉션 타입에 대한 유효성 검사가 불가능 하다는 것을 알게 되었고,
@Valid 의 작동원리를 알아야 컬렉션 타입의 유효성 검사가 불가능한 이유를 이해하는데 도움이 될듯 하여 작동원리부터 찾아보았다.

📌@Valid 작동 원리

  1. 컨트롤러 메소드의 파라미터는 ArgumentResolver가 생성하는데, 이때 @RequestBody 어노테이션이 붙은 파라미터에 대해서는 구현체인 RequestResponseBodyMethodProcessor가 동작하게 된다.

  2. RequestResponseBodyMethodProcessor는 JSON 메시지를 객체로 변환해주는 작업을 하는데, 만약 @Valid어노테이션이 있을 경우 객체에 대한 유효성 검사를 진행한다.

  3. 따라서 @Valid는 ArgumentResolver 단계에서 처리되기때문에 Controller 계층에서만 실행된다.

(참고: Spring MVC 흐름)

📌원인

  1. @Valid 을 통해 유효성 검사 시 DataBinder클래스의 validate() 메소드로 이어지게 된다. (이미지엔 짤려서 안보임..)

@Valid 유효성 검사 호출 흐름
HandlerMethodArgumentResolver → RequestResponseBodyMethodProcessor → AbstractMessageConverterMethodArgumentResolver → DataBinder


  1. DataBinder클래스의 validate 메소드를 보면 Validator인터페이스의 메소드인 validate()를 호출한다.

  1. Validator인터페이스를 보면 2개의 메소드(supports, validate)가 존재하며, @Valid를 사용하면 Hibernate Validator가 내부적으로 작동하기 때문에 ValidatorImpl클래스의 validate() 메소드를 호출한다고 한다. 실제로 디버깅 해보니 Validator인터페이스의 구현체인 SpringValidatorAdapter 를 통해 ValidatorImpl클래스의 validate() 메소드를 호출하고 있다.

  2. 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() 메소드를 오버라이딩하여 컬렉션 타입의 내부 객체들에 대한 유효성 검사를 할 수 있도록 로직을 작성하는 것이다.

📌구현 코드

1. Validator 인터페이스 구현하기

@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 → 구현체 SpringValidatorAdapterValidatorImpl의 validate() 메소드 순으로 호출한다.)

2. 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);
        }
    }
}
  • supports(Class<?> clazz)
    true 를 반환하도록 한 이유는 invokeValidator메소드의 내부 소스를 보면 supports가 false 일 경우 예외를 발생시키고 있기 때문이다.
  • validate(Object target, Errors errors)
    List 타입인지 확인한 후 for문을 돌려서 리스트 내부의 객체 필드에 대한 유효성 검사를 하나씩 진행한다.
    그런데 List 의 size() 가 0일 경우에는 invokeValidator메소드 내부의 Assert.notNull 에서 검사 되지 않기 때문에 Assert.notEmpty 코드를 추가 했다.

3. 예외 처리하기

@RestControllerAdvice
public class ExceptionControllerAdvice {
    @InitBinder
    public void initBinder(WebDataBinder binder) {
        binder.addValidators(new CollectionValidator());
    }
}

컨트롤러에서 발생하는 유효성 검사에 대한 예외를 처리하기 위해 ControllerAdvice에 CollectionValidator를 추가하는 작업이 필요하다.

이때 WebDataBinder는 요청값으로 들어오는 데이터를 바인딩하는 역할을 하는데,
데이터 바인딩 시 유효성 검사를 위해 CollectionValidator 를 추가하고 @InitBinder을 통해 WebDataBinder를 초기화한다.

결과

그럼 맨 위에서 유효성검사가 실행 되지 않았던 List 타입의 내부 객체 필드의 유효성 검사가 작동하게 된다!😖


CollectionValidator 전체 코드

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 챕터

profile
기억하기 위해 기록하고 있습니다. 포스트 중 잘못된 정보가 있다면 코멘트 남겨주세요🐰

0개의 댓글