Spring에서 Custom validation 테스트하기

스프링의 Bean validation을 활용하는 custom annotation을 테스트하는 과정에서 겪은 문제를 남겨둡니다.

개요

본문의 코드는 GitHub에서 확인하실 수 있습니다.

스프링에서는 기본으로 제공되는 validation 어노테이션 뿐만 아니라 필요에 따라 다양한 어노테이션을 직접 작성해 사용할 수도 있는데요.

이번에 겪은 문제는 @Autowired로 외부 의존성을 주입받는 validator를 테스트하는 과정에서 생겼습니다.

보통 직접 정의한 Custom validation의 테스트 방법을 검색하면 찾을 수 있는 코드(ex. 링크)는 대부분 다음과 같은 형태입니다.

private static ValidatorFactory validatorFactory;
private static Validator validator;

@BeforeAll
public static void init() {
    validatorFactory = Validation.buildDefaultValidatorFactory();
    validator = validatorFactory.getValidator();
}

@AfterAll
public static void close() {
    validatorFactory.close();
}

@Test
public void shouldReturnViolation() {
    ProductModel productModel = new ProductModel();
    productModel.setQuantity("a crazy String");

    Set<ConstraintViolation<ProductModel>> violations = validator.validate(productModel);

    assertThat(violations).isNotEmpty();
}

위의 코드는 대부분의 경우에 잘 동작합니다.

이제 문제가 발생한 상황을 재현해보겠습니다.

Replay

다음과 같은 요구사항을 가정하고 구현해보겠습니다.

  • 유저네임과 이메일을 갖는 회원을 등록할 수 있다.

  • 유저네임은 2~10자 길이여야 한다.

  • 이메일은 이메일 형식이어야 하며 다른 회원의 이메일과 중복되서는 안된다.

실행 결과와 전체 프로젝트는 GitHub에서 확인할 수 있고, 여기서는 중요한 부분들만 살펴보겠습니다.

사용자 등록 요청을 처리하는 Reuqest Dto를 다음과 같이 작성했습니다.

public class UserRegisterRequestDto {
    @NotBlank
    @Size(min = 2, max = 12)
    private String username;
    @UniqueEmail
    @Email
    private String email;

    // Getter, Setter
}

이메일 중복을 검사하기 위해 @UniqueEmail 어노테이션을 추가했습니다.

다음은 이 어노테이션을 보겠습니다.

