클라이언트/서버 검증
@PostMapping("/add")
public String addItem(@ModelAttribute 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()) {
model.addAttribute("errors", errors);
return "validation/v1/addForm";
}
//성공 로직
Item savedItem = itemRepository.save(item);
redirectAttributes.addAttribute("itemId", savedItem.getId());
redirectAttributes.addAttribute("status", true);
return "redirect:/validation/v1/items/{itemId}";
}
redirectAttributes.addAttribute("status", true);
/validation/v1/items/{itemId}?status=true
식으로 전달된다검증 로직 살피기
if (!StringUtils.hasText(item.getItemName())) {
errors.put("itemName", "상품 이름은 필수입니다.");
}
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()) {
model.addAttribute("errors", errors);
return "validation/v1/addForm";
}
Html 상의 오류 처리
<input type="text" id="quantity" th:field="*{quantity}"
th:class="${errors?.containsKey('quantity')} ? 'form-control field-error' : 'form-control'"
class="form-control" placeholder="수량을 입력하세요">
...
<div class="field-error" th:if="${errors?.containsKey('quantity')}" th:text=
"${errors['quantity']}"> 수량 오류 </div>
errors?.
은 errors
가 null
일때 NullPointerException
이 발생하는 대신, null
을 반환하는 문법 이다.th:if
에서 null
은 실패로 처리되므로 오류 메시지가 출력되지 않는다. <input type="text" th:classappend="${errors?.containsKey('itemName')} ? 'field-
error' : _" class="form-control">
@PostMapping("/add")
public String addItemV1(@ModelAttribute Item item, BindingResult bindingResult,
RedirectAttributes redirectAttributes) {
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() >= 10000) {
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);
return "validation/v2/addForm";
}
//성공 로직
Item savedItem = itemRepository.save(item);
redirectAttributes.addAttribute("itemId", savedItem.getId());
redirectAttributes.addAttribute("status", true);
return "redirect:/validation/v2/items/{itemId}";
}
주의
BindingResult bindingResult
의 파라미터 위치는 @ModelAttribute Item item
다음에 와야한다.
검증 로직
if (!StringUtils.hasText(item.getItemName())) {
bindingResult.addError(new FieldError("item", "itemName", "상품 이름은 필수입니다."));
}
public FieldError(String objectName, String field, String defaultMessage) {}
FieldError
객체를 생성해서 bindingResult
에 담아두면 된다.objectName:
@ModelAttribite 이름field:
오류가 발생한 필드 이름defaultMessage:
오류 기본 메시지 bindingResult.addError(new ObjectError("item", "가격 * 수량의 합은 10,000원
이상이어야 합니다. 현재 값 = " + resultPrice));
public ObjectError(String objectName, String defaultMessage) {}
ObjectEror
객체를 생성해서 bindingResult
에 담아두면된다.objectName:
@ModelAttribute의 이름defaultMessage:
오류 기본 메시지 <div th:if="${#fields.hasGlobalErrors()}">
<p class="field-error" th:each="err : ${#fields.globalErrors()}" th:text="${err}">
글로벌 오류 메시지
</p>
</div>
<div class="field-error" th:errors="*{price}">
가격 오류
</div>
타임리프 스프링 검증 오류 통합 가능
#fields
: #fields
로 BindingResult
가 제공하는 검증 오류에 접근할 수 있다th:errors:
해당 필드에 오류가 있는 경우에 태그를 출력한다th:errorclass
: th:field
에서 지정한 필드에 오류가 있으면 class
정보를 추가한다.BindingResult
BindingResult
가 있으면 @ModelAttribute
에 데이터 바인딩 시 오류가 발생해도 컨트롤러가 호출된다.BindingResult가
없으면 400오류가 발생하면서 컨트롤러가 호출되지 않고 오류페이지로 이동한다BindingResult
가 있으면 오류정보를 BindingResult
에 담아서 컨트롤러를 정상 호출한다주의
BindingResult
는 검증할 대상 바로 다음에 와야한다. @ModelAttribute Item item
바로 다음에 BindingResult
가 와야한다.BindingResult
는 Model
에 자동으로 포함된다.bindingResult.addError(new FieldError("item", "itemName",
item.getItemName(), false, null, null, "상품 이름은 필수입니다."));
bindingResult.addError(new FieldError("item", "price", item.getPrice(),
false, null, null, "가격은 1,000 ~ 1,000,000 까지 허용합니다."));
bindingResult.addError(new ObjectError("item", null, null,
"가격 * 수량 의 합은 10,000원 이상이어야 합니다. 현재 값 = " + resultPrice));
FieldError 생성자
public FieldError(String objectName, String field, String defaultMessage);
public FieldError(String objectName, String field, @Nullable Object
rejectedValue, boolean bindingFailure, @Nullable String[] codes, @Nullable
Object[] arguments, @Nullable String defaultMessage)
objectName:
오류가 발생한 객체 이름rejectedValue:
오류 필드bindingFailure:
사용자가 입력한 값(거절된 값)codes:
메시지 코드arguments:
메시지에서 사용하는 인자defaultMessage:
기본 오류 메시지FieldError 생성자
FieldError
는 두가지 생성자를 제공한다. public FieldError(String objectName, String field, String defaultMessage);
public FieldError(String objectName, String field, @Nullable Object
rejectedValue, boolean bindingFailure, @Nullable String[] codes, @Nullable
Object[] arguments, @Nullable String defaultMessage)
objectName:
오류가 발생한 객체 이름rejectedValue:
오류 필드bindingFailure:
사용자가 입력한 값(거절된 값)codes:
메시지 코드arguments:
메시지에서 사용하는 인자defaultMessage:
기본 오류 메시지스프링부트 메시지 설정 추가
src/main/resources/errors.properties
required.item.itemName=상품 이름은 필수입니다.
range.item.price=가격은 {0} ~ {1} 까지 허용합니다.
max.item.quantity=수량은 최대 {0} 까지 허용합니다.
totalPriceMin=가격 * 수량의 합은 {0}원 이상이어야 합니다. 현재 값 = {1}
bindingResult.addError(new FieldError("item", "itemName",
item.getItemName(), false, new String[]{"required.item.itemName"}, null, null));
bindingResult.addError(new FieldError("item", "price", item.getPrice(),
false, new String[]{"range.item.price"}, new Object[]{1000, 1000000}, null));
bindingResult.addError(new FieldError("item", "quantity",
item.getQuantity(), false, new String[]{"max.item.quantity"}, new Object[]
{9999}, null));
bindingResult.addError(new ObjectError("item", new String[]
{"totalPriceMin"}, new Object[]{10000, resultPrice}, null));
codes
: required.item.itemName
를 사용해서 메시지 코드를 지정arguments
: Object[]{1000,1000000}
를 사용해서 {0},{1}
로 치환할 값을 전달reject/rejectValue
BindingResult
가 제공하는 rejectValue()
, reject()
를 사용하면 FieldError
, ObjectError
를 직
접 생성하지 않고, 깔끔하게 검증 오류를 다룰 수 있다.
bindingResult.rejectValue("itemName", "required");
bindingResult.rejectValue("price", "range", new Object[]{1000, 1000000},null);
bindingResult.reject("totalPriceMin", new Object[]{10000, resultPrice}, null);
@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;
ValidationUtils.rejectIfEmptyOrWhitespace(errors, "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);
}
}
}
}
private final ItemValidator itemValidator;
@PostMapping("/add")
public String addItemV5(@ModelAttribute Item item, BindingResult bindingResult,
RedirectAttributes redirectAttributes) {
itemValidator.validate(item, bindingResult);
if (bindingResult.hasErrors()) {
log.info("errors={}", bindingResult);
return "validation/v2/addForm";
}
//성공 로직
Item savedItem = itemRepository.save(item);
redirectAttributes.addAttribute("itemId", savedItem.getId());
redirectAttributes.addAttribute("status", true);
return "redirect:/validation/v2/items/{itemId}";
}
ItemValidator
를 스프링 빈으로 주입 받아서 직접 호출했다.WebDataBinder
@InitBinder
public void init(WebDataBinder dataBinder) {
log.info("init binder {}", dataBinder);
dataBinder.addValidators(itemValidator);
}
@PostMapping("/add")
public String addItemV6(@Validated @ModelAttribute Item item, BindingResult
bindingResult, RedirectAttributes redirectAttributes) {
....
이렇게 WebDataBinder
에 검증기를 추가하면 해당 컨트롤러에서는 검증기를 자동으로 적용할 수 있다. @InitBinder
해당 컨트롤러에만 영향을 준다.
@Validated
는 검증기를 실행하라는 애노테이션이다.
이 애노테이션이 붙으면 앞서 WebDataBinder
에 등록한 검증기를 찾아서 실행한다. 그런데 여러 검증기를 등록한다 면 그 중에 어떤 검증기가 실행되어야 할지 구분이 필요하다. 이때 supports()
가 사용된다. 여기서는
supports(Item.class)
호출되고, 결과가 true
이므로 ItemValidator
의 validate()
가 호출된다.
@SpringBootApplication
public class ItemServiceApplication implements WebMvcConfigurer {
public static void main(String[] args) {
SpringApplication.run(ItemServiceApplication.class, args);
}
@Override
public Validator getValidator() {
return new ItemValidator();
}
}
이렇게 글로벌 설정을 추가할 수 있다. 기존 컨트롤러의 @InitBinder
를 제거해도 글로벌 설정으로 정상 동작하는
것을 확인할 수 있다.
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;
}
검증 에노테이션
@NotBlank:
빈값이나 공백만 있는 경우를 허용하지 않는다@NotNull:
null
을 허용하지 않는다@Range(min=1000,max=100000):
범위안의 값이어야 한다@Max(9999):
최대 9999까지만 허용한다 @Slf4j
@Controller
@RequestMapping("/validation/v3/items")
@RequiredArgsConstructor
public class ValidationItemControllerV3 {
@PostMapping("/add")
public String addItem(@Validated @ModelAttribute Item item, BindingResult
bindingResult, RedirectAttributes redirectAttributes) {
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}";
}
spring-boot-starter-validation
라이브러리를 넣으면 Bean Validator를 인지하고 스프링에 통합한다Validator
는 @NotNull
과 같은 에노테이션을 보고 검증을 수행한다@Valid,@Validated
만 적용해 주면 FieldError
, ObjectError
를 생성해서 BindingResult
에 담아준다 검증 순서
BeanValidation
에서 바인딩에 실패한 필드는 BeanValidation
을 적용하지 않는다@ModelAttribute
에서 타입변환에 실패할 경우 BeanValidation
을 적용하지 않는다Bean Validation 오류 코드 사용 에시
@NotBlank(message = "공백은 입력할 수 없습니다.")
private String itemName;
Bean Validation 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 {
}
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;
}
}
저장 로직에 Groups 적용 예시
@PostMapping("/add")
public String addItemV2(@Validated(SaveCheck.class) @ModelAttribute Item item,
BindingResult bindingResult, RedirectAttributes redirectAttributes) {
//...
}
실무에서는 회원 등록시 회원과 관련된 데이터만 전달받는 것이 아니라, 약관 정보도 추가로 받는 등 Item
과 관계없는 수많은 부가 데이터가 넘어온다. 그래서 보통 Item
을 직접 전달 받는 것이 아닌 복잡한 폼의 데이터를 컨트롤러까지 전달할 별도의 객체를 만들어서 전달한다. 예를 들면 ItemSaveForm
이라는 폼을 전달받는 전용객체를 만들어서 @ModelAttribute
로 사용한다. 이로 인해 굳이 groups
를 사용하지 않아도 된다.
@Data
public class Item {
private Long id;
private String itemName;
private Integer price;
private Integer quantity;
}
ItemSaveForm
@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
@Data
public class ItemUpdateForm {
@NotNull
private Long id;
@NotBlank
private String itemName;
@NotNull
@Range(min = 1000, max = 1000000)
private Integer price;
//수정에서는 수량은 자유롭게 변경할 수 있다.
private Integer quantity;
}
폼 객체 바인딩
@PostMapping("/add")
public String addItem(@Validated @ModelAttribute("item") ItemSaveForm form,
BindingResult bindingResult, RedirectAttributes redirectAttributes) {
//...
}
출처: https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81-mvc-2