Bean Validation 2.0(JSR-380)
이라는 기술 표준이다. 쉽게 이야기해서 검증 애노테이션과 여러 인터페이스의 모음이다. 마치 JPA가 표준 기술이고 그 구현체로 하이버네이트가 있는 것과 같다.공식 사이트: http://hibernate.org/validator/
공식 메뉴얼: https://docs.jboss.org/hibernate/validator/6.2/reference/en-US/html_single/
검증 어노테이션 모음: https://docs.jboss.org/hibernate/validator/6.2/reference/en-US/html_single/#validator-defineconstraints-spec
위에 검증 어노테이션을 들어가서 보면 굉장히 많은 어노테이션들을 볼 수 있다. 이메일 검증도 그냥 @email 요거 하나 넣으면 된다.
참고
javax.validation.constraints.NotNull
org.hibernate.validator.constraints.Range
javax.validation
으로 시작하면 특정 구현에 관계없이 제공되는 표준 인터페이스이고,
org.hibernate.validator
로 시작하면하이버네이트 validator 구현체
를 사용할 때만 제공되는 검증 기능이다.
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());
}
}
}
@Validated
만 있으면 된다.@PostMapping("/add")
public String addItem(@Validated @ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model) {
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}";
}
@Validated
어노테이션 하나로 스프링 MVC는 어떻게 Bean Validator를 사용할까?LocalValidatorFactoryBean
을 글로벌 Validator로 등록한다.LocalValidatorFactoryBean
이 global하게 등록되기 때문에 이외의 값이 global하게 등록되면 동작하지 않는다. 참고
검증시@Validated
,@Valid
둘다 사용가능하다.
javax.validation.@Valid 를 사용하려면 build.gradle 의존관계 추가가 필요하다.
implementation 'org.springframework.boot:spring-boot-starter-validation'
@Validated
는스프링 전용
검증 애노테이션이고,@Valid
는자바 표준
검증 애노테이션이다.
둘중 아무거나 사용해도 동일하게 작동하지만, @Validated 는 내부에 groups 라는 기능을 포함하고 있다.
@Data
public class Item {
private Long id;
@NotBlank(message = "호울뤼 쓑!")
private String itemName;
@NotNull(message = "holy molly")
@Range(min = 1000, max = 1_000_000)
private Integer price;
@NotNull(message = "비어있음 안 된당께!")
@Max(9999)
private Integer quantity;
public Item() {
}
public Item(String itemName, Integer price, Integer quantity) {
this.itemName = itemName;
this.price = price;
this.quantity = quantity;
}
}
Bean Validation이 기본으로 제공하는 오류 메시지를 좀 더 자세히 변경하고 싶으면 어떻게 하면 될까?
Bean Validation을 적용하고 bindingResult 에 등록된 검증 오류 코드를 보면 오류 코드가 애노테이션 이름으로 등록되는데 이는 마치 typeMismatch 와 유사하다.
NotBlank 라는 오류 코드를 기반으로 MessageCodesResolver 를 통해 다양한 메시지 코드가 순서대로 생성된
다.
@NotBlank
NotBlank.item.itemName
NotBlank.itemName
NotBlank.java.lang.String
NotBlank
@Range
Range.item.price
Range.price
Range.java.lang.Integer
Range
그리고 만약 간단하게 4레벨로 properties에 다음과 같이 넣어두었다면
NotBlank={0} no white space
Range={0}, allowed {2} ~ {1}
Max={0}, max: {1}
{0}
argument에는 필드 이름이 자동으로 들어가는 것을 확인할 수 있다.FieldError
말고 ObjectError
는 어떻게 처리할까? @ScriptAssert()
를 사용하면 된다.@Data
@ScriptAssert(lang = "javascript", script = "_this.price * _this.quantity >= 10000")
pulibc classs Item {
...
ScriptAssert.item
ScriptAssert
참고로 현재 jdk8
~ jdk14
의 JVM
상에서 사용되는 Nashorn
엔진은 javascript
를 지원하는데, jdk14
이후 버전부터는 javascript
가 지원되지 않는 GraalVM
을 사용하여 스프링 부트 3이후에는 java 17 이상을 사용하는 것이 필수조건으로 되어있기 때문에, 더는 스프링 부트 3에서는 @ScriptAssert를 이용한 자바스크립트 표현식을 사용할 수 없다.
그래서 이거 말고 따로 그냥
@PostMapping("/add")
public String addItemWithObjError(@Validated @ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model) {
// 특정 필드 예외가 아닌 전체 예외
if (item.getPrice() != null && item.getQuantity() != null) {
int resultPrice = item.getPrice() * item.getQuantity();
if (resultPrice < 10000) {
bindingResult.reject("totalPriceMin", new Object[]{10000,
resultPrice}, null);
}
}
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}";
}
오브젝트 오류
(글로벌 오류
)의 경우 @ScriptAssert
을 억지로 사용하는 것 보다는 다음과 같이 오브젝트 오류 관련 부분만 직접 자바 코드로 작성하는 것을 권장한다.bindingResult
와 .reject()
메서드, Error 객체를 배워야 사용이 가능한 부분이다. 이때 id가 null이면 안 된다고 하여 item 객체에 @Notnull
어노테이션을 무지성으로 넣어버리면 어떤 문제가 발생하냐면
아이템을 새로 등록할 때는 id가 부여되지 않았기 때문에 아예 등록을 할 수 없다.
이때 이런 문제를 해결할 수 있는 부분이 바로 groups 이다.
그런데 또 다른 방법이 있는데 form 전송용 바인딩 객체로 따로 만들어도 되긴하다.
interface
를 만든다.@Data
public class Item {
@NotNull(groups = UpdateCheck.class)
private Long id;
@NotBlank(groups = {UpdateCheck.class, SaveCheck.class})
private String itemName;
@NotNull(groups = {UpdateCheck.class, SaveCheck.class})
@Range(min = 1000, max = 1_000_000, groups = {UpdateCheck.class, SaveCheck.class})
private Integer price;
@NotNull(groups = {UpdateCheck.class, SaveCheck.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;
}
}
@Validate
어노테이션에 value 속성을 등록해준다.@PostMapping("/add")
public String addItem2(@Validated(SaveCheck.class) @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);
}
}
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}";
}
이렇게 @Validate
에 인터페이스를 값으로 넘겨줬다면 Item에 등록한 필드값에서 SaveCheck.class
가 적혀있는 필드값만 Validate가 동작한다.
참고로 앞에서 검증을 하려면 @Valid
, @Validated
어노테이션 둘 다 사용해도 된다고 했는데 이 groups
기능을 사용하려면 @Validated
을 사용해야한다.
그런데 문제는 groups를 보면 Item 객체와 로직이 복잡하고 직관이지 못하다.
그래서 사실 현업에서는 전용 객체를 따로 만들어둔다.
그럼 그냥 기존 객체를 사용하는 방법(groups)과 별도의 객체를 생성하는 방법의 장단점을 알아보자.
폼 데이터 전달에 Item 도메인 객체 사용
HTML Form -> Item -> Controller -> Item -> Repository
폼 데이터 전달을 위한 별도의 객체 사용
HTML Form -> ItemSaveForm -> Controller -> Item 생성 -> Repository
물론 추가와 수정의 form은 굉장히 많이 변할 수 있어 그냥 따로 생성하여 관리하는 것이 좋다.
이때 이름 짓는 방법은 따로 컨벤션이 존재하지 않기 때문에 ItemSaveForm
, ItemSaveRequest
, ItemSaveDto
처럼 그냥 일관성 있게만 지으면 된다.
@Data
public class ItemUpdateForm {
@NotNull
private String id;
@NotBlank
private String itemName;
@NotNull
@Range(min = 1000, max = 1_000_000)
private Integer price;
private Integer quantity;
}
@PostMapping("/{itemId}/edit")
public String edit(@PathVariable Long itemId, @Validated @ModelAttribute("item") ItemUpdateForm form, BindingResult bindingResult) {
//특정 필드 예외가 아닌 전체 예외
if (form.getPrice() != null && form.getQuantity() != null) {
int resultPrice = form.getPrice() * form.getQuantity();
if (resultPrice < 10000) {
bindingResult.reject("totalPriceMin", new Object[]{10000,
resultPrice}, null);
}
}
if (bindingResult.hasErrors()) {
log.info("errors={}", bindingResult);
return "validation/v4/editForm";
}
Item item = new Item();
item.setItemName(form.getItemName());
item.setPrice(form.getPrice());
item.setQuantity(form.getQuantity());
itemRepository.update(itemId, item);
return "redirect:/validation/v4/items/{itemId}";
}
이제 일반 Item 객체가 아닌 form 전송용 dto를 따로 만들었다. 해당 객체를 검증해야하기 때문에 @Validated
, @ModelAttribute
를 받는 전송용 객체를 넣어주면 된다.
@ModelAttribute
의 기본값은 다음에 나오는 대상 객체의 class 이름의 앞문자만 바꿔서 들어가기 때문에 html 파일을 통째로 바꿀거 아니면 item으로 바꾸어주어야한다.
결과적으로 업데이트 값이 Item 객체이기 때문에 해당 객체를 생성하여 값을 채워서 넘겨줘야한다.
여기서 검증용 form dto를 등록할 때 다시 한 번 말하지만 유용한 어노테이션들은
여기 들어가면 다 있다.
@Slf4j
@RestController
@RequestMapping("/validation/api/items")
public class ValidationItemApiController {
@PostMapping("/add")
public Object addItem(@RequestBody @Validated ItemSaveForm form, BindingResult bindingResult) {
log.info("API controller requested");
if (bindingResult.hasErrors()) {
log.info("ㄱㅓㅁ증오류 발생: errors={}", bindingResult);
return bindingResult.getAllErrors();
}
log.info("성공 로직 실행");
return form;
}
}
이러한 컨트롤러가 있다고 가정할 때 req를 날리면 어떻게 될까?
=> 로그가 아예 뜨지 않는다. 즉, 컨트롤러 자체가 호출이 안 된다. 왜그럴까?
우선 HTTP message를 보낼 때 해당 메시지 일단은 객체로 바뀌어야한다.
즉, @Validated
뒤에 명시한 ITemSaveForm객체로 바뀌어야 다음 절차로 검증을 할 수 있는데 일단 거기서 부터 입구컷 당한 것이다.
=> 400 BadRequest Error
그럼 만약 객체 생성은 되는데 검증에 실패한 경우는 어떻게 될까?
=> 컨트롤러를 들어와서 어떠한 메시지를 생성한다. 그 부분이 바로 bindingResult.getAllErrors
이 부분이다.
즉, 아래 json은 Error 객체인 것이다.
[
{
"codes": [
"Range.itemSaveForm.price",
"Range.price",
"Range.java.lang.Integer",
"Range"
],
"arguments": [
{
"codes": [
"itemSaveForm.price",
"price"
],
"arguments": null,
"defaultMessage": "price",
"code": "price"
},
1000000,
1000
],
"defaultMessage": "must be between 1000 and 1000000",
"objectName": "itemSaveForm",
"field": "price",
"rejectedValue": 1,
"bindingFailure": false,
"code": "Range"
},
{
"codes": [
"Max.itemSaveForm.quantity",
"Max.quantity",
"Max.java.lang.Integer",
"Max"
],
"arguments": [
{
"codes": [
"itemSaveForm.quantity",
"quantity"
],
"arguments": null,
"defaultMessage": "quantity",
"code": "quantity"
},
9999
],
"defaultMessage": "must be less than or equal to 9999",
"objectName": "itemSaveForm",
"field": "quantity",
"rejectedValue": 100000,
"bindingFailure": false,
"code": "Max"
}
]
HTTP 요청 파리미터를 처리하는 @ModelAttribute
는 각각의 필드 단위로 세밀하게 적용된다.
그래서 특정 필드에 타입이 맞지 않는 오류가 발생해도 나머지 필드는 정상 처리할 수 있었다.
HttpMessageConverter
는 @ModelAttribute
와 다르게 각각의 필드 단위로 적용되는 것이 아니라, 전체 객체 단위
로 적용된다.
따라서 메시지 컨버터의 작동이 성공해서 ItemSaveForm 객체를 만들어야 @Valid
, @Validated
가 적용된다.
@ModelAttribute
는 필드 단위로 정교하게 바인딩이 적용된다.
특정 필드가 바인딩 되지 않아도 나머지 필드는 정상 바인딩 되고, Validator를 사용한 검증도 적용할 수 있다.
@RequestBody
는 HttpMessageConverter
단계에서 JSON 데이터를 객체로 변경하지 못하면 이후 단계 자체가 진행되지 않고 예외가 발생한다. 컨트롤러도 호출되지 않고, Validator도 적용할 수 없다.
한마디로, HttpMessageConverter
단계에서 실패하면 예외가 발생한다.