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);
});
}
}

Spring Boot에서의 Bean Validation (1)

Bean Validation은 Java 생태계에서 유효성(Validation) 검증 로직을 구현하기위한 사실상의 표준이다. Bean Validation은 Spring과 Spring Boot에도 잘 통합되어 있다.

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

Validation 설정

Spring Boot에서의 Bean Validation은 spring-boot-starter-validation를 추가함으로써 사용할 수 있다.

1
implementation('org.springframework.boot:spring-boot-starter-validation')

Spring Dependency Management Gradle 플러그인(“io.spring.dependency-management”)을 사용한다면 현재 사용중인 Spring Boot 버전에서 자동으로 의존성(버전)을 가져오기 때문에 별도로 버전을 명시할 필요가 없다.

만약 spring-boot-starter-web를 포함하고 있다면 Validation Starter도 포함되어 있기 때문에 따로 추가할 필요는 없다.

1
implementation('org.springframework.boot:spring-boot-starter-web')

Validation Starter는 Bean Validation Specification 구현체 중 가장 널리 사용되고 있는 hibernate validator를 포함하고 있다.

Bean Validation 기본

기본적으로 Bean Validation은 클래스 필드에 특정 어노테이션(annotation)을 달아 제약 조건을 정의하는 방식으로 동작한다. 그 후 해당 클래스 객체를 Validator를 이용해 제약 조건의 충족 여부를 확인한다.

Spring MVC Controller의 Validation

Spring RestController를 통해 Client로부터 전달받은 요청(request, input)에 대해 유효성을 검사하고자 한다. Client로부터 들어오는 다음의 3가지 요청 형식에 대해서 유효성 검사를 할 수 있다.

  • request body
  • path에 포함된 variables (ex. /foos/{id}의 id)
  • query parameters

Request Body

POST 혹은 PUT 요청에서 request body에 JSON 형식의 데이터를 전달하는 것은 일반적이며, Spring은 JSON 형식의 데이터를 Java 객체에 자동으로 매핑한다. 이 과정에서 매핑되는 Java 객체가 요구 사항을 충족하는지 확인하려 한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
@Data
public class InputRequest {

@Min(1)
@Max(10)
private int numberBetweenOneAndTen;

@NotEmpty
private String notEmptyString;

@Pattern(regexp = "^[0-9]{6}$")
private String pinCode;
}

numberBetweenOneAndTen는 1에서 10 사이의 값을 가져야 하며, notEmptyString은 빈 문자열(“”)이 아니어야 하고, pinCode는 6자리의 숫자를 가져야 한다.

request body에서 InputRequest 객체를 가져와 유효성 검사를 하는 RestController는 다음과 같다.

1
2
3
4
5
6
7
8
@RestController
public class ValidateRequestBodyController {

@PostMapping("/validateBody")
ResponseEntity<String> validateBody(@Valid @RequestBody InputRequest request) {
return ResponseEntity.ok("valid");
}
}

@RequestBody 어노테이션을 사용하고 있는 Input 매개변수에 @Valid 어노테이션만 추가하면 된다. 이로인해 Spring은 다른 작업을 수행하기 전에 먼저 객체를 Validator에 전달해서 유효성을 검사하게 된다.

만약 InputRequest 클래스에 유효성을 검사해야하는 다른 객체가 필드로 포함된 경우, 이 필드에도 @Valid 어노테이션을 추가해야한다. 이러한 경우를 Complex Type이라고 부른다.

유효성 검사에 실패할 경우 MethodArgumentNotValidException 예외가 발생한다. 기본적으로 Spring은 이 예외에 대해서 HTTP status code 400(Bad Request)으로 변환한다.

아래의 테스트 코드를 통해 동작을 확인할 수 있다.

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
@ExtendWith(SpringExtension.class)
@WebMvcTest(controllers = ValidateRequestBodyController.class)
class ValidateRequestBodyControllerTest {

@Autowired
private MockMvc mvc;

@Autowired
private ObjectMapper objectMapper;

@Test
void whenInputRequestIsInvalid_thenReturnStatus400() throws Exception {
final InputRequest request = new InputRequest();
request.setNumberBetweenOneAndTen(50);
request.setNotEmptyString("");
request.setPinCode("1234");

final String body = objectMapper.writeValueAsString(request);

mvc.perform(post("/validateBody")
.contentType("application/json")
.content(body))
.andExpect(status().isBadRequest());
}
}

Path Variables & Request Parameters

path에 포함된 variables과 query parameters에 대한 유효성 검사는 조금 다르게 동작한다.

Path Variable과 Request Parameter의 경우는 int와 같은 primitive type이거나 Integer 혹은 String과 같은 객체이기 때문에 복잡한 유효성 검사를 하지 않는다.

