Bean Validation
Bean Validation 이란 ?
Bean Validation 2.0이라는 기술 표준이다.
검증 애노테이션과 여러 인터페이스의 모음이다.
Bean Validation을 사용하려면 의존관계를 추가해야 한다.
implementation 'org.springframework.boot:spring-boot-starter-validation'
package hello.itemservice.domain.item;
import lombok.Data;
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 = 1000000)
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;
}
}
검증 애노테이션
@NotBlank : 빈값 + 공백만 있는 경우를 허용하지 않는다.
@NotNull : null을 허용하지 않는다.
@Range(min = 1000, max = 1000000) : 범위 안의 값이어야 한다.
@Max(9999) : 최대 9999까지만 허용한다.
Bean Validation - 스프링 적용
package hello.itemservice.web.validation;
import hello.itemservice.domain.item.Item;
import hello.itemservice.domain.item.ItemRepository;
import hello.itemservice.domain.item.SaveCheck;
import hello.itemservice.domain.item.UpdateCheck;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.util.StringUtils;
import org.springframework.validation.BindingResult;
import org.springframework.validation.FieldError;
import org.springframework.validation.ObjectError;
import org.springframework.validation.ValidationUtils;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.WebDataBinder;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
import java.util.List;
@Slf4j
@Controller
@RequestMapping("/validation/v3/items")
@RequiredArgsConstructor
public class ValidationItemControllerV3 {
private final ItemRepository itemRepository;
@GetMapping
public String items(Model model) {
List<Item> items = itemRepository.findAll();
model.addAttribute("items", items);
return "validation/v3/items";
}
@GetMapping("/{itemId}")
public String item(@PathVariable long itemId, Model model) {
Item item = itemRepository.findById(itemId);
model.addAttribute("item", item);
return "validation/v3/item";
}
@GetMapping("/add")
public String addForm(Model model) {
model.addAttribute("item", new Item());
return "validation/v3/addForm";
}
// @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);
}
}
//검증에 실패하면 다시 입력 폼으로
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}";
}
@PostMapping("/add")
public String addItemV2(@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}";
}
@GetMapping("/{itemId}/edit")
public String editForm(@PathVariable Long itemId, Model model) {
Item item = itemRepository.findById(itemId);
model.addAttribute("item", item);
return "validation/v3/editForm";
}
@PostMapping("/{itemId}/edit")
public String edit(@PathVariable Long itemId,@Validated(UpdateCheck.class) @ModelAttribute Item item, BindingResult bindingResult) {
//특정 필드가 아닌 복합 룰 검증
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/editForm";
}
itemRepository.update(itemId, item);
return "redirect:/validation/v3/items/{itemId}";
}
}
스프링 부트는 자동으로 글로벌 Validator로 등록한다.
LocalValidatorFactoryBean 을 글로벌 Validator로 등록한다. 이 Validator는 @NotNull 같은 애노테이션을 보고
검증을 수행한다. 이렇게 글로벌 Validator가 적용되어 있기 때문에, @Valid , @Validated만 적용하면 된다.
검증 오류가 발생하면 FieldError , ObjectError를 생성해서 BindingResult에 담아준다.
Bean Validation - 에러 코드
BeanValidation이 기본으로 제공하는 오류 메시지를 좀 더 자세히 변경하고 싶은 경우
Bean Validation을 적용하고 bindingResult에 등록된 검증 오류 코드롤 보면
오류코드가 애노테이션 이름으로 등록된다. 마치 typeMismatch와 유사하다.
@NotBlank
NotBlank.item.itemName
NotBlank.itemName
NotBlank.java.lang.String
NotBlank
@Range
Range.item.price
Range.price
Range.java.lang.Integer
Range
메시지 등록
errors.properties
#Bean Validation 추가
NotBlank={0} 공백X
Range={0}, {2} ~ {1} 허용
Max={0}, 최대 {1}
실행해보면 등록한 메시지가 적용되는 것을 볼 수 있다.
BeanValidation 메시지 찾는 순서
생성된 메시지 코드 순서대로 messageSource 에서 메시지 찾기
애노테이션 message 속성 사용 -> @NotBlank
라이브러리가 제공하는 기본 값 사용 -> 널이어서는 안됩니다.
애노테이션 message 사용 예
@NotBlank(message = "공백은 입력할 수 없습니다.")
private String itemName;
Bean Validation - 오브젝트 오류
Bean Validation에서 특정 필드(FieldError)가 아닌 해당 오브젝트 관련 오류(ObjectError)는 어떻게 처리 할까?
@ScriptAssert()를 사용하면 된다.
@Data
@ScriptAssert(lang = "javascript", script = "_this.price * _this.quantity >= 10000", message = "총합이 10000원 넘게 입력해주세요.")
public class Item {
//...
}
실제 사용해보면 제약이 많고 복잡하다.
실무에서는 검증 기능이 해당 객체의 범위를 넘어서는 경우들도 종종 등장하는데, 그런 경우 대응이 어렵다.
따라서 오브젝트 오류의 경우 @ScriptAssert를 억지로 사용하는 것 보다는 직접 자바 코드로 작성하는 것이 좋다.
Bean Validation - 한계
데이터를 등록할 때 와 수정할 때 요구사항이 다를 수 있다.
예를 들면 등록시에는 수량을 최대 9999까지 등록할 수 있지만 수정시에는 수량을 무제한으로 변경할 수 있다.
등록시에는 id 값이 없어도 되지만, 수정시에는 id 값이 필수이다.
@Data
public class Item {
@NotNull
private Long id;
//...
}
이렇게 id에 @NotNull을 추가한다면 등록시에는 id값이 없으므로 에러가 날 것이다.
이러한 문제를 해결하는 방법으로 2가지가 있다.
1. BeanValidation의 groups 기능을 사용한다.
2. Item을 직접 사용하지 않고 , ItemSaveForm , ItemUpdateForm 같은 폼전송을 위한 별도의 모델객체를 만들어서
사용한다.
BeanValidation groups 기능 사용
저장용 groups 생성
package hello.itemservice.domain.item;
public interface SaveCheck {
}
수정용groups 생성
package hello.itemservice.domain.item;
public interface UpdateCheck {
}
Item - groups 적용
package hello.itemservice.domain.item;
import lombok.Data;
import org.hibernate.validator.constraints.Range;
import org.hibernate.validator.constraints.ScriptAssert;
import javax.validation.constraints.Max;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
@Data
public class Item {
@NotNull
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;
}
}
컨트롤러에 groups 적용
@PostMapping("/add")
public String addItemV2(@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}";
}
@Valid에는 groups를 적용할 수 있느 기능이 없다. groups를 사용하려면 @Validated를 사용해야 한다.
Form 전송 객체 분리 - 프로젝트 준비 V4
실무에서는 groups를 잘 사용하지 않는데, 그 이유가 다른 곳에 있다.
등록시 폼에서 전달하는 데이터가 Item 도메인 객체와 딱 맞지 않기 때문이다.
보통 Item을 직접 전달받는 것이 아니라, 복잡한 폼의 데이터를 컨트롤러까지 전달할 별도의 객체를 만들어서 전달한다.
예를 들면 ItemSaveForm이라는 폼을 전달받는 전용 객체를 만들어서 @ModelAttribute로 사용한다.
이것을 통해 컨트롤러에서 폼 데이터를 전달 받고, 이후 컨트롤러에서 필요한 데이터를 사용해서 Item을 생성한다.
package hello.itemservice.web.validation.form;
import lombok.Data;
import org.hibernate.validator.constraints.Range;
import javax.validation.constraints.Max;
import javax.validation.constraints.NotNull;
@Data
public class ItemSaveForm {
@NotNull
private String itemName;
@NotNull
@Range(min = 1000, max = 1000000)
private Integer price;
@NotNull
@Max(value = 9999)
private Integer quantity;
}
package hello.itemservice.web.validation.form;
import lombok.Data;
import org.hibernate.validator.constraints.Range;
import javax.validation.constraints.Max;
import javax.validation.constraints.NotNull;
@Data
public class ItemUpdateForm {
@NotNull
private Long id;
@NotNull
private String itemName;
@NotNull
@Range(min = 1000, max = 1000000)
private Integer price;
//수정시에는 자유롭게 변경할 수 있다.
private Integer quantity;
}
@PostMapping("/add")
public String addItem(@Validated @ModelAttribute("item") ItemSaveForm form, BindingResult bindingResult,
RedirectAttributes redirectAttributes) {
//특정 필드가 아닌 복합 룰 검증
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/addForm";
}
//성공 로직
Item item = new Item();
item.setItemName(form.getItemName());
item.setPrice(form.getPrice());
item.setQuantity(form.getQuantity());
Item savedItem = itemRepository.save(item);
redirectAttributes.addAttribute("itemId", savedItem.getId());
redirectAttributes.addAttribute("status", true);
return "redirect:/validation/v4/items/{itemId}";
}
Bean Validation - HTTP 메시지 컨버터
@Valid , @Validated는 HttpMessageConverter에도 적용할 수 있다.
package hello.itemservice.web.validation;
import hello.itemservice.web.validation.form.ItemSaveForm;
import lombok.extern.slf4j.Slf4j;
import org.springframework.validation.BindingResult;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@Slf4j
@RestController
@RequestMapping("/validation/api/items")
public class ValidationItemApiController {
@PostMapping("/add")
public Object addItem(@RequestBody @Validated ItemSaveForm form, BindingResult bindingResult) {
log.info("API 컨트롤러 호출");
if(bindingResult.hasErrors()){
log.info("검증 오류 발생 errors={}", bindingResult);
return bindingResult.getAllErrors();
}
log.info("성공 로직 실행");
return form;
}
}
Postman을 사용해서 테스트 해봤다.
HttpMessageConverter 에서 요청 JSON을 Item 객체로 생성하는데 실패한다.
이 경우는 Item 객체를 만들지 못하기 때문에 컨트롤러 자체가 호출되지 않고 그 전에 예외가 발생한다.
Validator도 실행되지 않는다.
return bindingResult.getAllErrors();는 ObjectError 와 FieldError를 반환한다.
스프링이 이 객체를 JSON으로 변환해서 클라이언트에 전달했다.
출처
https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81-mvc-2
'SPRING' 카테고리의 다른 글
김영한 - 스프링 MVC 2편 - 백엔드 웹 개발 핵심 기술 7일차 (0) | 2022.01.20 |
---|---|
김영한 - 스프링 MVC 2편 - 백엔드 웹 개발 핵심 기술 6일차 (0) | 2022.01.18 |
김영한 - 스프링 MVC 2편 - 백엔드 웹 개발 핵심 기술 4일차 (0) | 2022.01.14 |
김영한 - 스프링 MVC 2편 - 백엔드 웹 개발 핵심 기술 3일차 (0) | 2022.01.12 |
김영한 - 스프링 MVC 2편 - 백엔드 웹 개발 핵심 기술 2일차 (0) | 2022.01.11 |