Spring Collection @Valid

Miz·2021년 5월 9일
0

@Valid를 통한 Collection Dto 객체 validation

public class OrderItemRequest {
    @NotNull(message = "name is not null")
    @Length(max=50)
    private String name;

    @Min(value = 0, message = "price is over zero")
    private int unitPrice;

    @Min(value = 1, message = "price is over zero")
    private int unitCount;

}

   @PostMapping("/test")
    public ApiResult<OrderDto> test(
            @RequestBody @NotEmpty @Valid List<OrderItemRequest> items
    ) {
        // 생략
    }
  • 먼저 Collection을 @Valid 아래와 같이 했을때 원했던 동작 결과는 OrderItemRequest의 @NotNull, @Length, @Min 등의 Valid를 하고 싶었지만, 정상적으로 동작하지 않았다.
  • 그 이유를 찾아보기 위해 디버깅을 통해 클래스를 확인해보니
  1. RequestResponseBodyMethodProcessor.validateIfApplicable()
    • @Valid 어노테이션 확인
  2. DataBinder.validate()
    • org.springframework.validation.DataBinder
  3. ValidatorImpl.validate()
    • org.hibernate.validator.internal.engine.ValidatorImpl
@Override
	public final <T> Set<ConstraintViolation<T>> validate(T object, Class<?>... groups) {
		Contracts.assertNotNull( object, MESSAGES.validatedObjectMustNotBeNull() );
		sanityCheckGroups( groups );

		@SuppressWarnings("unchecked")
		Class<T> rootBeanClass = (Class<T>) object.getClass();
		BeanMetaData<T> rootBeanMetaData = beanMetaDataManager.getBeanMetaData( rootBeanClass );

		if ( !rootBeanMetaData.hasConstraints() ) {
			return Collections.emptySet();
		}

		BaseBeanValidationContext<T> validationContext = getValidationContextBuilder().forValidate( rootBeanClass, rootBeanMetaData, object );

		ValidationOrder validationOrder = determineGroupValidationOrder( groups );
		BeanValueContext<?, Object> valueContext = ValueContexts.getLocalExecutionContextForBean(
				validatorScopedContext.getParameterNameProvider(),
				object,
				validationContext.getRootBeanMetaData(),
				PathImpl.createRootPath()
		);

		return validateInContext( validationContext, valueContext, validationOrder );
	}
  • ValidatotrImpl.validate()에서 if ( !rootBeanMetaData.hasConstraints() ) 제약 조건에 걸리면서 emptySet()을 반환하고 종료된다.
  • Collection이 JavaBeans 명세에 포함되지 않아서 그렇다.

해결방법

  • 이 해결 방법은 spring boot 2.x 이상 버전에서만 정상 동작한다.
  • 이게 아니면 직접 custom validator를 구현하자
@RestController
@Validated // 이 어노테이션을 클래스 레벨에 붙여 준다.
public class Apis {

    // 메소드는 기존과 동일하다.
   @PostMapping("/test")
    public ApiResult<OrderDto> test(
            @RequestBody @NotEmpty @Valid List<OrderItemRequest> items
    ) {
        // 생략
    }
}

동작방식

  1. 위의 기본적인 @Valid 경우를 확인한다 -> 여기서 emptySet()을 반환하기 떄문에 에러가 없다.
  2. MethodValidationInterceptor
    • org.springframework.validation.beanvalidation.MethodValidationInterceptor
  3. ValidatorImpl.validateParameters()
    • org.hibernate.validator.internal.engine.ValidatorImpl
  • validateParameters를 호출하면서 Collection 내부의 Dto들에 대해 validate를 진행한다.

Exception Handling

  • 예외 핸들링 방법으로는 @ControllerAdvice 방식을 사용했다.
@RestControllerAdvice
public class ControllerExceptionAdvice {

    @ExceptionHandler(ConstraintViolationException.class)
    public ResponseEntity error(ConstraintViolationException e) {
        return ResponseEntity.ok().body(ApiResult.failed(e.getMessage()));
    }

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity error(MethodArgumentNotValidException e) {
        return ResponseEntity.ok().body(ApiResult.failed(e.getBindingResult().getAllErrors().get(0).getDefaultMessage()));
    }
}
  • ConstraintViolationException 예외는 Collection을 validateParameters()에서 발생하는 예외이다.
  • MethodArgumentNotValidException 예외는 먼저 동작하는 validate()에서 발생하는 예외이다.
    • bindingResult에 값이 담겨 오기 때문에 풀어서 적어줬다.

문제점

  1. 기본 validate(), validateParameters() 함수를 두번 부른다.
  2. 그냥 Dto를 validate할때도 마찬가지로 두번을 부른다
  3. 그냥 CustomValidator를 구현하는 방법으로 변경해야 할지 의문이 생긴다.

Custom Validator

출처 https://github.com/HomoEfficio/dev-tips/wiki/SpringMVC%EC%97%90%EC%84%9C-Collection%EC%9D%98-Validation


import org.springframework.stereotype.Component;
import org.springframework.validation.Errors;
import org.springframework.validation.Validator;
import org.springframework.validation.beanvalidation.SpringValidatorAdapter;
import org.springframework.web.bind.MethodArgumentNotValidException;

import javax.validation.Validation;
import java.util.Collection;

@Component
public class CustomCollectionValidator implements Validator {

        private SpringValidatorAdapter validator;

        public CustomCollectionValidator() {
            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 Collection){
                Collection collection = (Collection) target;

                for (Object object : collection) {
                    validator.validate(object, errors);
                }

            } else {
                validator.validate(target, errors);
            }

        }

}

위와 같이 CustomValidator를 만들어 준뒤 controller class에 DI 받아온다

    private final CustomCollectionValidator customCollectionValidator;

    // 생성자 코드 생략

    @PostMapping("test")
    public ApiResult<OrderDto> createOrder(
            @RequestBody @Valid List<OrderItemRequest> items, BindingResult bindingResult
    ) {

        customCollectionValidator.validate(items, bindingResult);
        if(bindingResult.hasError()) {
            //error 처리
        }

        //생략
    }

문제점

  • Apis 메소드에 추가되는 코드가 생긴다.

결과

  • 개인적으로는 클래스에 @Validated를 붙이는 방식이 낫다고 생각한다.
  • 직관적이지는 않지만 새로 추가되는 코드가 없어 유지보수성이 높다고 생각합니다
  • 수정
  • 다른 DTO들도 validate() + validateParameter()를 두번 타는 비효율을 가질 필요가 없다고 생각해 CustomValidator를 사용하는 방법으로 변경하였다.

더 깔끔한 방법은 뭘까... 정답을 아시면 알려주세요..

profile
2년차 백엔드 개발자

1개의 댓글

comment-user-thumbnail
2021년 6월 14일

좋은 글 감사합니다 미즈님~_~

답글 달기