이번 글에서는 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
'전체보기 > Spring' 카테고리의 다른 글
[JPA] JpaRepository vs CrudRepository(1) - Paging과 Sorting / QueryExampleExecutor (0) | 2022.01.01 |
---|---|
[Spring Boot] DateTime 다뤄보기(3) - Database에 저장하기 (0) | 2021.07.06 |
[Spring Boot] DateTime 다뤄보기 (0) | 2021.06.02 |
[Spring Boot]HikariCP 모니터링 - InstanceAlreadyExistsException 해결 (0) | 2020.07.19 |
[Spring Boot]HikariCP 모니터링 (0) | 2020.07.10 |