앞서 배운 검증에서는 검증 과정을 직접 코드로 작성했다.
하지만 대부분 빈 값 여부, 크기 제한, 범위 제한 등 지극히 일반적인 로직이다.
이러한 로직은 이미 스프링이 편하게 사용할 수 있도록 준비를 다 해놨다.
그것이 Bean Validation이다.
Bean Validation 시작하기
- build.gradle에 추가
implementation 'org.springframework.boot:spring-boot-starter-validation'
그리고 Item 클래스에 다음과 같이 어노테이션을 붙여주기만 하면,
다양한 검증 로직을 쉽게 등록할 수 있다.
@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;
}
}
Bean Validation 사용하기
테스트를 통해 사용법을 알아보자.
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 = " + violation.getMessage());
}
}
}
ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
Validator validator = factory.getValidator();와 같은 방법으로 검증기를 생성해야 한다.
그리고 검증 대상(item)을 검증기에 넣고 그 반환값으로 Set을 받을 수 있다.
Set에는 ConstraintViolation이라는 검증 오류가 담기고, 만약 set이 비어있다면 검증 오류가 없는 것이다.
근데, 매번 이렇게 사용하려면 좀 불편하지 않나?라는 생각이 들 수 있다.
이를 예상하고 스프링은 모든 것을 준비해두었다!
Bean Validation 적용하기
검증하고자 하는 객체에 @Validated만 추가해주면 된다.
그럼 알아서 그 안의 필드에 있는 @NotNull 같은 어노테이션을 보고 검증을 해준다.
@PostMapping("/add")
public String addItem(@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("error = {}", 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}";
}
검증 순서는
1. @ModelAttribute: 각 필드에 타입 변환 시도
성공하면 -> 다음 단계로
실패하면 -> typeMismatch로 FieldError를 추가
2. Validator 적용 : 바인딩에 성공한 필드만 Bean Validation 적용
Bean Validation의 에러 코드
- 어노테이션 이름으로 오류 코드가 등록됨
- 예시
- @NotBlank의 경우
- NotBlank.item.itemName
- NotBlank.itemName
- NotBlank.java.lang.String
- NotBlank
- errors.properties 예시
#Bean Validation 추가
NotBlank={0} 공백X
Range={0}, {2} ~ {1} 허용
Max={0}, 최대 {1}
이때 {0}은 필드명이 들어가고, {1}부터는 각 어노테이션마다 적절한 값이 들어가게 된다.
+) 특정 필드가 아닌 글로벌 오류의 경우 @ScriptAsset()이 있지만 잘 사용하지 않으므로 생략.
Bean Validation - 상황에 따라 다른 검증 적용
방법 1. groups
방법 2. 폼 전송을 위한 별도의 모델 객체를 만들어 사용
방법 1. groups
인터페이스로 다음과 같이 그룹을 만들고,
public interface SaveCheck {
}
public interface UpdateCheck {
}
아래와 같이 groups 설정을 해주면 어노테이션 별로 별도의 검증을 수행
@Data
@ScriptAssert(lang = "javascript", script = "_this.price * _this.quantity >= 10000", message = "총 합이 10000 을 넘게 해주세요")
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=100, 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;
}
}
적용은 아래와 같이 @Validated에 (SaveCheck.class)와 같이 설정을 해주면 된다.
@PostMapping("/add")
public String addItemV2(@Validated(SaveCheck.class) @ModelAttribute Item item,
BindingResult bindingResult, RedirectAttributes redirectAttributes) {
//...
}
이 방법은 다소 복잡도가 올라가서 잘 사용하지 않는다.(어노테이션도 어지러워짐)
실무에선 아래 방법이 더 잘 사용된다.
방법 2. 폼 전송을 위한 별도의 모델 객체를 만들어 사용
아래와 같이 저장용 폼 객체, 수정용 폼 객체를 만들어준다.
@Data
public class ItemSaveForm {
@NotBlank
private String itemName;
@NotNull
@Range(min=1000, max=1000000)
private Integer price;
@NotNull
@Max(9999)
private Integer quantity;
}
@Data
public class ItemUpdateForm {
@NotNull
private Long id;
@NotBlank
private String itemName;
@NotNull
@Range(min=1000, max=1000000)
private Integer price;
//수정 시에는 자유롭게 변경 가능
private Integer quantity;
}
그리고 사용은 아래와 같이 하면 된다.
- 저장용 폼 객체 사용하기
@ModelAttribute를 적용할 객체를 Item이 아닌 ItemSaveForm으로 설정한다.
이때 @ModelAttribute에 이름을 item이라고 따로 설정해줘야 .addAttribute() 자동 저장 시에 저 이름으로 저장해서 폼을 별도로 수정하지 않고 그대로 쓸 수 있게 된다.
그리고 레포지토리에 item 객체를 저장하기 위해 item 객체를 만들어서 데이터를 받아오는 과정이 필요하다.
@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("error = {}", 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}";
}
- 수정용 폼 객체 사용하기
사용법은 마찬가지다.
@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("error = {}", bindingResult);
return "validation/v4/editForm";
}
Item itemParam = new Item();
itemParam.setItemName(form.getItemName());
itemParam.setPrice(form.getPrice());
itemParam.setQuantity(form.getQuantity());
itemRepository.update(itemId, itemParam);
return "redirect:/validation/v4/items/{itemId}";
}
Bean Validation - HTTP 메시지 컨버터
@Valid, @Validated는 HttpMessageConverter(@RequestBody)에도 적용할 수 있다.
@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("검증 오류 발생 error ={}", bindingResult);
return bindingResult.getAllErrors();
}
log.info("성공 로직 실행");
return form;
}
}
여기서 포스트맨을 활용해서 바디에 다음 json 데이터를 담아서 보내보자.
{"itemName":"hello", "price":1000, "quantity": 10}를 보내면 검증에 성공할 것이고,
{"itemName":"hello", "price":"A", "quantity": 10}와 같이 보내면 검증에 실패할 것이다.
위의 경우는 정확히 하자면 Item객체 자체가 생성되지 못하기 때문에 컨트롤러 호출 전 오류가 발생하는 것이다.
그래서 Bad Request 에러가 발생한다.
{"itemName":"hello", "price":1000, "quantity": 10000}를 보내면 HttpMessageConverter는 성공하지만 검증 단계에서 실패하게 된다. 그래서 결과로 bindingResult.getAllError()를 날려줄 것이다.
@ModelAttribute vs @RequestBody 차이점
HTTP 요청 파리미터를 처리하는 @ModelAttribute 는 각각의 필드 단위로 세밀하게 적용된다. 그래서
특정 필드에 타입이 맞지 않는 오류가 발생해도 나머지 필드는 정상 처리할 수 있었다.
HttpMessageConverter 는 @ModelAttribute 와 다르게 각각의 필드 단위로 적용되는 것이 아니라,
전체 객체 단위로 적용된다.
따라서 메시지 컨버터의 작동이 성공해서 Item 객체를 만들어야 @Valid , @Validated 가 적용된다.
- @ModelAttribute 는 필드 단위로 정교하게 바인딩이 적용된다. 특정 필드가 바인딩 되지 않아도 나머지
필드는 정상 바인딩 되고, Validator를 사용한 검증도 적용할 수 있다.
- @RequestBody 는 HttpMessageConverter 단계에서 JSON 데이터를 객체로 변경하지 못하면 이후
단계 자체가 진행되지 않고 예외가 발생한다. 컨트롤러도 호출되지 않고, Validator도 적용할 수 없다.
----------------------------------------------
참고 : 인프런 김영한님 강의(스프링 MVC 2편 - 백엔드 웹 개발 활용 기술)
댓글