스프링 MVC-스프링 Bean Validation
검증 2
이전 포스팅에서 배웠던 내용들은 스프링에서 검증이 어떤 식으로 이뤄지는 지 알아보기 위해 기초부터 쌓아올려왔던 것이다.
이전 웹 mvc 패턴을 공부할 때처럼 스프링에서는 강력한 어노테이션 기능을 지원하기 때문에 실제로는 검증 또한 어노테이션을 활용하여 편리하고 강력하게 사용할 수 있다.
BeanValidation
BeanValidation은 기술 표준이다. 어떠한 구현체가 아닌 여러 어노테이션과 인터페이스로 이루어진 모음이다.
BeanValidation을 구현한 구현체 중 자주 사용되는 것은 하이버네이트 Validator이다. BeanValidation을 사용하려면 먼저 라이브러리에 추가하고, 필드에 어노테이션을 추가하면 된다.
@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;
}
}
기존 Item 객체의 필드에 어노테이션을 붙여 제한한 코드이다. 기존에는 검증 로직 내에서 처리했던 부분들을 어노테이션을 붙여 간단하게 처리할 수 있다.
각 어노테이션의 의미는 다음과 같다.
- @NotBlank : 빈값 + 공백만 있는 경우를 허용하지 않는다.
- @NotNull : null을 허용하지 않는다.
- @Range(min = 1000, max = 1000000) : 범위 안의 값이여야 한다.
- @Max(9999) : 최대 9999까지만 허용한다.
이렇게 검증 어노테이션들을 붙인 필드들은 빈 검증기를 통해 검증할 수 있는데, ValidatorFactory, Validator 등 빈 검증에 사용되는 여러 객체가 있지만 이미 스프링에 통합되어 있으니 직접 코드를 작성하여 검증하는 부분은 넘어가도록 하겠다.
스프링에서 빈 검증을 사용하는 것은 아주 간단하다.
@PostMapping("/add")
public String addItemV6(@Validated @ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model) {
if(bindingResult.hasErrors()) {
return "validation/v3/addForm";
}
Item savedItem = itemRepository.save(item);
redirectAttributes.addAttribute("itemId", savedItem.getId());
redirectAttributes.addAttribute("status", true);
return "redirect:/validation/v3/items/{itemId}";
}
컨트롤러의 이름만 v3로 바뀌었을 뿐 이전 포스팅에서 작성했던 addItemV6와 동일한 코드이다. 실행하면 빈 검증이 문제없이 작동하는 것을 볼 수 있는데, 이는 스프링 부트가 자동으로 스프링에 빈 검증을 통합해주었기 때문이다.
스프링 부트에 ‘spring-boot-starter-validation’ 라이브러리를 등록하면 자동으로 빈 검증을 스프링에 통합해주고, LocalValidatorFactoryBean를 글로벌 Validator로 등록한다.
해당 Validator는 검증 어노테이션을 확인하고 검증을 진행해주기 때문에 우리는 @Validated로 검증을 진행할지만 작성해주면 된다.
검증 오류가 발생하면 FieldError, ObjectError를 Validator가 생성하여 BindingResult에 담아준다. 매우 편리하다.
다만 유의해야할 점이 있는데 Bean Validation은 바인딩이 정상적으로 이루어져야만 의미가 있다는 것이다. 검증 과정이 다음과 같기 때문이다.
- @ModelAttribute 각 필드에 타입 변환을 시도
- 성공했다면 Validator를 적용
- 실패했다면 typeMismatch로 FieldError가 추가
오류 메세지 변경
위의 코드를 실행시키면 Validator가 기본으로 제공하는 오류 메세지가 출력된다. 오류 메세지를 이전과 같이 원하는대로 변경할 수도 있는데, 오류 코드가 어노테이션 이름을 기준으로 생성되는 것만 유의하고 errors.properties에 작성해주면 된다.
예를 들어 @NotBlank 어노테이션의 메세지 코드는 이전 포스팅에서 배웠던 MessageCodesResolver를 통해 다음과 같이 생성된다.
- NotBlank.item.itemName
- NotBlank.itemName
- NotBlank.java.lang.String
- NotBlank
이를 바탕으로 errors.properties에 다음과 같이 작성해주었다.NotBlank={0} 공백X Range={0}, {2} ~ {1} 허용 Max={0}, 최대 {1}
정상 작동하는 것을 확인할 수 있었다. errors.properties에서 한꺼번에 관리하는 것이 편리하지만 원한다면 어노테이션에 붙여서 간단하게 작성할 수도 있다.
@NotBlank(message = "공백은 입력할 수 없습니다.") private String itemName;
위와 같이 작성한다면 messageSource에서 메세지를 찾은 후 없다면 어노테이션에 붙은 메세지로 출력하게 된다. 어노테이션 메세지도 없다면 라이브러리의 기본 메세지가 출력된다.
글로벌 에러
필드가 아닌 글로벌 에러의 경우는 @ScriptAssert를 사용하여 작성할 수 있다.
@Data
@ScriptAssert(lang = "javascript", script = "_this.price * _this.quantity >= 10000")
public class Item {
//...
}
위와 같이 작성하면 정상적으로 잘 작동하고 메세지 코드도 ScriptAssert.item 등으로 잘 생성되는 것을 확인할 수 있지만, 형식이 제한되어 있어 우리가 원하는대로 검증 로직을 작성하기엔 적합하지 않아 보인다.
따라서 강사님은 글로벌 오류는 따로 직접 자바코드로 작성하는 것을 권장한다.
@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}";
}
필드가 아닌 전체 예외는 컨트롤러에 추가하여 작성한 코드이다. 검증의 완전한 분리는 아니지만 글로벌 에러 한 두개는 컨트롤러에서 작성하는 것이 더 안정적으로 보인다.
요구사항 변경
그런데 Bean Validation에는 한 가지 한계가 있다. 만약 등록과 수정에서 필드의 제한을 서로 다르게 한다면, 예를 들어 등록에선 수량이 9999까지지만 수정에선 제한이 없도록 한다면 지금과 같이 하나의 객체에서 필드에 어노테이션을 붙여서는 구현이 불가능하다.
위의 문제를 해결하기 위한 방법은 2가지가 있다. 첫 번째는 Bean Validation의 groups 기능을 이용하는 것이고, 두 번째는 객체로 Item을 사용하는 대신 ItemSaveForm, ItemUpdateForm 과 같이 폼의 데이터를 전달받을 별개의 객체를 사용하는 것이다.
groups 기능
groups 기능을 사용하기 위해선 먼저 준비가 필요하다. 먼저 등록 그룹, 수정 그룹을 구분하기 위해 2개의 인터페이스를 작성해야 한다.
package hello.itemservice.domain.item;
public interface SaveCheck {
}
public interface UpdateCheck {
}
이 두 인터페이스는 구분을 위한 마커 인터페이스로 별다른 구현을 하지 않는다. 이제 groups 기능을 사용하여 기존 Item 객체를 수정해보자.
@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;
}
}
수정한 코드이다. 어노테이션 뒤에 groups={}로 적용될 그룹을 선택하는 것을 볼 수 있다. 요구사항에 맞게 id가 NotNull인건 수정 시에만, 수량 제한이 9999인 것은 등록 시에만 적용되도록 그룹을 적용하였다.
이렇게 객체에 적용한 그룹은 아래와 같이 컨트롤러에서 @Validated 어노테이션의 매개변수로 넣어줘 사용할 수 있다.
@PostMapping("/add")
public String addItemV2(@Validated(SaveCheck.class) @ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes) {
//...
}
위와 같이 그룹을 지정해주면 해당 그룹의 제한사항만 적용하여 검증이 진행된다.
그런데 groups 기능을 사용하니 객체와 컨트롤러의 복잡도가 증가하였다. 따라서 실무에서는 다음에 설명할 객체를 분리하여 사용하는 방법을 더 많이 사용한다고 한다.
객체의 분리
groups 기능을 잘 사용하지 않는 이유는 사실 등록 시 넘어오는 데이터가 Item 객체와 일치하지 않기 때문이다.
우리가 하고 있는 예제는 매우 간단하기 때문에 등록과 수정의 데이터가 일치하지만, 실무에서는 등록 시 회원과 관련된 데이터 뿐만 아니라 약관 정보 등 수많은 부가 데이터가 같이 넘어온다.
따라서 복잡한 폼의 데이터를 전달받을 ItemSaveForm과 같은 전용 객체를 만들어서 @ModelAttribute로 전달받고, 그 후 컨트롤러에서 필요한 데이터를 파싱하여 Item 객체를 생성하는 방식을 사용한다.
이 방식을 사용하면 두 객체에 검증을 따로 적용할 수 있기 때문에 Bean Validation이 훨씬 편해지는 장점이 있다.
수정 시에도 변경할 수 없는 id, 주민번호 등의 데이터는 폼에서 빠지는 것이 좋기 때문에 별도의 객체로 받는 것이 좋다.
이제 위의 방법을 우리의 예제에도 적용해보자.
먼저 Item의 검증 파트를 제거하고 새로운 객체 ItemSaveForm, ItemUpdateForm 두 가지를 만들어야 한다.
@Data
public class ItemSaveForm {
@NotBlank
private String itemName;
@NotNull
@Range(min = 1000, max = 1000000)
private Integer price;
@NotNull
@Max(value = 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;
}
groups 같은 기능을 사용하지 않아도 객체가 분리되어 있으므로 각각의 검증사항을 구현하기가 편해졌다. 또한 등록 폼에서는 자동 생성되는 id와 같은 필드가 사라져 코드가 더 간결해진 것을 볼 수 있다.
이제 컨트롤러도 새로운 객체들을 사용하도록 수정해주어야 한다.
@Slf4j
@Controller
@RequestMapping("/validation/v4/items")
@RequiredArgsConstructor
public class ValidationItemControllerV4 {
private final ItemRepository itemRepository;
@GetMapping
public String items(Model model) {
List<Item> items = itemRepository.findAll();
model.addAttribute("items", items);
return "validation/v4/items";
}
@GetMapping("/{itemId}")
public String item(@PathVariable long itemId, Model model) {
Item item = itemRepository.findById(itemId);
model.addAttribute("item", item);
return "validation/v4/item";
}
@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, Model model) {
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()) {
return "validation/v4/addForm";
}
Item item=new Item();
item.setItemName(form.getItemName());
item.setQuantity(form.getQuantity());
item.setPrice(form.getPrice());
Item savedItem = itemRepository.save(item);
redirectAttributes.addAttribute("itemId", savedItem.getId());
redirectAttributes.addAttribute("status", true);
return "redirect:/validation/v4/items/{itemId}";
}
@GetMapping("/{itemId}/edit")
public String editForm(@PathVariable Long itemId, Model model) {
Item item = itemRepository.findById(itemId);
model.addAttribute("item", item);
return "validation/v4/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 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}";
}
}
등록과 수정 로직에서 @ModelAttribute로 Item 대신 각자의 객체를 받도록 변경되었고, 내부에서 각자의 객체를 파싱하여 Item을 생성하는 로직이 추가되었다.
뷰 템플릿에서는 객체 이름이 item으로 되어있으므로 뷰 템플릿의 수정을 피하기 위해 @ModelAttribute의 매개변수를 넣어 model에 저장될 이름을 따로 넣어주었다.
API 적용
API에서 @RequestBody 등을 사용할 때에도 Bean Validation을 적용할 수 있다.
@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;
}
}
간단하게 작성해본 api 컨트롤러이다. @RequestBody로 postman에서 전송한 JSON 데이터를 매개변수로 받는다.
바인딩 오류의 경우엔 컨트롤러가 호출되기 전에 예외처리되어 실패 요청이 발생한다.
검증 오류가 발생하면 반환된 검증 오류 객체들을 스프링이 JSON으로 변환하여 클라이언트에 전달해준다.
간단한 예제이므로 오류를 그대로 반환했지만 실무에서는 필요한 데이터를 파싱하여 API의 스펙에 맞게 객체를 만들어 반환해야 한다고 한다.
@ModelAttribute vs @RequestBody
@ModelAttribute와 같은 경우에는 각 필드 단위로 세밀하게 작동하여 한 필드에서 바인딩 오류가 발생하더라도 나머지 필드는 정상 처리되고, @Validated와 같은 검증도 제대로 작동한다.
그에 반해 HttpMessageConverter를 사용하는 @RequestBody는 전체 객체 단위로 적용되므로 메세지 컨버터의 작동이 성공하여 정상적인 객체를 만들어야 컨트롤러도 동작하고 이후의 @Validated가 작동한다.
끝
기나긴 검증 파트가 드디어 끝이 났다. 배우면서 왜 중요한지도 알 수 있었고 기초적인 검증 구현에서부터 BeanValidation까지 서서히 올라와서 그런지 어떤 식으로 동작하는 지도 전체적인 그림을 배울 수 있었던 것 같다.
또한 스프링에서 어노테이션으로 얼마나 강력하고 유연한 체계를 구축했는지 다시 한번 느낄 수 있었다.
다음 포스팅에서는 드디어 로그인과 세션, 쿠키에 대해 배우고 포스팅할 예정이다. 개인 프로젝트로 웹을 구축하기 위해서는 빠질 수 없는 부분인 만큼 잘 배우고 정리할 예정이다.