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으로 설정하면 된다.

Author

KimJongMin

Posted on

2019-11-18

Updated on

2021-03-22

Licensed under

댓글