Bean Validation 사용 전
Item (사용 전)
@Data
public class Item {
private Long id;
private String itemName;
private Integer price;
private Integer quantity;
public Item() {
}
public Item(String itemName, Integer price, Integer quantity) {
this.itemName = itemName;
this.price = price;
this.quantity = quantity;
}
}
만약 위와 같은 Item 객체가 있을 때 클라이언트에서 새로운 Item을 등록하면서
가격(price)을 문자열로 입력한다면 현재 Item의 price 필드는 Integer 타입이므로 넘어온 문자열 데이터를
price 필드에 바인딩 할 수 없습니다.
이런 상황을 방지하기 위해 서버는 클라이언트가 보내는 데이터를 Validation(검증) 할 필요가 있습니다.
ItemValidator
import org.springframework.validation.Errors;
import org.springframework.validation.Validator;
@Component
public class ItemValidator implements Validator {
@Override
public boolean supports(Class<?> clazz) {
return Item.class.isAssignableFrom(clazz);
}
@Override
public void validate(Object target, Errors errors) {
Item item = (Item) target;
// 검증 로직
if (!StringUtils.hasText(item.getItemName())) {
errors.rejectValue("itemName", "required");
}
if (item.getPrice() == null || item.getPrice() < 1000 || item.getPrice() > 10000000) {
errors.rejectValue("price", "range", new Object[]{1000,10000000},null);
}
if (item.getQuantity() == null || item.getQuantity() > 9999) {
errors.rejectValue("quantity", "max", new Object[]{9999}, null);
}
// 특정 필드가 아닌 복합 룰 검증
if (item.getPrice() != null && item.getQuantity() != null) {
int resultPrice = item.getPrice() * item.getQuantity();
if (resultPrice < 1000) {
errors.reject("totalPriceMin", new Object[]{10000, resultPrice}, null);
}
}
}
}
Validator를 구현하는 클래스를 작성해야 합니다. 이때 supports 메서드는 파라미터로 넘어온 클래스가 Item 클래스 혹은 그 자식클래스 인지를 검사합니다. 만약 supports에서 true가 반환되면 validate 메서드가 실행됩니다.
ValidationItemController (사용 전)
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.WebDataBinder;
import org.springframework.web.bind.annotation.*;
@Controller
public class ValidationItemController{
private final ItemRepository itemRepository;
private final ItemValidator itemValidator;
@InitBinder
public void init(WebDataBinder dataBinder) {
dataBinder.addValidators(itemValidator);
}
@PostMapping("/add")
public String addItem(@Validated @ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes) {
if (bindingResult.hasErrors()) {
log.info("bindingResult = {}", bindingResult);
return "validation/addForm";
}
// 성공 로직
Item savedItem = itemRepository.save(item);
redirectAttributes.addAttribute("itemId", savedItem.getId());
redirectAttributes.addAttribute("status", true);
return "redirect:/validation/items/{itemId}";
}
}
Validation Bean을 사용하지 않고 데이터를 검증하려면 위의 예제코드와 같이 Validator를 구현하는 ItemValidator와 같은 클래스를 만들고, Controller에서 @InitBinder 애노테이션과 함께 ItemValidator를 WebDataBinder에 추가하면 해당 컨트롤러에 들어오는 요청이 ItemValidator를 통해 검증됩니다.
이때 위의 addItem 메서드의 파라미터처럼 검증할 대상(@ModelAttribute Item item) 앞에 @Validated 혹은 @Valid 애노테이션을 붙여줘야 합니다. 그래야만 검증대상에 대한 Validator가 동작하기 때문입니다.
또한 검증할 대상(@ModelAttribute Item item) 뒤에 BindingResult 가 위치해야 합니다. BindingResult는 @ModelAttribute에 대한 바인딩 및 검증 결과를 저장합니다. 따라서 BindingResult가 @ModelAttribute 바로 뒤에 위치해야, Spring이 바인딩 결과를 올바르게 매핑할 수 있습니다.
이와 같이 Bean Validation을 적용하지 않고 검증을 하는것은 Validator의 구현체클래스를 작성하고, 검증할 컨트롤러마다 WebDataBinder에 검증기를 추가해야 하는 비교적 복잡하고 번거로운 과정을 거쳐야 합니다.
Bean Validation 사용 후
Bean Validation이란?
Bean Validation은 특정 구현체가 아닌 Bean Validation2.0(JSR-380)이라는 기술 표준입니다.
따라서 여러 구현체가 있습니다. 그중 스프링은 spring-boot-starter-validation 의존관계를 추가하면
자동으로 hibernate-validator 구현체 라이브러리를 받아옵니다.
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;
}
}
ValidationItemController (사용 후)
import org.springframework.validation.BindingResult;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
@Controller
@RequiredArgsConstructor
public class ValidationItemController {
private final ItemRepository itemRepository;
public String addItem(@Validated @ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes) {
if (bindingResult.hasErrors()) {
log.info("bindingResult = {}", bindingResult);
return "validation/addForm";
}
// 성공 로직
Item savedItem = itemRepository.save(item);
redirectAttributes.addAttribute("itemId", savedItem.getId());
redirectAttributes.addAttribute("status", true);
return "redirect:/validation/items/{itemId}";
}
}
Bean Validation을 적용하기 위해서는 각각의 필드에 @NotBlank, @NotNull, @Range, @Max 등과 같은 애노테이션을
사용하여 각 필드별 검증을 설정해야 합니다.(더 많은 hibernate-validator 애노테이션이 궁금하시다면 아래의 참고를 보시면 됩니다.)
위의 예제 코드에서 쓰인 애노테이션의 의미는 아래와 같습니다.
@NotBlank : 빈값 + 공백만 있는 경우를 허용하지 않는다.
@NotNull : null 을 허용하지 않는다.
@Range(min = 1000, max = 1000000) : 범위 안의 값이어야 한다.
@Max(9999)` : 최대 9999까지만 허용한다.
Controller의 코드는 변하지 않았지만 Validator의 구현클래스를 작성하고, 검증할 컨트롤러마다 WebDataBinder에 검증기를 추가하는 코드의 작성 없이 동일하게 필드값을 검증할 수 있게 되었습니다. 따라서 코드의 가독성과 유지보수성이 좋아지게 되었습니다.
ObjectError 검증하기
FieldError는 말그대로 특정 필드에 대한 값을 검증오류를 말합니다.
앞서 살펴본 방식은 모두 특정 필드(itemName, price, quantity)의 에러를 Bean Validation을 이용하여 검증하는 것이었습니다.
ObjectError란 객체 자체의 검증오류를 말합니다.
예를 들어 "price와 quantity의 곱이 10000원 이상이어야 한다"와 같이 여러 필드의 값을 조합하는 경우를 말합니다.
FiledError의 경우 애노테이션을 활용하여 검증하는 것을 권장합니다.
ObjectError의 경우는 다음과 같은 2가지 방식으로 검증할 수 있습니다.
ObjectError 검증 첫번째 방법 - @ScriptAssert() 사용
Item (@ScriptAssert 적용)
@Data
@ScriptAssert(lang = "javascript", script = "_this.price * _this.quantity >= 10000")
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;
}
}
기존의 Item 클래스에서 @ScriptAssert 애노테이션을 적용하여 ObjectError를 검증할 수 있습니다.
하지만 위의 방식은 기능이 제한적이고, 예제처럼 단순한 ObjectError에는 사용하기 편리하나 복잡한 ObjectError에는 대응하기 어렵다는 단점이 있기에 아래의 두 번째 방식을 더 권장합니다.
ObjectError 검증 두번째 방법 - 직접 자바 코드 작성 (권장)
ValidationItemController (글로벌 오류 추가)
@PostMapping("/add")
public String addItem(@Validated @ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes) {
// ObjectError 검증 추가
if (item.getPrice() != null && item.getQuantity() != null) {
int resultPrice = item.getPrice() * item.getQuantity();
if (resultPrice < 10000) {
bindingResult.reject("totalPriceMain", new Object[]{10000, resultPrice}, null);
}
}
// 검증오류 발생
if (bindingResult.hasErrors()) {
log.info("bindingResult = {}", bindingResult);
return "validation/addForm";
}
// 성공 로직
Item savedItem = itemRepository.save(item);
redirectAttributes.addAttribute("itemId", savedItem.getId());
redirectAttributes.addAttribute("status", true);
return "redirect:/validation/items/{itemId}";
}
위와 같이 Controller에서 ObjectError에 관한 검증을 직접 자바코드를 작성하여 적용할 수 있습니다.
첫 번째 방식에 비해 더 넓은 범위의 다양한 ObjectError에 대응할 수 있기 때문에 이 방식을 권장합니다.
Bean Validation의 한계
Bean Validation을 통해 편리하게 FiledError와 ObjectError를 검증할 수 있게 되었지만
이런 Bean Validation에도 아래와 같은 한계가 있습니다.
예를 들어 상품을 등록하는 경우, 상품정보를 수정하는 경우
이러한 2가지의 시나리오가 있다고 할 때 모두 동일한 Item클래스(Validator 애노테이션이 적용된 클래스)를 사용한다면
등록과 수정 모두에서 각각의 필드에 같은 검증을 하게 됩니다.
상품을 등록하는 경우 만약 DB에서 auto_increment를 이용해 id값이 자동으로 하나씩 증가되도록 설계되어있다면
등록을 할 때는 상품의 id는 필요하지도 않을뿐더러 DB에 등록되기 전까지는 그 값을 알 수 없기 때문에 클라이언트가 서버에 상품 id 값을 넘길 수 없습니다.
상품정보를 수정의 경우 어떤 상품을 수정할 것인지 확인하기 위해 상품 id가 필요하기 때문에 클라이언트는 서버에게 수정하고자 하는 상품의 id값을 넘겨줘야 합니다.
이렇듯 각각 다른 상황에서 동일한 방식으로 검증할 경우 위와 같은 문제가 발생할 수 있습니다.
이런 문제를 해결하기 위해서는 아래의 2가지 방법을 사용할 수 있습니다.
해결 첫 번째 방법 - groups 사용
상품 등록용 groups 생성
public interface SaveCheck {
}
상품 수정용 groups 생성
public interface UpdateCheck {
}
Item (groups 적용)
@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;
}
}
ValidationController의 addItem메서드
@PostMapping("/add")
public String addItem(@Validated(SaveCheck.class) @ModelAttribute Item item,
BindingResult bindingResult, RedirectAttributes redirectAttributes) {
//...
}
ValidationController 의 editItem메서드
@PostMapping("/{itemId}/edit")
public String editItem(@PathVariable Long itemId, @Validated(UpdateCheck.class)
@ModelAttribute Item item, BindingResult bindingResult) {
//...
}
위와 같이 먼저 사용할 각각의 그룹을 interface로 생성해 둔 뒤,
Item의 필드 각각 마다 사용할 검증을 groups 속성으로 지정할 수 있습니다.
만약 상품의 등록과 수정 모두에서 사용하려면 groups = {SaveCheck.class, UpdateCheck.class} 와같이 지정할 수 있습니다.
등록의 경우에만 사용한다면 groups = SaveCheck.class 와 같이 지정할 수 있습니다.
이후 Controller에서 각각의 메서드 별로 @Validated 애노테이션의 속성값으로 사용할 group 클래스를 지정합니다.
(이때 @Valid와 @Validated의 차이점에 대해 알아야 합니다. @Valid는 자바의 표준 검증 애노테이션이고, @Validated는 스프링이
제공하는 검증 애노테이션입니다. 위와 같은 groups를 사용하는 것은 @Validated에서만 제공하는 기능이므로 @Valid가 아닌 @Validated를 사용해야 합니다.)
groups를 사용하는것은 위의 예제처럼 등록과 수정에서 요구하는 데이터가 어느 정도 비슷한 경우에는 유용합니다. 하지만
실제로는 서로가 원하는 데이터가 매우 다른 경우가 많기 때문에 groups를 사용하는 방식보다는 아래와 같은 방식을 사용하는 것을 권장합니다.
해결 두 번째 방법 - Form 전송 객체 분리
두번째 방식은 상품등록 form과 상품수정 form에서 사용할 객체 자체를 분리하는 것입니다.
Item (상품 등록용)
@Data
public class ItemSaveForm {
@NotBlank
private String itemName;
@NotNull
@Range(min = 1000, max = 1000000)
private Integer price;
@NotNull
@Max(value = 9999)
private Integer quantity;
}
상품 등록 form을 위한 클래스를 작성합니다.
Item (상품 수정용)
@Data
public class ItemUpdateForm {
@NotNull
private Long id;
@NotBlank
private String itemName;
@NotNull
@Range(min = 1000, max = 1000000)
private Integer price;
//수정에서는 수량은 자유롭게 변경할 수 있다.
private Integer quantity;
}
상품 수정 form을 위한 클래스를 작성합니다.
ValidationController의 addItem메서드
@PostMapping("/add")
public String addItem(@Validated @ModelAttribute("item") ItemSaveForm form,
BindingResult bindingResult, RedirectAttributes redirectAttributes) {
//...
//...
Item savedItem = itemRepository.save(item);
redirectAttributes.addAttribute("itemId", savedItem.getId());
redirectAttributes.addAttribute("status", true);
return "redirect:/validation/items/{itemId}";
}
ValidationController 의 editItem메서드
@PostMapping("/{itemId}/edit")
public String editItem(@PathVariable Long itemId, @Validated @ModelAttribute("item") ItemUpdateForm form, BindingResult bindingResult) {
//...
//...
Item itemParam = new Item();
itemParam.setItemName(form.getItemName());
itemParam.setPrice(form.getPrice());
itemParam.setQuantity(form.getQuantity());
itemRepository.update(itemId, itemParam);
return "redirect:/validation/items/{itemId}";
}
각각 상품 등록, 상품 수정 form에서 사용할 객체를 생성하고, Controller에서 addItem 메서드에서는 ItemSaveForm을,
editItem 메서드에서는 ItemUpdateForm 클래스를 @ModelAttribute로 바인딩해줍니다.
이렇게 받아온 각각의 객체를 DB에서 사용하려면 Item 객체로 변환이 필요합니다.
따라서 Controller의 각 메서드마다 DB에서 사용할 수 있는 Item 객체로 변환 후 반환합니다.
이와 같이 각각 객체를 분리하여 사용하면 사용하는 데이터가 달라도 검증값을 지정하여 적용하는 것이 편리해집니다.
참고
https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81-mvc-1/dashboard
'Back-end > Spring' 카테고리의 다른 글
[Spring] Dispatcher-Servlet 알아보기 (0) | 2025.02.26 |
---|---|
[Spring] 빈 생명주기 콜백 (3) | 2024.08.18 |
[Spring] 싱글톤 컨테이너&싱글톤 패턴 (2) | 2024.08.13 |