@Constraint(validatedBy = UniqueEmailValidator.class)
@Target({ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
public @interface UniqueEmail {

    String message() default "이미 존재하는 이메일입니다";

    Class<?>[] groups() default {};

    Class<? extends Payload>[] payload() default {};
}

실질적으로 중복을 검사하는 로직은 UniqueEmailValidator에서 구현하고 있습니다.

public class UniqueEmailValidator implements ConstraintValidator<UniqueEmail, String> {

    @Autowired
    private UserRepository userRepository;

    @Override
    public void initialize(UniqueEmail constraintAnnotation) {

    }

    @Override
    public boolean isValid(String email, ConstraintValidatorContext context) {
        return !userRepository.findByEmail(email).isPresent();
    }
}

UserRepository에는 이메일로 사용자를 조회하는 Optional<User> findByEmail(String email)이 선언돼 있습니다.

isValid 메서드에서 해당 이메일로 사용자를 조회하여 중복된 이메일인지 확인하여 유효성을 검사합니다.

이제 유효성 검사가 잘 동작하는지 테스트해보겠습니다.

    private static ValidatorFactory validatorFactory;
    private static Validator validatorFromFactory;

    @BeforeAll
    public static void init() {
        validatorFactory = Validation.buildDefaultValidatorFactory();
        validatorFromFactory = validatorFactory.getValidator();
    }

    @AfterAll
    public static void close() {
        validatorFactory.close();
    }

    @Test
    void test_should_fail() {
        // Given
        UserRegisterRequestDto registerRequestDto = UserRegisterRequestDto.of("james", "test123@example.com");

        // When
        Set<ConstraintViolation<UserRegisterRequestDto>> violations = validatorFromFactory.validate(registerRequestDto);

        // Then
        assertThat(violations).isNotEmpty();
    }

위 테스트는 다음과 같은 예외가 발생하며 실패합니다.

// ... more above
Caused by: java.lang.NullPointerException
	at kr.latera.customvalidationtest.web.validator.UniqueEmailValidator.isValid(UniqueEmailValidator.java:21)
	at kr.latera.customvalidationtest.web.validator.UniqueEmailValidator.isValid(UniqueEmailValidator.java:9)
	at org.hibernate.validator.internal.engine.constraintvalidation.ConstraintTree.validateSingleConstraint(ConstraintTree.java:171)
	... 50 more

UniqueEmailValidator에서 @Autowired로 선언한 UserRepository 변수에 DI가 되지 않아 NPE가 발생했습니다.

처음엔 UserRepository 빈이 등록되지 않아서 발생했다고 생각했지만 원인을 찾아갈수록 테스트 코드에 더 의심이 가서 DI된 custom validator의 테스트 방법을 찾다가 링크의 답변을 찾았습니다.

코드를 자세히 보면 ValidatorFactory의 생성 방법이 다른 답변들에서 봤던 것과 다른 것을 볼 수 있습니다.

여기서 ‘지금 테스트에 사용한 Validator 외에 스프링에서 제공하는 Validator 구현이 있는 게 아닐까?‘와 같은 생각에 도달해서 Validator를 팩토리에서 생성하는 대신 직접 주입받아서 테스트해봤습니다.

   @Autowired
    private Validator validatorInjected;

    @Test
    void test_must_succeed() {
        // Given
        UserRegisterRequestDto registerRequestDto = UserRegisterRequestDto.of("james", "test123@example.com");

        // When
        Set<ConstraintViolation<UserRegisterRequestDto>> violations = validatorInjected.validate(registerRequestDto);

        // Then
        assertThat(violations).isNotEmpty();
    }

Validator 객체를 취하는 방법을 바꿨더니 테스트가 성공합니다.

이쯤에서 두 Validator가 서로 다른 구현체라는 것을 느끼고 좀 더 살펴봤습니다.

먼저 팩토리에서 직접 생성한 Validator는 다음 클래스입니다.

팩토리에서 생성한 Validator

@Autowired로 주입받은 Validator는 다음과 같습니다.

주입받은 Validator

Validator를 직접 생성했을 때, org.hibernate.validator.internal.ValidatorImpl의 Javadoc은 해당 클래스에 대해 간단히 설명하고 있습니다.

메인 Bean validation 클래스. 하이버네이트 validator의 코어 프로세싱 클래스입니다.

Jpa에서 Bean validation에 기본적으로 사용하는 클래스라고 유추할 수 있습니다.

@Autowired로 주입받은 org.springframework.validation.beanvalidation.LocalValidatorFactoryBean는 다음과 같이 설명하고 있습니다.

스프링 애플리케이션 컨텍스트에서 javax.validation (JSR-303) 셋업을 위한 클래스입니다. javaxvalidation.ValidationFactory를 로드하고 스프링 Validator 인터페이스와 JSR-303 Validator 인터페이스와 ValidatorFactory 자체를 통해 노출합니다.

스프링이나 JSR-303 Validator 인터페이스를 통해 이 빈의 인스턴스를 사용하면, 겉으로는 드러나지 않는 Validator Factory의 기본 Validator를 사용하게 됩니다. 거의 항상 기본 Validator를 사용한다고 가정하면, 팩토리에 추가로 호출을 수행할 필요가 없다는 점에서 편리합니다. 또한 Validator 타입의 의존성 대상에 직접 주입될 수도 있습니다.

이 클래스는 javax.validation API가 존재하지만 Validator가 명시적으로 설정되지 않은 경우, 스프링의 MVC 설정 네임스페이스에서도 사용됩니다.

@Autowired로 빈 객체를 주입받으려면 스프링의 애플리케이션 컨텍스트에서 실행돼야 합니다. 스프링에서 제공하는 Validator는 Bean Validation을 위한 객체들이 스프링 애플리케이션 컨텍스트에서 실행되도록 하는 기능을 하는 것으로 보입니다.

Conclusion

해결 과정과 설명을 장황하게 늘어놨지만, 결과적으로 초반의 테스트 코드에서 수정해야 할 부분은 단지 Validator@Autowired로 주입받는 것 뿐입니다.

Validation 로직은 단순하지만 Bean Validation을 처음 접하면서 관련된 문제를 어떻게 해결해야 하는지, 어떤 키워드로 검색해야 하는지 찾는 부분이 생각보다 쉽지 않았습니다.

목록으로