전체보기/Spring

[Spring Boot] Custom Constraint / Validation 파헤치기

once0827 2020. 8. 26. 23:04

이번 글에서는 Spring Boot에서

validation이 동작하는 원리를 간단하게 살펴보고

직접 validator를 정의하는 방법을 정리해보겠습니다.

 

참고로, validation에 대한 설명을 따로 하지는 않기 때문에

validation이 무엇인지 궁금하신 분들은

다음 글을 참고해 주시면 될 것 같습니다.

( Validation 어디까지 해봤니? : TOAST Meetup )

 

또한, 예제들은 다음과 같은 환경에서 작성되었습니다.

  • Spring Boot 2.3.3
  • hibernate validator 6.1.5.Final ('spring-boot-starter-validation' dependency에 포함되어 있습니다.)
  • JUnit 5

우선 validation을 수행하기 위해

간단한 핸들러 메소드와 DTO(User class)를 정의해보겠습니다.

 

//SampleController.java
@PostMapping("/signin")
public boolean signin(@Valid @RequestBody User user){
    return service.signin(user);
}

//SampleService.java
public boolean signin(User user){
    return !user.getName().isEmpty();
}

 

@Data
public class User {
    @NotBlank
    @NoSpecialCharacter
    private String name;

    private int age;
}

 

validator에 초점을 맞추기 위해

최대한 간단하게 코드를 작성해 보았습니다.

 

User class를 보시면 name이라는 필드 값에

@NotBlank, @NoSpecialCharacter라는 어노테이션이 붙어있는 것을 보실 수 있습니다.

 

여기서 @NotBlank는 미리 정의되어 있는 어노테이션,

@NoSpecialCharacter는 이번 글에서 우리가 직접 정의할 어노테이션입니다.

 

그럼 바로 @NoSpecialCharacter가 어떻게 정의되어 있는지 보겠습니다.

 

@Constraint(validatedBy = NoSpecialCharacterValidator.class)
@Target(FIELD)
@Retention(RUNTIME)
public @interface NoSpecialCharacter {
    String message() default "can't contain special character";

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

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

 

이번 글에서는 validation에 초점을 맞추기 위해

@Target, @Retention 어노테이션은 따로 설명하지 않겠습니다.

 

그럼 가장 먼저 눈에 띄는 @Constraint 어노테이션을 살펴보겠습니다.

 

@Constraint의 validatedBy 값으로 우리가 아래에서 정의할

validator를 전달해주면 Spring Boot는 우리가 API를 호출할 때

전달한 값을 가져오면서 validation을 수행합니다.

 

//ConstraintHelper::getDefaultValidatorDescriptors
Class<? extends ConstraintValidator<A, ?>>[] validatedBy = (Class<? extends ConstraintValidator<A, ?>>[]) annotationType
	.getAnnotation( Constraint.class )
	.validatedBy();

//ConstraintTree::validateSingleConstraint
V validatedValue = (V) valueContext.getCurrentValidatedValue();
isValid = validator.isValid( validatedValue, constraintValidatorContext );

 

그럼 여기서 전달되는 validator는 어떻게 정의 되어 있는지 살펴보겠습니다.

 

import javax.validation.ConstraintValidator;
import javax.validation.ConstraintValidatorContext;
import java.util.regex.Pattern;

public class NoSpecialCharacterValidator implements ConstraintValidator<NoSpecialCharacter, String> {
    @Override
    public boolean isValid(String value, ConstraintValidatorContext context) {
        if (value == null) {
            return false;
        }

        Pattern pattern = Pattern.compile("[^a-zA-Z0-9\\s]");
        return !pattern.matcher(value).find();
    }
}

 

생각보다 간단하게, ConstraintValidator라는 인터페이스를 구현하고

isValid라는 메소드 하나를 정의하고 있습니다.

 

이 isValid라는 메소드에 우리가 정의하고자하는 validation logic이 정의됩니다.

예제의 메소드에서는 value가 null이거나

value에 알파벳과 숫자가 아닌 문자가 포함되어 있으면 false를 반환합니다.

 

그리고 위의 결과가 false일 경우, MethodArgumentNotValidException이 발생합니다.

 

다시 @NoSpecialCharacter로 돌아와서 나머지 내용들을 살펴보겠습니다.

 

@Constraint(validatedBy = NoSpecialCharacterValidator.class)
@Target(FIELD)
@Retention(RUNTIME)
public @interface NoSpecialCharacter {
    String message() default "can't contain special character";

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

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

 

@NoSpecialCharacter 내부에는 message, groups, payload가 정의되어 있습니다.

해당 내용들은 하나라도 빠져서는 안되며 ConstraintHelper가 이를 확인합니다.

 

//ConstraintHelper::isConstraintAnnotation
return externalConstraints.computeIfAbsent( annotationType, a -> {
	assertMessageParameterExists( a );
	assertGroupsParameterExists( a );
	assertPayloadParameterExists( a );
	assertValidationAppliesToParameterSetUpCorrectly( a );
	assertNoParameterStartsWithValid( a );

	return Boolean.TRUE;
} );

 

코드를 보시면 아시겠지만 이 외에도 @NoSpecialCharacter 내부에는

"valid"로 시작되는 메소드가 있어도 예외가 발생합니다.

 

Hibernate의 공식 문서에 따르면 각각의 값이 의미하는 바는 다음과 같습니다.

  • message: validation이 실패할 경우 반환되는 default 메세지
  • group: 특정 validation을 group을 지정하는 값( Validation Grouping )
  • payload: 사용자가 추가 정보를 위해 전달할 수 있는 값으로 주로 심각도를 나타낼 때 사용됩니다.

각 값들에 대한 내용은 기회가 된다면 다른 글에서 조금 더 자세하게 다뤄보겠습니다.

 

마지막으로 우리가 정의한 validator가 잘 동작하는지 테스트 해보겠습니다.

 

class NoSpecialCharacterValidatorTest {
    private static ValidatorFactory validatorFactory;
    private static Validator validator;

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

    @Test
    void isValid_validUser_Test() {
        User user = new User();
        user.setName("name");

        Set<ConstraintViolation<User>> violations = validator.validate(user);
        assertTrue(violations.isEmpty());
    }

    @Test
    void isValid_invalidUser_Test() {
        User user = new User();
        user.setName("name!");

        Set<ConstraintViolation<User>> violations = validator.validate(user);

        Iterator<ConstraintViolation<User>> iterator = violations.iterator();
        ConstraintViolation<User> violation = iterator.next();

        assertEquals("name", violation.getPropertyPath().toString());
    }
}

 

그럼 여기까지 Custom Constraint 정의하는 방법이었습니다.

 

감사합니다.

Reference

반응형