클래스 필드에 직접 어노테이션을 추가하지 않고 다음과 같이 Controller 메소드 매개 변수에 직접 제약 조건을 추가한다.

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

@GetMapping("/validatePathVariable/{id}")
ResponseEntity<String> validatePathVariable(@PathVariable("id") @Min(5) int id) {
return ResponseEntity.ok("valid");
}

@GetMapping("/validateRequestParameter")
ResponseEntity<String> validateRequestParameter(@RequestParam("param") @Min(5) int param) {
return ResponseEntity.ok("valid");
}
}

@Validated 어노테이션을 클래스 레벨의 Controller에 추가해 Spring이 메서드 매개 변수에 대한 제한 조건 annotation을 평가하게 해야한다.

request body 유효성 검사와 달리 실패할 경우 MethodArgumentNotValidException 예외가 아닌 ConstraintViolationException 예외가 발생한다. 주의할 것으로는 Spring은 ConstraintViolationException 예외에 대해서는 기본적으로 exception을 handling 하지 않기 때문에 HTTP status code 500(Internal Server Error)로 처리한다.

만약 HTTP status code 400(Bad Request)으로 처리하고자 한다면, custom exception handler를 사용하면 된다.

1
2
3
4
5
6
7
8
9
10
11
12
13
@Validated
@RestController
public class ValidateParametersController {

// request mapping method omitted

@ExceptionHandler(ConstraintViolationException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
@ResponseBody
String handleConstraintViolationException(ConstraintViolationException e) {
return "not valid due to validation error: " + e.getMessage();
}
}

테스트 코드를 통해 동작을 확인해보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@ExtendWith(SpringExtension.class)
@WebMvcTest(controllers = ValidateParametersController.class)
class ValidateParametersControllerTest {

@Autowired
private MockMvc mvc;

@Test
void whenPathVariableIsInValid_thenReturnStatus400() throws Exception {
mvc.perform(get("/validatePathVariable/1"))
.andExpect(status().isBadRequest());
}

@Test
void whenRequestParameterIsInvalid_thenReturnStatus400() throws Exception {
mvc.perform(get("/validateRequestParameter")
.param("param", "1"))
.andExpect(status().isBadRequest());
}
}

Spring Service의 Validation

Controller 레벨에서 입력을 검증하는것 뿐만 아니라 @Validated@Valid 어노테이션을 이용해 다른 Spring component에서도 입력에 대해 유효성을 검증할 수 있다.

1
2
3
4
5
6
7
@Service
@Validated
public class ValidateService {

void validateInputRequest(@Valid InputRequest input) {
}
}

@Validated 어노테이션은 클래스 수준에서만 평가되기 때문에 메서드에는 추가하지 말아야 한다.

테스트 코드는 다음과 같다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@ExtendWith(SpringExtension.class)
@SpringBootTest
class ValidateServiceTest {

@Autowired
private ValidateService service;

@Test
void whenInputRequestIsInvalid_thenThrowException() {
final InputRequest request = new InputRequest();
request.setNumberBetweenOneAndTen(50);
request.setNotEmptyString("");
request.setPinCode("1234");

assertThrows(ConstraintViolationException.class, () -> {
service.validateInputRequest(request);
});
}
}

Spring Repository의 Validation

유효성 검사를 위한 가장 마지막 계층은 persistence layer이다. 기본적으로 Spring Data는 Hibernate를 사용하여 Bean Validation을 지원한다.

JPA Entities

InputEntity 클래스의 객체를 DB에 저장하려고 한다. 먼저 필요한 JPA 어노테이션들을 추가한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Data
@Entity
public class InputEntity {

@Id
@GeneratedValue
private Long id;

@Min(1)
@Max(10)
private int numberBetweenOneAndTen;

@NotEmpty
private String notEmptyString;

@Pattern(regexp = "^[0-9]{6}$")
private String pinCode;
}

위의 Entity를 관리하는 CRUE Repository도 생성한다.

1
public interface ValidateRepository extends CrudRepository<InputEntity, Long> {}

기본적으로 제약 조건을 위반한 InputEntity 객체를 저장할 때 ConstraintViolationException이 발생한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@ExtendWith(SpringExtension.class)
@DataJpaTest
class ValidateRepositoryTest {

@Autowired
private ValidateRepository repository;

@Autowired
private EntityManager entityManager;

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

assertThrows(ConstraintViolationException.class, () -> {
repository.save(inputEntity);
entityManager.flush();
});
}
}

Bean Validation은 EntityManager가 flush된 후에 트리거 된다. Hibernate는 특정 상황에서 EntityManager를 자동으로 flush하지만 integration test의 경우에는 직접 수행해야한다.

만약 Spring Data Repository에서 Bean Validation을 비활성화하려면 Spring Boot property인 spring.jpa.properties.javax.persistence.validation.mode 값을 none으로 설정하면 된다.

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×