: 검증 로직을 모든 프로젝트에 적용할 수 있게 공통화하고, 표준화한 기능입니다. 특정한 구현체가 아니라 기술 표준으로서, 검증 애노테이션과 여러 인터페이스의 모음으로 보시면 됩니다.
: BeanValidation을 구현한 기술 중에서 일반적으로 사용하는 구현체는 하이버네이트 Vadidator 입니다.
@NotBlank
@NotNull
@Range(min=1000, max=100000)
과 같은 검증 애노테이션이 존재합니다.
: javax.validation으로 시작하면 '특정 구현'에 관계 없이 제공되는 표준 인터페이스입니다.
: org.hibernate.validator로 시작하면 하이버네이트 validator구현체를 사용할 때만 제공되는 검증입니다.
: 하지만 실무에서 대부분 하이버네이트 validator를 사용하므로 자유롭게 사용해도 됩니다.
Bean Validator는 보편적으로 사용되는 빈 값, 범위 등에 대한 검증 로직을 모든 프로젝트에 범용적으로 사용할 수 있도록 표준화한 인터페이스 입니다. 즉, Bean Validation은 특정한 구현체가 아닙니다. 기술 표준으로서 다양한 애노테이션과 여러 인터페이스의 모음입니다. Bean Validation의 구현체 로는 hibernate Validator가 가장 많이 사용됩니다.
참조블로그
스프링은 이미 개발자를 위해 bean validation은 스프링에 완전히 통합해두었습니다. 스프링 부트가 spring-boot-starter-validation
라이브러리를 넣으면, 자동으로 Bean Validator를 인지하고 스프링에 통합하는 것입니다.
스프링부트는 자동으로 LocalValidatorFactoryBean
을 글로벌 Validator로 등록합니다. 이 Validator는 @NotNull 같은 애노테이션을 보고 검증을 수행합니다.
이렇게 글로벌 Validator가 적용되어 있기 때문에 @Valid, @Validated만 적용하면 됩니다. 검증 오류가 발생하면 FieldError, ObjectError를 생성해서 BindingResult에 담아줍니다.
LocalValidatorFactoryBean는 Spring에서 Validator를 사용하기 위해서 필요합니다.
@Validated는 스프링 전용 검증 애노테이션이고 @Valid는 자바 표준 검증 애노테이션입니다. 둘 중 아무거나 사용해도 동일하게 작동하지만 @Validated는 내부에 groups라는 기능을 포함하고 있어서 주로 사용합니다.
@ModelAttribute
각각의 필드에 타입 변환을 시도합니다. 성공하면 다음으로 넘어가고, 실패한다면 typeMismatch
로 FieldError
를 추가합니다.이를 통해 알 수 있는 것은, 타입 변환에 성공해서 바인딩에 성공한! 필드여야 Bean Validation이 의미가 있다는 것입니다.
: Bean Validation을 적용하고 bindingResult에는 오류 코드가 애노테이션 이름으로 등록됩니다.
: NotBlank
라는 오류 코드를 기반으로 MessageCodeResolver
를 통해 Field Error에 대한 다양한 메시지 코드가 순서대로 생성되는 예시입니다.
: NotBlank.item.itmeName
: NotBlank.itemName
: NotBlank.java.lang.String
: NotBlank
BeanValidation 메시지를 찾는 순서는 다음과 같습니다.
1. 생성된 메시지 코드 순서대로 messageSource에서 찾기
2. 애노테이션의 message 속성 사용 (ex. @NotBlank(message ="공백!")
3. 라이브러리가 제공하는 기본 값 사용 (ex. 공백일 수 없습니다.)
필드에러의 경우는 위와 같이 처리하면 되고,
오브젝트와 관련된 오류는 Field에 애노테이션을 붙일 수 없으므로 @ScriptAssert()
를 사용하면 됩니다.
@Data
@ScriptAssert(lang="javascript", script="_this.price*_this.quantity>=10000")
public class Item{
}
하지만 Object Error를 이렇게 사용하면 제약이 많고 복잡해집니다. 따라서 Object Error(글로벌 오류)의 경우 따로 자바 코드로 작성하는 것이 보다 유용합니다.
@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}";
}
: 데이터 등록시와 수정시의 요구사항이 다를 수 있습니다.
: 예를들어 회원 등록의 경우에는 다음과 같이 약관 동의와 같은 초기 정보를 받을 수 있습니다.
: 하지만 회원 정보 수정시에는 등록과 완전히 똑같은 정보를 요구하지만은 아님을 확인할 수 있습니다.
이를 위해 스프링은 두 가지의 방법을 제공합니다.
저장용 groups과
package hello.itemservice.domain.item;
public interface SaveCheck {
}
수정용 groups
package hello.itemservice.domain.item;
public interface UpdateCheck {
}
을 생성하고 Item 클래스에는 다음과 같이 적용합니다.
@Data
public class Item {
@NotNull(groups = UpdateCheck.class) //수정시에만 적용
private Long id;
@NotBlank(groups = {SaveCheck.class, UpdateCheck.class})
private String itemName;
...
}
}
그리고 저장 로직에는
public String addItemV2(@Validated(SaveCheck.class) ...)
수정 로직에는
public String editV2(@Validated(UpdateCheck.class) ..)
처럼 적용하면 됩니다.
하지만 보통 실무에서는 폼 전송 객체를 분리하는 방식으로 사용합니다.
이 방식은 Item을 직접 전달받는 것이 아니라, 복잡한 폼의 데이터를 컨트롤러 까지 전달할 별도의 객체를 만들어서 전달하는 것입니다.
: 예를 들어 ItemSaveForm
이라는 폼을 전달받는 전용 객체를 만들어서 @ModelAttribute로 사용하는 것입니다.
: 이를 통해서 컨트롤러에서 폼 데이터를 전달받고, 필요한 정보만을 사용해서 Item을 생성하면 됩니다.
: 이때, 이러한 폼 전달 객체 생성의 과정에서 ItemSaveForm과 ItemEditForm을 분리하면 등록폼과 수정폼의 내용이 달라지도록 구현할 수 있습니다.
: Item에서 모든 검정 코드는 적용하지 않아도 됩니다.
@Data
public class Item{
private long id;
private String itemName;
...
}
: ITEM 저장용! 폼입니다.
@Data
public class ItemSaveForm{
@NotBlank
private String itemName;
@NotNull
@Range(min=1000, max = 1000000)
pirvate Integer price;
...
}
: ITEM 수정용! 폼입니다.
@Data
public class ItemUpdateForm{
@NotNull
private Long id;
@NotBlank
private String itemName;
...
}
: 해당 폼을 사용하도록 컨트롤러를 수정합니다.
@Slf4j
@Controller
@RequestMapping("/validation/v4/items")
@RequiredArgsConstructor
public class ValidationItemController{
private final ItemRepository itemrRepository;
@GetMapping("/add")
public String addForm(Model model){
model.addAttribute("item", new Item());
return "validation/v4/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[]{1000, 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}";
}
: @Validated를 HTTPMessageConverter(@RequestBody)
에도 사용할 수 있습니다.
: @RequestBody는 주로 API JSON 요청을 다룰 때 사용합니다.
@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;
}
}
해당 코드에서 얻어낼 수 있는 결과는 세가지가 있습니다.
: 2번째 case가 생기는 이유는 다음과 같습니다. 바로 필드단위로 적용되는 @ModelAttribute
대비 HttpMessageConverter
는 전체 객체 단위로 적용되기 때문입니다. @ModelAttribute는 하나의 필드가 잘못되어도 다른 필드는 정상처리할 수 있지만 HttpMessageConverter는 불가능합니다.