요청을 효과적으로 검증하고 싶다.
package org.springframework.validation;
public interface Validator {
boolean supports(Class<?> clazz);
void validate(Object target, Errors errors);
}
boolean supports(Class<?> clazz)
void validate(Object target, Errors errors);
import org.springframework.stereotype.Component;
import org.springframework.validation.Errors;
import org.springframework.validation.Validator;
// DI를 위해 Bean으로 등록
// Bean 등록 안 해도 사용은 할 수 있음
@Component
public class ItemValidator implements Validator {
@Override
public boolean supports(Class<?> clazz) {
return CreateItemRequest.class.isAssignableFrom(clazz);
}
@Override
public void validate(Object target, Errors errors) {
CreateItemRequest request = (CreateItemRequest) target;
if (request.getName().isBlank() || request.getName().isEmpty()) {
errors.reject("name", "name should not be blank");
}
if (1000 > request.getPrice() || request.getPrice() > 1_000_000) {
errors.reject("price", "price should be in range from 1000 to 1_000_000");
}
if (request.getQuantity() > 9_999) {
errors.reject("quantity", "quantity must be less than 9_999");
}
}
}
Controller에서 요청이 발생하면
supports
를 통해 내가 검증할 수 있는 형식(class)인가
를 파악함validate
메서드로 검증을 수행한 뒤, Errors
에 검증 실패 필드를 추가import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.validation.BindingResult;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.WebDataBinder;
import org.springframework.web.bind.annotation.*;
@Slf4j
@RestController
@RequestMapping("/api/items")
@RequiredArgsConstructor
public class ItemController {
private final ItemValidator itemValidator;
// 요청이 발생할 때 마다 validator가 호출된다.
@InitBinder
public void init(WebDataBinder dataBinder) {
dataBinder.addValidators(itemValidator);
// Validator 여러 개 등록 가능
}
// Validator에서 검증한 결과가 BindingResult bindingResult에 담겨있다.
@PostMapping
public void createItem(@Validated @RequestBody CreateItemRequest request, BindingResult bindingResult) {
log.info("request: {}", request);
if (bindingResult.hasErrors()) {
log.info("hasErrors: {}", bindingResult);
}
}
}
@InitBinder
을 통해, 컨트롤러에서 사용할 Validator를 등록한다.@Validated
또는 @Valid
를 통해 validator를 실행하야 함을 나타낸다.@InitBinder
에 등록된 Validator 구현체들 중에서, support
를 통과하는 구현체의 validate
를 사용한다.BindingResult
에 담겨있다.@InitBinder
에 등록된 Validator는 자기가 속한 컨트롤러의 요청에만 반응한다.Global Validator
로 등록해야 한다. (하단에서 추가 설명)실제 검증 실패 개수 * 2
가 된다.Validator
구현체를 만들어서 사용자 요청을 검증할 수 있다.
그런데 필드마다 검증 로직을 하나하나 만들어야 해서 귀찮다.
Bean Validation
을 사용하면 Annotation으로 간편하게 검증을 할 수 있다.
depenedencies {
...
implementation 'org.springframework.boot:spinrg-boot-starter-validation'
...
}
spinrg-boot-starter-validation
을 추가하면, 자동으로 Validator 인터페이스와 구현체
를 사용할 수 있게 된다.
Bean Validation
은 구현체가 아닌, 자바 표준 기술이다.
주로 Hibernate에서 만든 구현체가 사용된다.
jakarta.validation:jakarta.validation-api:2.0.2 -> 인터페이스
org.hibernate.validator:hiberante-validator:6.1.7.Final -> 구현체
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.hibernate.validator.constraints.Range;
import javax.validation.constraints.Max;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class CreateUserRequest {
@NotBlank
private String name;
@NotNull
@Range(min = 1_000, max = 1_000_000)
private Integer price;
@NotNull
@Max(9_999)
private Integer quantity;
}
Range
는 표준에는 없는 Hibernate가 제공하는 Annotation이다. 그런데 보통은 Hibernate 구현체를 사용하므로 걱정없이 사용해도 된다고 한다.
다양한 Annotation은 여기서 알아보자
spring-boot-starter-validation
라이브러리를 인식하면, 알아서 Bean Validation을 인지하고 스프링과 통합시킨다.LocalValidatorFactoryBean
을 글로벌 Validator로 등록한다.// Controller
import lombok.extern.slf4j.Slf4j;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@Slf4j
@RestController
@RequestMapping("/api/items")
public class ItemController {
@PostMapping
public void createItem(@Validated @ModelAttribute CreateItemRequest request) {
...
}
}
요청 객체가 Annotation의 제약 조건을 위배하면 MethodArgumentNotValidException
예외가 발생하고, 이를 DefaultHandlerExceptionResolver
가 처리하여 400응답을 보낸다.
그런데 검증 결과를 BindingResult
파라미터로 사용하면 예외로 인식하지 않는다.
@Slf4j
@RestController
@RequestMapping("/api/items")
public class ItemController {
@PostMapping
public void createItem(@Validated @RequestBody CreateItemRequest request, BindingResult bindingResult) {
...
}
}
검증 조건을 만족하지 못하지만, 예외 응답(400 BAD REQUEST)대신 정상 응답 (200 OK)를 보낸다. 이는 Bean Validator
를 사용하지 않는 상황에서도 마찬가지
WebMvcConfigurer
의 getValidator
를 통해 글로벌 Validator를 설정하면, 스프링 부트는 LocalValidatorFactoryBean
을 글로벌 Validator로 등록하지 않는다.
Bean Validation은 바인딩에 성공한 필드에만 이뤄진다.
정수형 필드에 문자열 입력 -> 바인딩 실패 -> 검증할 필요 없음
정수형 필드에 정수형 입력 -> 바인딩 성공 -> 검증 진행
상황에 맞춰 검증할 필드를 변경하고 싶다.
EX) 생성할 때는 ID 없음 <-> 수정할 때는 ID 있음
groups
사용하기// Check
public interface CreateCheck {}
public interface UpdateCheck {]
// DTO
@NotNull(groups = {CreateCheck.class, UpdateCheck.class})
private Long id;
// Controller
@PostMapping
public void create(@Valiated(CreateCheck.class) ...)
@PutMapping
public void update(@Validated(UpdateCheck.class) ...)
groups
옵션을 통해 검증할 상황을 구분할 수 있다.
위 방법은 사용이 번거로워서 잘 사용하지 않는다.
대신에 요청마다 별도의 객체를 만들어서 사용한다.
Bean Validation은 HttpMessageConverter(@RequestBody)
에도 사용할 수 있다.
다음의 과정을 거쳐 요청을 처리하게 된다.
요청 발생 -> 객체로 만들기 -> 검증 -> Controller 호출
@ModelAttribute
는 필드 단위로 바인딩이 진행되는 반면, @RequestBody
는 객체 단위로 바인딩이 진행된다.
즉, 여러 필드 중 한 필드가 바인딩이 안 되는 경우, @ModelAttribute
는 그 필드를 null로 처리하고 객체를 만드는데, @RequestBody
는 객체 자체가 만들어지지 않는다. 따라서 @RequestBody
는 이어지는 Validation 과정도 진행되지 않을 수 있다.