Bean Validation 2.0(JSR-380)이라는 기술 표준
대표적인 구현체는 하이버네이터 Validator가 있다.
이름은 하이버네이트이지만 ORM과는 관련이 없다.
하이버네이트 Validator
의존 관계 추가
Copy implementation 'org.springframework.boot:spring-boot-starter-validation'
테스트 코드 작성
Copy import lombok . Data ;
// 하이버네이트 validator 구현체를 사용할 때만 제공된다. 실무에서 대부분 사용하므로 자유롭게 쓰면 된다.
import org . hibernate . validator . constraints . Range ;
// 특정 구현에 관계없이 제공되는 표준 인터페이스
import javax . validation . constraints . Max ;
import javax . validation . constraints . NotBlank ;
import javax . validation . constraints . NotNull ;
@ Data
public class Item {
private Long id;
@ NotBlank
private String itemName;
@ NotNull
@ Range (min = 1000 , max = 1_000_000 )
private Integer price;
@ NotNull
@ Max ( 9999 )
private Integer quantity;
public Item () {
}
public Item ( String itemName , Integer price , Integer quantity) {
this . itemName = itemName;
this . price = price;
this . quantity = quantity;
}
}
Copy public class BeanValidationTest {
@ Test
void beanValidation () {
ValidatorFactory factory = Validation . buildDefaultValidatorFactory ();
// 검증기 생성
Validator validator = factory . getValidator ();
Item item = new Item() ;
item . setItemName ( " " );
item . setPrice ( 0 );
item . setQuantity ( 10000 );
Set < ConstraintViolation < Item >> violations = validator . validate (item);
for ( ConstraintViolation < Item > violation : violations) {
System . out . println ( "violation=" + violation);
System . out . println ( "violation.message=" + violation . getMessage ());
}
}
}
Copy violation={interpolatedMessage='공백일 수 없습니다', propertyPath=itemName,rootBeanClass=class hello.itemservice.domain.item.Item,messageTemplate='{javax.validation.constraints.NotBlank.message}'}
violation.message=공백일 수 없습니다
violation={interpolatedMessage='9999 이하여야 합니다', propertyPath=quantity,rootBeanClass=class hello.itemservice.domain.item.Item,messageTemplate='{javax.validation.constraints.Max.message}'}
violation.message=9999 이하여야 합니다
violation={interpolatedMessage='1000에서 1000000 사이여야 합니다',propertyPath=price, rootBeanClass=class hello.itemservice.domain.item.Item,messageTemplate='{org.hibernate.validator.constraints.Range.message}'}
violation.message=1000에서 1000000 사이여야 합니다
애너테이션 하나로 간단하게 검증할 수 있다. 스프링은 빈 검증기를 통합해뒀기 때문에 실제로 직접 빈 검증기를 쓸 일은 없다.
스프링 적용
ValidationItemControllerV3.java
Copy @ Slf4j
@ Controller
@ RequestMapping ( "/validation/v3/items" )
@ RequiredArgsConstructor
public class ValidationItemControllerV3 {
@ PostMapping ( "/add" )
public String addItem(@Validated @ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes) {
if (bindingResult.hasErrors()) {
log . info ( "errors={}" , bindingResult);
return "validation/v3/addForm" ;
}
Item savedItem = itemRepository . save (item);
redirectAttributes. addAttribute ( "itemId" , savedItem.getId());
redirectAttributes. addAttribute ( "status" , true);
return "redirect:/validation/v3/items/{itemId}" ;
}
}
기존 Validator 코드를 제거해도 실행해보면 검증기가 적용된다. validation 의존성을 추가하면 스프링이 자동으로 처리해주기 때문이다.
작동 원리
LocalValidatorFactoryBean을 글로벌 Validator로 등록한다.
따라서 @Valid, Validated 애너테이션만 적용하면 된다. 검증 오류가 발생하면 FieldError, ObjectError를 생성해 BindingResult에 담는다.
만약 이전에 만들었던 글로벌 검증기를 적용하고 있다면 스프링 부트가 자동으로 Validator를 등록하지 않기 때문에 제거해준다.
검증 순서
@ModelAttribute가 각 필드에 타입 변환을 시도한다.
requestParam을 각 필드에 넣어준다.
실패하면 typeMismatch로 FieldError를 추가한다.
즉, 바인딩에 성공한 필드만 Bean Validation을 적용한다. 일단 모델 객체에서 바인딩 받는 값이 정상으로 들어와야 검증도 의미가 있기 때문이다.
에러 코드
각 애너테이션은 그 이름을 오류 코드로 해서 메시지 코드를 생성한다.
@NotBlank
NotBlank.java.lang.String
@Range
따라서 메시지를 등록하면 Bean Validation의 기본 기능의 메시지를 수정할 수 있다.
errors.properties
Copy ...
# Bean Validation 추가
NotBlank= {0} 공백X
Range= {0}, {2} ~ {1} 허용
Max= {0}, 최대 {1}
메시지 검색 우선순위
생성된 메시지 코드의 순서대로 messageSource에서 메시지 검색
애너테이션에 정의한 message 속성
@NotBlank(message = "공백은 넣을 수 없습니다. {0}")
오브젝트 오류
@ScriptAssert
Bean Validation 애너테이션은 필드에 선언한다. 오브젝트 에러를 만들고 싶다면 어떻게 해야할까?
Item.java
Copy @ Data
@ ScriptAssert (lang = "javascript" , script = "_this.price * _this.quantity >= 10000" )
public class Item {
...
}
Copy 스크립트 표현식 "_this.price * _this.quantity >= 10000"가 true로 평가되지 않았습니다
상품의 가격 * 수량의 합은 10,000원 이상이어야 합니다. 현재 값 = 1,000
이 방식은 제약 조건이 많고 복잡하다. 실무에서 나오는 다양한 조건을 만족하기도 어렵다.
자바 코드 작성
Copy @ Slf4j
@ Controller
@ RequestMapping ( "/validation/v3/items" )
@ RequiredArgsConstructor
public class ValidationItemControllerV3 {
@ PostMapping ( "/add" )
public String addItem(@Validated @ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes) {
if (item.getPrice() != null && item.getQuantity() != null) {
int resultPrice = item . getPrice () * item . getQuantity ();
if (resultPrice < 10000 ) {
bindingResult . reject ( "totalPriceMin" , new Object []{ 10000 , resultPrice} , null );
}
}
...
}
}
따라서 그냥 자바 코드로 풀어내는 것을 권장한다.
Groups
데이터 등록과 수정의 요구 사항이 다를 수 있다. 만약 수정 기능에선 id가 @NotNull이라면 같은 객체를 사용하는 등록 기능에 문제가 발생한다.
그래서 Bean Validation은 groups라는 기능을 제공한다. 예를 들어 등록과 수정을 다른 그룹으로 나눠 적용할 수 있다.
SaveCheck.java UpdateCheck.java
Copy public interface SaveCheck {
}
Copy public interface UpdateCheck {
}
Item.java ValidationItemControllerV3.java
Copy @ Data
public class Item {
// 수정할 때만 적용한다.
@ NotNull (groups = UpdateCheck . class )
private Long id;
@ NotBlank (groups = { SaveCheck . class , UpdateCheck . class })
private String itemName;
@ NotNull (groups = { SaveCheck . class , UpdateCheck . class })
@ Range (min = 1000 , max = 1000000 , groups = { SaveCheck . class , UpdateCheck . class })
private Integer price;
@ NotNull (groups = { SaveCheck . class , UpdateCheck . class })
// 등록할 때만 적용한다.
@ Max (value = 9999 , groups = SaveCheck . class )
private Integer quantity;
public Item () {
}
public Item ( String itemName , Integer price , Integer quantity) {
this . itemName = itemName;
this . price = price;
this . quantity = quantity;
}
}
Copy @ Slf4j
@ Controller
@ RequestMapping ( "/validation/v3/items" )
@ RequiredArgsConstructor
public class ValidationItemControllerV3 {
@ PostMapping ( "/add" )
// 각 메서드에 적용한다.
public String addItemV2(@Validated(SaveCheck.class) @ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes) {
...
}
}
@Validated에 groups 기능을 적용하면 된다. @Valid에는 이 기능이 없다.
하지만 복잡도가 올라가고 실무에서는 다른 방법을 쓰기 때문에 잘 쓰지 않는다.