Spring Boot에서의 Bean Validation (2)

해당 포스팅에서 사용된 예제 코드는 spring-boot validation example에서 확인 가능합니다.

Custom Validator

사용 가능한 constraint 어노테이션이 제공하는 제약 조건 외에 필요한 경우, 직접 만들어서 사용할 수 있다.

이전 포스팅에서 InPutRequestInputEntity 클래스에서 정규식(Regular Expression)을 사용하여 String이 유효한 PinCode 형식(6자리)인지 확인했었다. 이 부분을 별도의 Validator를 구현해 대체해보려고 한다.

먼저, custom constraint 어노테이션 PinCode를 생성한다.

1
2
3
4
5
6
7
8
9
10
11
12
@Target({ FIELD })
@Retention(RUNTIME)
@Constraint(validatedBy = PinCodeValidator.class)
@Documented
public @interface PinCode {

String message() default "{PinCode.invalid}";

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

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

custom constraint 어노테이션에는 다음과 같은 것들이 필요하다.

  • parameter message: ValidationMessages.properties에서 특정 property key를 가리키는 메시지 (제약 조건 위반시 메시지로 사용된다.)
  • parameter groups: 유효성 검사가 어떤 상황에서 실행되는지 정의할 수 있는 매개 변수 그룹.
  • parameter payload: 유효성 검사에 전달할 payload를 정의할 수 있는 매개 변수.
  • @Constraint: ConstraintValidator interface 구현을 나타내는 어노테이션

PinCode validator는 다음과 같이 구현한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class PinCodeValidator implements ConstraintValidator<PinCode, String> {

@Override
public boolean isValid(String value, ConstraintValidatorContext context) {
final Pattern pattern = Pattern.compile("^[0-9]{6}$");
final Matcher matcher = pattern.matcher(value);

try {
return matcher.matches();
} catch (Exception e) {
return false;
}
}
}

이제 다른 constraint 어노테이션과 마찬가지로 @PinCode 어노테이션을 사용할 수 있다.

1
2
3
4
5
6
7
public class InputEntityWithCustomValidator {

@PinCode
private String pinCode;

// ...
}

직접 Validator를 생성해 Validation 하기

Spring이 지원하는 Bean Validator에 의존하지 않고 직접 Bean Validation을 하고자하는 경우가 있을 수 있다.

이 경우, 직접 Validator를 생성하고 validation을 할 수 있다. Spring의 지원이 전혀 필요하지 않다는 것이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
@Service
@RequiredArgsConstructor
public class DirectlyValidateService {

public void validateInput(InputEntity inputEntity) {
final ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
final Validator validator = factory.getValidator();
final Set<ConstraintViolation<InputEntity>> violations = validator.validate(inputEntity);
if (!violations.isEmpty()) {
throw new ConstraintViolationException(violations);
}
}
}

그러나, Spring Boot는 이미 사전에 설정되어 만들어진 Validator 인스턴스를 제공한다. 그렇기 때문에 위와 같이 별도의 Validator 인스턴스를 직접 생성하지 않고 서비스에서 주입받아 사용하면 된다.

1
2
3
4
5
6
7
8
9
10
11
12
13
@Service
@RequiredArgsConstructor
public class DirectlyValidateService {

private final Validator validator;

public void validateInputWithInjectedValidator(InputEntity inputEntity) {
final Set<ConstraintViolation<InputEntity>> violations = validator.validate(inputEntity);
if (!violations.isEmpty()) {
throw new ConstraintViolationException(violations);
}
}
}

위의 Service는 Spring에 의해 Bean으로 생성될 때, Validator를 주입받게 된다.

위의 두 방법이 제대로 동작하는지 확인하기 위해 테스트 코드를 작성해보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
@ExtendWith(SpringExtension.class)
@SpringBootTest
class DirectlyValidateServiceTest {

@Autowired
private DirectlyValidateService service;

@Test
void whenInputEntityIsInvalid_thenThrowsException() {
final InputEntity inputEntity = new InputEntity();
inputEntity.setNumberBetweenOneAndTen(50);
inputEntity.setNotEmptyString("");
inputEntity.setPinCode("1234");

assertThrows(ConstraintViolationException.class, () -> {
service.validateInput(inputEntity);
});
}

@Test
void givenInjectedValidator_whenInputEntityIsInvalid_thenThrowsException() {
final InputEntity inputEntity = new InputEntity();
inputEntity.setNumberBetweenOneAndTen(50);
inputEntity.setNotEmptyString("");
inputEntity.setPinCode("1234");

assertThrows(ConstraintViolationException.class, () -> {
service.validateInputWithInjectedValidator(inputEntity);
});
}
}

Validation Groups

종종 특정 객체(클래스)는 서로 다른 상황에서 공유되어 사용될 수 있다.

예를 들면, CRUD와 같은 작업에서 “Create”와 “Update”를 수행할 때 같은 객체(클래스)를 사용하는 것이다. 그러나 다음의 경우처럼 서로 다른 상황에서 실행되어야 하는 validation이 있을 수 있다.

