Validation
검증을 살펴보자
package hello.itemservice.web.basic;
import hello.itemservice.domain.item.DeliveryCode;
import hello.itemservice.domain.item.Item;
import hello.itemservice.domain.item.ItemRepository;
import hello.itemservice.domain.item.ItemType;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
import javax.annotation.PostConstruct;
import java.util.*;
@Slf4j
@Controller
@RequestMapping("/basic/v1/items")
@RequiredArgsConstructor //fianl이 붙은 애들을 생성자로 주입시킴
public class BasicItemControllerV1 {
private final ItemRepository itemRepository;
@ModelAttribute("regions") //모든 컨트롤러에 다 담김 model.addAttribute("regions", regions);
public Map<String, String> regions(){
Map<String, String> regions = new LinkedHashMap<>(); //해쉬맵은 순서 보장 안함.
regions.put("SEOUL", "서울");
regions.put("BUSAN", "부산");
regions.put("JEJU", "제주");
return regions;
}
@ModelAttribute("itemTypes")
public ItemType[] itemTypes(){
return ItemType.values();
}
@ModelAttribute("deliveryCodes")
public List<DeliveryCode> deliveryCodes(){
List<DeliveryCode> deliveryCodes = new ArrayList<>();
deliveryCodes.add(new DeliveryCode("FAST", "빠른 배송"));
deliveryCodes.add(new DeliveryCode("NORMAL", "일반 배송"));
deliveryCodes.add(new DeliveryCode("SLOW", "느린 배송"));
return deliveryCodes;
}
@GetMapping
public String items(Model model){
List<Item> items = itemRepository.findAll();
model.addAttribute("items", items);
log.info("test");
return "basic/v1/items";
}
@GetMapping("/{itemId}")
public String item(@PathVariable Long itemId, Model model){
Item item = itemRepository.findById(itemId);
model.addAttribute("item", item);
return "basic/v1/item";
}
@GetMapping("/add")
public String addForm(Model model){
model.addAttribute("item", new Item());
return "basic/v1/addForm";
}
@PostMapping("/add")
public String addItemV6(Item item, RedirectAttributes redirectAttributes, Model model){
//검증 오류 결과를 보관
Map<String, String> errors = new HashMap<>();
//검증 로직
if(!StringUtils.hasText(item.getItemName())){
errors.put("itemName", "상품 이름은 필수입니다.");
}
if(item.getPrice() == null || item.getPrice() < 1000 || item.getPrice() > 1000000){
errors.put("price", "가격은 1,000 - 1,000,000 까지 허용합니다.");
}
if(item.getQuantity() == null || item.getQuantity() >= 9999){
errors.put("quantity", "수량은 최대 9,999 까지 허용합니다.");
}
//특정 필드가 아닌 복합 롤 검증
if(item.getPrice() != null && item.getQuantity() != null){
int resultPrice = item.getPrice() * item.getQuantity();
if(resultPrice < 10000){
errors.put("globalError", "가격 * 수량의 합은 10,000원 이상이어야 합니다. 현재 값 = " + resultPrice);
}
}
//검증에 실패하면 다시 입력 폼으로
if(!errors.isEmpty()){
log.info("errors ={}", errors);
model.addAttribute("errors", errors);
return "basic/v1/addForm";
}
//성공 로직
Item savedItem = itemRepository.save(item);
redirectAttributes.addAttribute("itemId", savedItem.getId());
redirectAttributes.addAttribute("status", true);
return "redirect:/basic/v1/items/{itemId}"; // status는 파라미터로 넘어감. ?status=true
}
@GetMapping("/{itemId}/edit")
public String editForm(@PathVariable Long itemId, Model model){
Item item = itemRepository.findById(itemId);
model.addAttribute("item", item);
return "basic/v1/editForm";
}
@PostMapping("/{itemId}/edit")
public String editItemV1(@PathVariable Long itemId, @ModelAttribute Item item){
itemRepository.update(itemId, item);
return "redirect:/basic/v1/items/{itemId}";
}
/**
* 테스트용 데이터 추가
*/
@PostConstruct
public void init(){
itemRepository.save(new Item("itemA", 10000, 10));
itemRepository.save(new Item("itemB", 20000, 20));
}
}
등록 폼
<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="utf-8">
<link th:href="@{/css/bootstrap.min.css}"
href="../css/bootstrap.min.css" rel="stylesheet">
<style>
.container {
max-width: 560px;
}
.field-error {
border-color: #dc3545;
color: #dc3545;
}
</style>
</head>
<body>
<div class="container">
<div class="py-5 text-center">
<h2>상품 등록 폼</h2>
</div>
<h4 class="mb-3">상품 입력</h4>
<form action="item.html" th:action th:object="${item}" method="post">
<div th:if="${errors?.containsKey('globalError')}">
<p class="field-error" th:text="${errors['globalError']}">전체 오류 메시지</p>
</div>
<div>
<label for="itemName">상품명</label>
<input type="text" id="itemName" th:field="*{itemName}"
th:class="${errors?.containsKey('itemName')} ? 'form-control field-error' : 'form-control'"
class="form-control"
placeholder="이름을 입력하세요">
<div class="field-error" th:if="${errors?.containsKey('itemName')}" th:text="${errors['itemName']}">
상품명 오류
</div>
</div>
<div>
<label for="price">가격</label>
<input type="text" id="price" th:field="*{price}" class="form-control"
th:class="${errors?.containsKey('price')} ? 'form-control field-error' : 'form-control'"
placeholder="가격을 입력하세요">
<div class="field-error" th:if="${errors?.containsKey('price')}" th:text="${errors['price']}">
가격 오류
</div>
</div>
<div>
<label for="quantity">수량</label>
<input type="text" id="quantity" th:field="*{quantity}" class="form-control"
th:class="${errors?.containsKey('price')} ? 'form-control field-error' : 'form-control'"
placeholder="수량을 입력하세요">
<div class="field-error" th:if="${errors?.containsKey('price')}" th:text="${errors['price']}">
수량 오류
</div>
</div>
<hr class="my-4">
<div class="row">
<div class="col">
<button class="w-100 btn btn-primary btn-lg" type="submit">상품
등록</button>
</div>
<div class="col">
<button class="w-100 btn btn-secondary btn-lg"
onclick="location.href='items.html'"
th:onclick="|location.href='@{/basic/v1/items}'|" type="button">취소</button>
</div>
</div>
</form>
</div> <!-- /container -->
</body>
</html>
스프링이 제공하는 BindingResult
@PostMapping("/add")
public String addItemV1(@ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model){
//ModelAttribute 객체 뒤에 bindingResult 가 와야함.
//검증 로직
if(!StringUtils.hasText(item.getItemName())){
bindingResult.addError(new FieldError("item", "itemName", "상품 이름을 필수 입니다."));
}
if(item.getPrice() == null || item.getPrice() < 1000 || item.getPrice() > 1000000){
bindingResult.addError(new FieldError("item", "price", "가격은 1,000 - 1,000,000 까지 허용합니다."));
}
if(item.getQuantity() == null || item.getQuantity() >= 9999){
bindingResult.addError(new FieldError("item", "quantity", "수량은 최대 9,999 까지 허용합니다."));
}
//특정 필드가 아닌 복합 롤 검증
if(item.getPrice() != null && item.getQuantity() != null){
int resultPrice = item.getPrice() * item.getQuantity();
if(resultPrice < 10000){
bindingResult.addError(new ObjectError("item", "가격 * 수량의 합은 10,000원 이상이어야 합니다. 현재 값 = " + resultPrice));
}
}
//검증에 실패하면 다시 입력 폼으로
if(bindingResult.hasErrors()){
log.info("errors = {}", bindingResult);
//model에 안담아도 bindingResult에 자동으로 담김.
return "basic/v2/addForm";
}
//성공 로직
Item savedItem = itemRepository.save(item);
redirectAttributes.addAttribute("itemId", savedItem.getId());
redirectAttributes.addAttribute("status", true);
return "redirect:/basic/v2/items/{itemId}"; // status는 파라미터로 넘어감. ?status=true
}
FieldError에 에러 담고
글로벌 에러는 ObjectError에 담는다.
BindingResult는 검증 대상 바로 다음에 와야한다. ex> @ModelAttribute Item item
BindingResult 는 Model에 자동으로 포함된다.
<div th:if="${#fields.hasGlobalErrors()}">
<p class="field-error" th:each="err : ${#fields.globalErrors()}" th:text="${err}">글로벌 오류 메시지</p>
</div>
<div>
<label for="itemName">상품명</label>
<input type="text" id="itemName" th:field="*{itemName}"
th:errorclass="field-error" class="form-control" placeholder="이름을 입력하세요"> <!-- th:filed는 bindResult에서 필드명 찾음 -->
<div class="field-error" th:errors="*{itemName}"> <!--th:errors는 에러가 있을 때 th:if의 편의 버전이다. -->
상품명 오류
</div>
</div>
- BindingResult는 스프링이 제공하는 검증 오류를 보관하는 객체.
- BindingResult가 있으면 @ModelAttribute에 데이터 바인딩 시 오류가 발생해도 컨트롤러가 호출됨.(원래 오류 발생하면 튕김)
- 만약 타입 에러가 발생하면 400에러가 발생해서 컨트롤러가 호출되지 않고 에러메시지로 이동하지만
- BindingResult가 있으면 오류정보(FieldError)를 BindResult에 담아서 컨트롤러를 정상 호출한다.
BindingResult, FieldError, ObjectError를 사용해서 에러 메시지를 처리하면
고객이 입력한 내용이 모두 사라진다.
if(item.getPrice() == null || item.getPrice() < 1000 || item.getPrice() > 1000000){
bindingResult.addError(new FieldError("item", "price", "가격은 1,000 - 1,000,000 까지 허용합니다."));
}
if(item.getPrice() == null || item.getPrice() < 1000 || item.getPrice() > 1000000){
bindingResult.addError(new FieldError("item", "price", item.getPrice(),
false, null, null, "가격은 1,000 - 1,000,000 까지 허용합니다."));
}
FieldError는 에러가 발생했을 때 고객이 입력한 내용을 유지 하기 위해 아래 생성자를 사용함.
가격에 숫자가 아닌 문자가 입력된다면 Integer타입이 아니므로 문자를 보관할 수 없는데
오류가 발생할 경우 사용자 입력 값을 보관할 수 있다. 이 보관값을 화면에 다시 출력한다.
저기서는 item.getPrice가 저장소가 된다.
required.item.itemName=상품 이름은 필수입니다.!!!
range.item.price=가격은 {0} ~ {1} 까지 허용합니다.@@@
max.item.quantity=수량은 최대 {0} 까지 허용합니다.@@@$
totalPriceMin=가격 * 수량의 합은 {0}원 이상이어야 합니다. 현재 값 = {1}
errors.properties에 메시지 국제화 기능에서 사용했던 프로퍼티스 파일을 만든다.
@PostMapping("/add")
public String addItemV3(@ModelAttribute Item item, BindingResult bindingResult,
RedirectAttributes redirectAttributes) {
if (!StringUtils.hasText(item.getItemName())) {
bindingResult.addError(new FieldError("item", "itemName",
item.getItemName(), false, new String[]{"required.item.itemName"}, null,
null));
}
if (item.getPrice() == null || item.getPrice() < 1000 || item.getPrice() >
1000000) {
bindingResult.addError(new FieldError("item", "price", item.getPrice(),
false, new String[]{"range.item.price"}, new Object[]{1000, 1000000}, null));
}
if (item.getQuantity() == null || item.getQuantity() > 10000) {
bindingResult.addError(new FieldError("item", "quantity",
item.getQuantity(), false, new String[]{"max.item.quantity"}, new Object[]
{9999}, null));
}
//특정 필드 예외가 아닌 전체 예외
if (item.getPrice() != null && item.getQuantity() != null) {
int resultPrice = item.getPrice() * item.getQuantity();
if (resultPrice < 10000) {
bindingResult.addError(new ObjectError("item", new String[]
{"totalPriceMin"}, new Object[]{10000, resultPrice}, null));
}
}
if (bindingResult.hasErrors()) {
log.info("errors={}", bindingResult);
return "basic/v2/addForm";
}
//성공 로직
Item savedItem = itemRepository.save(item);
redirectAttributes.addAttribute("itemId", savedItem.getId());
redirectAttributes.addAttribute("status", true);
return "redirect:/basic/v2/items/{itemId}";
}
codes에 메시지코드를 지정한다. 배열로 여러개 올수도 있다.
arguments에 치환값을 전달한다.
BindingResult는 검증해야할target객체 뒤에 온다.
여기서는 item객체 뒤에 있으므로 자신이 검증해야 할 객체가 item인것을 안다.
BindingResult가 제공하는 rejectValue()와 reject()를 사용하면 FieldError와 ObjectError를 생성하지 않고 오류를 다룰 수 있다.
@PostMapping("/add")
public String addItemV4(@ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model) {
//ModelAttribute 객체 뒤에 bindingResult 가 와야함.
if (!StringUtils.hasText(item.getItemName())) {
bindingResult.rejectValue("itemName", "required");
}
if (item.getPrice() == null || item.getPrice() < 1000 || item.getPrice() >
1000000) {
bindingResult.rejectValue("price", "range", new Object[]{1000,
1000000}, null);
}
if (item.getQuantity() == null || item.getQuantity() > 10000) {
bindingResult.rejectValue("quantity", "max", new Object[]{9999}, null);
}
//특정 필드 예외가 아닌 전체 예외
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 "basic/v2/addForm";
}
//성공 로직
Item savedItem = itemRepository.save(item);
redirectAttributes.addAttribute("itemId", savedItem.getId());
redirectAttributes.addAttribute("status", true);
return "redirect:/basic/v2/items/{itemId}";
}
그런데 errors.properties를 보면 너무 세밀하게 작성돼서 범용성이 떨어진다.
required.item.itemName=상품 이름은 필수입니다.!!!
range.item.price=가격은 {0} ~ {1} 까지 허용합니다.@@@
max.item.quantity=수량은 최대 {0} 까지 허용합니다.@@@$
totalPriceMin=가격 * 수량의 합은 {0}원 이상이어야 합니다. 현재 값 = {1}
필수값 입력은 많은 곳에서 검증해야 하는데 이 줄은 item밖에 사용 못한다.
required.item.itemName=상품 이름은 필수입니다.!!!
만약 required=필수입니다. 를 하나더 지정하면
상세하게 적힌 required.item.itemName이 우선순위가 1이고 그 다음인 required가 우선순위 2가 된다.
public class MessageCodesResolverTest {
MessageCodesResolver codesResolver = new DefaultMessageCodesResolver();
@Test
void messageCodesResolverObject(){
String[] messageCodes = codesResolver.resolveMessageCodes("required", "item");
for (String messageCode : messageCodes) {
System.out.println("messageCode = " + messageCode);
}
assertThat(messageCodes).containsExactly("required.item", "required");
}
@Test
void messageCodesResolverField(){
String[] messageCodes = codesResolver.resolveMessageCodes("required", "item", "itemName", String.class);
for (String messageCode : messageCodes) {
System.out.println("messageCode = " + messageCode);
}
assertThat(messageCodes).containsExactly("required.item.itemName"
, "required.itemName", "required.java.lang.String", "required");
}
}
MessageCodeResulver인터페이스는
검증오류코드로 메시지 코드들을 생성한다. DefaultMessageCodesResolver가 기본 구현체.
저기서 생성된 messageCodes값들을 Codes로 사용한다.
messageCode = required.item.itemName
messageCode = required.itemName
messageCode = required.java.lang.String
messageCode = required
이렇게 규칙을 가지고 코드를 생성시킨다. FieldError생성자 기억하면 String[]배열로 여러개의 코드 들을 받았다.
BindingResult는 rejectValue(), reject()를 사용하면 내부에서 MessageCodesResolver를 사용해서 메시지 코드들을 생성한다. 그리고 생성된 코드들을 보관한다.
정리
1. rejectValue() 호출
2. MessageCodesResolver 를 사용해서 검증 오류 코드로 메시지 코드들을 생성
3. new FieldError() 를 생성하면서 메시지 코드들을 보관
4. th:erros 에서 메시지 코드들로 메시지를 순서대로 메시지에서 찾고, 노출
검증오류 코드는 개발자가 직접 설정한 오류 코드를(rejectValue)직접 호출하거나,
스프링이 직접 검증 오류에 추가한 경우(주로 타입 정보, Integer타입인데 문자 입력했을 때 )
두가지로 나뉜다.
타입 에러가 날 경우 저렇게 스프링이 만든 타입미스매치 에러가 보여지는데 MessageCodeResolver를 통해서 생성된 기본 메시지가 출력된다.
#추가
typeMismatch.java.lang.Integer=숫자를 입력해주세요.
typeMismatch=타입 오류입니다.
errors.properties에 메시지 코드를 추가하면
사용자가 직접 만든 메시지코드를 출력할 수 있다.
스프링은 검증을 체계적으로 제공하기 위해 Vaildator라는인터페이스를 제공한다.
package hello.itemservice.web.basic;
import hello.itemservice.domain.item.Item;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
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() >
1000000) {
errors.rejectValue("price", "range", new Object[]{1000,
1000000}, null);
}
if (item.getQuantity() == null || item.getQuantity() > 10000) {
errors.rejectValue("quantity", "max", new Object[]{9999}, null);
}
//특정 필드 예외가 아닌 전체 예외
if (item.getPrice() != null && item.getQuantity() != null) {
int resultPrice = item.getPrice() * item.getQuantity();
if (resultPrice < 10000) {
errors.reject("totalPriceMin", new Object[]{10000,
resultPrice}, null);
}
}
}
}
기존 Controller에서 복잡한 검증과정 코드를 줄이기 위해 검증 코드를 클래스로 분리했다.
저 검증클래스를 사용하기 위해 @component를 사용해서 빈으로 등록한 후
@Slf4j
@Controller
@RequestMapping("/basic/v2/items")
@RequiredArgsConstructor //fianl이 붙은 애들을 생성자로 주입시킴
public class BasicItemControllerV2 {
private final ItemRepository itemRepository;
private final ItemValidator itemValidator;
ItemValidator클래스를 생성자로 주입한다. 그리고
@PostMapping("/add")
public String addItemV5(@ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model) {
itemValidator.validate(item, bindingResult);
//검증에 실패하면 입력폼으로
if (bindingResult.hasErrors()) {
log.info("errors={}", bindingResult);
return "basic/v2/addForm";
}
//성공 로직
Item savedItem = itemRepository.save(item);
redirectAttributes.addAttribute("itemId", savedItem.getId());
redirectAttributes.addAttribute("status", true);
return "redirect:/basic/v2/items/{itemId}";
}
validate에 매개변수로 item객체와 bindingResult를 넘겨주기만 하면 된다.
이렇게 스프링 빈으로 주입받은 클래스를 직접 호출해서 사용하는 방법도 있다.
@InitBinder
public void init(WebDataBinder dataBinder){
dataBinder.addValidators(itemValidator);
}
Validator인터페이스를 앞에서는 검증기를 직접 불러서 사용했지만 검증기를 직접 만들면 스프링의 추가적인 도움을 받는다. WebDataBinder는 스프링 파라미터 바인딩의 역할을 해주고 검증 기능도 내부에 포함한다.
WebDataBinder에 검증기를 추가하면 컨트롤러는 해당 검증기를 자동으로 적용할 수 있다.
@PostMapping("/add")
public String addItemV6(@Validated @ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model) {
//검증에 실패하면 입력폼으로
if (bindingResult.hasErrors()) {
log.info("errors={}", bindingResult);
return "basic/v2/addForm";
}
//성공 로직
Item savedItem = itemRepository.save(item);
redirectAttributes.addAttribute("itemId", savedItem.getId());
redirectAttributes.addAttribute("status", true);
return "redirect:/basic/v2/items/{itemId}";
}
파라미터로 @Validated를 사용하면 validator를 직접 호출하는 부분이 사라진다.
@Validated는 검증기를 사용하라는 의미이다. WebDataBinder에 등록한 검증기를 실행하는데 검증기가 여러개일 경우(itemValidator말고 예를 들면 userValidator등등) 어떤 검증기를 실행해야 할지 구분해야 하는데
@Override
public boolean supports(Class<?> clazz) {
return Item.class.isAssignableFrom(clazz);
}
이때 이것이 호출되고 결과가 true이면 ItemValidator의 validate가 호출된다.