스프링 빈 검증
검증 로직을 만드는 일은 반복적이고 귀찮은 일이다. 특히 특정 필드에 대한 검증 로직은 널 값인지, 특정 범위를 넘어서는지 등 거의 정해져 있다. Bean Validation을 사용한다면 애노테이션으로 검증 로직을 대체할 수 있다.
Bean Validation이란 자바 빈 객체를 검증하는 표준 기술이며 검증 애노테이션과 여러 인터페이스를 정의하고 있다. Bean Validation을 구현한 구현체중 일반적으로 하이버네이트 Validator을 많이 사용한다.
공식 사이트 : The Bean Validation reference implementation. - Hibernate Validator
스프링 빈 검증 사용
Bean Validation을 사용하려면 의존 관계를 추가하자. build.gradle 파일에 아래의 의존을 추가하면 하이버네이트 Vaildator 라이브러리가 추가된다.
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;
}
}
그 후 컨트롤러에서 검증할 객체에 @Validated 애노테이션을 붙이면 된다.
@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}";
}
스프링 부트에 spring-boot-starter-validation 의존을 추가하면 자동으로 Bean Validator을 스프링에 통합시킨다. 즉 스프링 부트는 자동으로 LocalValidatorFactoryBean을 글로벌 Validator로 등록한다. 이 글로벌 Validator는 @NotNull 등의 애노테이션을 보고 검증을 수행하며 만약 검증 오류가 발생하면 FieldError, ObjectError 객체를 생성해 BindingResult에 담아준다.
컨트롤러에 @Validator 애노테이션 적용 시 자동 등록된 글로벌 Validator가 동작함을 알 수 있으며 프로그래머는 검증 로직과 검증 객체를 구현할 필요 없이 쉽게 애노테이션으로 검증 로직을 만들 수 있게 된다.
글로벌 오류(ObjectError)
단순한 필드 오류(FieldError)는 Bean Validation으로 검출할 수 있었다. 그러면 오브젝트 관련 오류(ObjectError)는 어떻게 검출할까? Bean Validation의 @ScriptAssert을 사용해도 되지만 검증이 해당 객체의 범위를 넘어서는 경우가 많기에 억지로 @ScriptAssert을 적용하기보다는 자바 코드로 오브젝트 관련 오류를 검출하는 것이 좋다.
@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}";
}
Form 전송 객체 분리
위에서 만든 어플리케이션에서 상품 등록과 상품 수정 폼의 입력 정보를 Item이라는 도메인 객체가 받으며 Bean Validator을 적용해 검증을 수행하고 있다. 하지만 이 방식에는 3가지 문제가 있다.
- 상품 등록 입력과 상품 수정 입력의 검증은 달라야한다. 하지만 두 개 입력을 같은 클래스의 객체로 받고 있으므로 Bean Validator 적용 시 검증 로직을 다르게 만들 수 없다.
- 입력 정보는 사용자 동의 등의 여러 부가적인 정보도 포함한다. Item 도메인 객체로 부가 정보를 받을 순 없다.
- 상품 입력 정보와 상품 수정 정보는 서로 다를 확률이 높다.
실무에서는 주로 위 문제를 해결하기위해 입력 정보를 저장하고 컨트롤러까지 전달할 별도의 객체를 따로 만든다. 즉 상품 등록 입력 정보를 저장하는 객체와 상품 수정 입력 정보를 저장하는 객체를 따로 만든다. 간단한 프로젝트의 경우 입력 정보를 바로 도메인 객체로 바인딩할 수 있지만 실무에서 입력 정보는 사용자 동의 등의 여러 부가적인 정보도 포함하므로 따로 입력 정보를 저장하는 객체를 만드는 것이 좋으며 등록과 수정 입력도 서로 달라질 확률이 높기에 애초에 따로 만드는 것이 좋다.
상품 등록 입력을 받을 객체(ItemSaveForm), 상품 수정 입력을 받을 객체(ItemUpdateForm)을 만들자. 이때 이름은 ItemSaveForm, ItemSaveRequest , ItemSaveDto 등 의미있게 만들고 중요한건 일관성있게 사용하는 것이다. 흐름은 아래와 같다.
HTML Form -> ItemSaveForm -> Controller -> Item 생성 -> Repository
ItemSaveForm
package hello.itemservice.web.validation.form;
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 ItemSaveForm {
@NotBlank
private String itemName;
@NotNull
@Range(min = 1000, max = 1000000)
private Integer price;
@NotNull
@Max(value = 9999)
private Integer quantity;
}
ItemUpdateForm
package hello.itemservice.web.validation.form;
import lombok.Data;
import org.hibernate.validator.constraints.Range;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
@Data
public class ItemUpdateForm {
@NotNull
private Long id;
@NotBlank
private String itemName;
@NotNull
@Range(min = 1000, max = 1000000)
private Integer price;
//수정에서는 수량은 자유롭게 변경할 수 있다.
private Integer quantity;
}
ValidationItemControllerV4 - addForm
@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}";
}
ValidationItemControllerV4 - editForm
@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}";
}
@ModelAttribute, @RequestBody 객체 바인딩
@ModelAttribute 적용시 요청 파라미터로부터 객체로 바인딩하는 메시지 컨버터는 스프링에서 제공한다. 따라서 바인딩 오류 발생 시 스프링이 바인딩 오류를 BindingResult에 담아주며 컨트롤러가 정상적으로 호출된다.
@RequestBody 적용시 JSON 메시지로부터 객체로 바인딩하는 메시지 컨버터는 jackson2 외부 라이브러리를 사용한다. 따라서 바인딩 오류 발생 시 예외가 발생한다. (컨트롤러가 호출되지 않는다.)
여기서 말하고 싶은 바는 두 개 애노테이션에서 바인딩 오류 발생 시 처리 흐름이 달라지는 것도 있지만 객체 바인딩이 완료된 객체에 Bean Validation이 적용된다는 것이다. Bean Validation은 객체의 바인딩 오류를 검출하는 것이 아닌 바인딩이 완료된 객체의 값 검증 오류를 검출한다.
'Spring > Spring MVC' 카테고리의 다른 글
ArgumentResolver 활용 (0) | 2023.04.17 |
---|---|
스프링 Filter, Interceptor (0) | 2023.04.14 |
스프링 검증 (1) (0) | 2023.03.27 |
스프링 MVC 기본 기능 (0) | 2023.02.28 |
스프링 MVC (2) (0) | 2023.02.21 |