  • “Create” 상황에서만 Validation
  • “Update” 상황에서만 Validation
  • 두 가지 상황 모두에서 Validation

위와 같이 Validation 규칙을 구현할 수 있는 Bean Validation 기능을 Validation Groups이라고 부른다.

이전에 custom constraint 어노테이션을 직접 만들면서 groups 필드가 반드시 있어야 하는것을 보았다. 이를 이용해서 Validation이 실행되어야 하는 특정 validation group을 명시할 수 있다.

CRUD 예제를 위해 OnCreateOnUpdate라는 2개의 marker interface를 정의한다.

1
2
3
interface OnCreate {}

interface OnUpdate {}

그 다음 marker interface를 다음과 같은 constraint 어노테이션과 함께 사용할 수 있다.

1
2
3
4
5
6
7
8
class InputEntityWithCustomValidator {

@Null(groups = OnCreate.class)
@NotNull(groups = OnUpdate.class)
private Long id;

// ...
}

위의 코드는 “Create” 상황에서는 id가 null일 수 있고, “Update” 상황에서는 not null이어야 함을 의미한다.

Spring은 @Validated 어노테이션을 이용해 validation group을 사용할 수 있도록 지원한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Service
@Validated
public class ValidateServiceWithGroups {

@Validated(OnCreate.class)
void validateForCreate(@Valid InputEntityWithCustomValidator input) {
// do something
}

@Validated(OnUpdate.class)
void validateForUpdate(@Valid InputEntityWithCustomValidator input) {
// do something
}
}

@Validated 어노테이션은 클래스에도 적용되어야 하며, validation group에 의한 Validation을 하기 위해서는 메서드에도 적용되어야 한다.

제대로 동작하는지 확인하기 위해 테스트 코드를 작성해보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
@ExtendWith(SpringExtension.class)
@SpringBootTest
class ValidateServiceWithGroupsTest {

@Autowired
private ValidateServiceWithGroups service;

@Test
void whenInputIsInvalidForCreate_thenThrowsException() {
InputEntityWithCustomValidator input = new InputEntityWithCustomValidator();
input.setId(17L);
input.setNumberBetweenOneAndTen(5);
input.setNotEmptyString("not empty");
input.setPinCode("123456");

assertThrows(ConstraintViolationException.class, () -> {
service.validateForCreate(input);
});
}

@Test
void whenInputIsInvalidForUpdate_thenThrowsException() {
InputEntityWithCustomValidator input = new InputEntityWithCustomValidator();
input.setId(null);
input.setNumberBetweenOneAndTen(5);
input.setNotEmptyString("not empty");
input.setPinCode("123456");

assertThrows(ConstraintViolationException.class, () -> {
service.validateForUpdate(input);
});
}
}
Author

KimJongMin

Posted on

2019-11-21

Updated on

2021-03-22

Licensed under

댓글