전과 같이 검증 기능을 매번 코드로 작성하는 것은 상당히 번거롭다.
특히 특정 필드에 대한 검증 로직은 대부분 빈 값, 크기와 같이 매우 일반적인 로직이다.
검증 로직을 모든 프로젝트에 적용할 수 있게 공통화하고, 표준화한 것이 바로 Bean Validation이다.
먼저 스프링과 통합하지 않고, 순수한 Bean Validation 사용법부터 테스트 코드로 알아보자.
build.gradle에 의존 관계 추가 후,
테스트 코드를 작성해본다.
Item 클래스 작성
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(value = 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까지만 허용한다.
BeanValidationTest - Bean Validation 테스트 코드 작성
package hello.itemservice.validation;
import hello.itemservice.domain.item.Item;
import org.junit.jupiter.api.Test;
import javax.validation.ConstraintViolation;
import javax.validation.Validation;
import javax.validation.Validator;
import javax.validation.ValidatorFactory;
import java.util.Set;
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());
}
}
}
1. 검증기 생성
2. 검증 실행
2-1. 검증 대상(item)을 직접 검증기에 넣고 결과를 받는다.
2-2. Set에는 ConstraintViolation이라는 검증 오류가 담긴다.
이렇게 빈 검증기를 직접 사용하는 방법을 알아보았다. 스프링은 빈 검증기를 스프링에 완전히 통합해두었다.
이제 ValidationItemControllerV3를 만들어 Bean Validation을 스프링에 적용해보자.
스프링 MVC가 Bean Validator를 사용하는 방법?
스프링 부트가 spring-boot-starter-validation 라이브러리를 넣으면 자동으로 Bean Validator를 인지하고 스프링에 통합한다.
스프링 부트는 자동으로 글로벌 Validator로 등록한다.
우리는 @Valid, @Validated만 적용하면 된다. 검증 오류가 발생하면 BindingResult에 담아준다.
검증 순서
1. @ModelAttribute 각각의 필드에 타입 변환 시도
1-1. 성공하면 다음으로
1-2. 실패하면 typeMismatch로 FieldError 추가
2. Validator 적용
=> 타입이 맞는지 확인 후, 검증기에 넣는다.
에러 코드
오류 메시지를 좀 더 자세히 변경하고 싶으면 어떻게 해야 할까?
bindingResult에 오류 코드가 애노테이션 이름으로 등록된다. 이 오류 코드를 기반으로 MessageCodesResolver를 통해 다양한 메시지 코드가 순서대로 생성된다.
에러 코드별로 errors.properties에 추가하면 된다.
#Bean Validation 추가
NotBlank={0} 공백X
Range={0}, {2} ~ {1} 허용
Max={0}, 최대 {1}
{0}은 필드명, {1}, {2}...은 각 애노테이션마다 다르다.
필드 오류가 아닌 오브젝트 오류인 경우?
@Data
@ScriptAssert(lang = "javascript", script = "_this.price * _this.quantity >= 10000")
public class Item {
//...
}
@ScriptAssert()를 사용해도 되지만, 실무에서는 복잡하고 제약이 많기 때문에 거의 쓰지 않는다.
그래서 다음과 같이 오브젝트 오류 관련 부분만 직접 자바 코드로 작성하는 것을 권장한다.
@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("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 삭제 후 new Object[]를 추가해주었다.
이제 검증 로직을 수정 폼에도 적용한다.
수정시 요구사항으로는,
1. 수량을 무제한으로 변경 가능
2. id값 필수
가 있다.
수정 요구사항을 적용하기 위해,
1. quantity: @Max(9999) 제거,
2. id: @NotNull 추가
로 수정했다.
문제점
현재 구조에서는 수정시 item의 id값은 항상 들어있도록 로직이 구성되어있다. 애플리케이션을 실행해보면 수정은 잘 동작하나, 등록에서는 문제가 발생한다. 등록시에는 id에 값도 없고, quantity 수량 제한 값인 9999도 적용되지 않는 문제가 발생한다.
결과적으로 item은 등록과 수정에서 검증 조건의 충돌이 발생해 같은 Bean Validation을 적용할 수 없다.
동일한 모델 객체를 등록, 수정할 때 다르게 검증하는 방법
1. BeanValidation의 groups 기능 사용
2. Item을 사용하지 않고, ItemSaveForm, ItemUpdateForm 같은 폼 전송을 위한 별도의 모델 객체 생성 후 사용
먼저, '1. BeanValidation의 groups 기능 사용' 에 대해 알아보자.
등록에 검증할지, 수정에 검증할지 group을 적용할 수 있는 기능이다.
등록용 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 javax.validation.constraints.Max;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
@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;
}
}
ValidationItemControllerV3 - 저장 로직에 SaveCheck 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}";
}
@Validated(SaveCheck.class) 적용
수정 로직도 마찬가지로 적용해준다.
다 적용해보니, 복잡도가 올라갔다.
사실 groups 기능은 실제로 잘 사용하지는 않는데, 주로 '2. 폼 전송을 위한 객체를 분리해서 사용'하기 때문이다.
<Form 전송 객체 분리>
Item 원복
이제 Item의 검증은 사용하지 않는다.
@Data
public class Item {
private Long id;
private String itemName;
private Integer price;
private Integer quantity;
}
ItemSaveForm - 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.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 - 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.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
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 hello.itemservice.web.validation.form.ItemSaveForm;
import hello.itemservice.web.validation.form.ItemUpdateForm;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
import java.util.List;
@Slf4j
@Controller
@RequestMapping("/validation/v4/items")
@RequiredArgsConstructor
public class ValidationItemControllerV4 {
private final ItemRepository itemRepository;
//...
@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}";
}
@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 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}";
}
}
1. 폼 객체 바인딩
@PostMapping("/add")
public String addItem(@Validated @ModelAttribute("item") ItemSaveForm form, BindingResult bindingResult, RedirectAttributes redirectAttributes) {
//...
}
Item 대신 ItemSaveForm을 전달받는다. @Validated로 검증도 수행하고, BindingResult로 검증 결과도 받는다.
**주의**
`@ModelAttribute("item")` 에 `item` 이름을 넣어준 부분을 주의하자. 이것을 넣지 않으면 `ItemSaveForm` 의 경우, 규칙에 의해 `itemSaveForm` 이라는 이름으로 MVC Model에 담기게 된다. 이렇게 되면 뷰 템플릿에서 접근하는 `th:object` 이름도 함께 변경해주어야 한다.
2. 폼 객체를 Item으로 변환
//성공 로직
Item item = new Item();
item.setItemName(form.getItemName());
item.setPrice(form.getPrice());
item.setQuantity(form.getQuantity());
Item savedItem = itemRepository.save(item);
폼 객체를 이용해 Item 객체를 생성한다.
수정(edit())도 같은 방식으로 수정해주었다.
이렇게 Form 객체를 따로 만들어 검증하는 방법이 가장 편한 것 같다.
'공부 > Spring' 카테고리의 다른 글
[Spring MVC](12) 로그인 처리2 - 필터, 인터셉터 (0) | 2024.02.20 |
---|---|
[Spring MVC](11) 로그인 처리1 - 쿠키 & 세션 (0) | 2024.02.17 |
[Spring MVC](9) 검증(Validation) (2) | 2024.01.28 |
[Spring MVC](8) 웹 페이지 만들기 (0) | 2024.01.22 |
[Spring MVC](7) 기본 기능 - 요청 데이터 2, 응답 (1) | 2024.01.22 |