요구사항: 검증 로직 추가
타입 검증
가격, 수량에 문자가 들어가면 검증 오류 처리
필드 검증
상품명: 필수, 공백X
가격: 1000원 이상, 1백만원 이하
수량: 최대 9999
특정 필드의 범위를 넘어서는 검증
가격 * 수량의 합은 10,000원 이상
클라이언트 검증은 조작가능,
서버만으로 검증하면, 즉각 고객 사용성이 부족함
결론 = 둘이 적절히 섞어서 사용하자
상품등록 메커니즘
오류 발생시
실패하면 검증 오류 결과를 포함하여 다시 상품등록 폼으로 간다.
@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()){
log.info("errors = {}", errors);
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}";
}
erros Map을 하나 만들어서 에러가 생길때마다 계속 담는다 그리고 마지막에 error가 비어있지않으면 addform으로 다시 넘어간다.
addform.html
<div th:if="${errors?.containsKey('globalError')}">
<p class = "field-error" th:text="${errors['globalError']}">전체 오류 메시지</p>
</div>
erros?. : erros가 null이면 무시하는 로직
th:class="${errors?.containsKey('itemName')} ? 'form-control field-error' : 'form-control'"
중복처리가 많음, 숫자 타입에 문자가 들어오면 컨트롤러에 진입하기도 전에 예외가 발생, 그리고 오류가 발생해도 고객이 입력한 값을 보여줘야한다.
ValidationItemControllerV2
@PostMapping("/add")
public String addItem(@ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model) {
//검증 로직
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("globalError", "가격 * 수량의 합은 10,000원 이상이어야 합니다. 현재 값 = " + resultPrice));
}
}
//검증에 실패하면 다시 입력 폼으로
if (bindingResult.hasErrors()) {
log.info("errors = {}", bindingResult);
return "validation/v2/addForm";
}
public FieldError(String objectName, String field, String defaultMessage) {}
objectName : @ModelAttribute 이름,
field : 오류가 발생한 필드 이름
defualtMessage : 오류 기본 메시지
bindingResult를 사용하도록 고친다.
<div>
<label for="itemName" th:text="#{label.item.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="itemName" th:text="#{label.item.itemName}">상품명</label>
<input type="text" id="itemName" th:field="*{itemName}"
th:errorclass="field-error" class="form-control" placeholder="이름을 입력하세요">
<div class="field-error" th:errors="*{itemName}">
상품명 오류
</div>
</div>
타임리프는 스프링의 BindingResult 를 활용해서 편리하게 검증 오류를 표현하는 기능을 제공한다.
#fields : #fields 로 BindingResult 가 제공하는 검증 오류에 접근할 수 있다.
th:errors : 해당 필드에 오류가 있는 경우에 태그를 출력한다. th:if 의 편의 버전이다.
th:errorclass : th:field 에서 지정한 필드에 오류가 있으면 class 정보를 추가한다.
messages.properties를 사용해도 좋지만 오류 메시지를 구분하기 쉽게 errors.properties라는 파일로 관리한다.
errors.properties
required.item.itemName=상품 이름은 필수입니다.
range.item.price=가격은 {0} ~ {1} 까지 허용합니다.
max.item.quantity=수량은 최대 {0} 까지 허용합니다.
totalPriceMin=가격 * 수량의 합은 {0}원 이상이어야 합니다. 현재 값 = {1}
error에 등록한 메시지를 사용하도록 변경
if (!StringUtils.hasText(item.getItemName())) {
bindingResult.addError(new FieldError("item", "itemName", item.getItemName(), false,new String[]{"required.item.itemName"},null, null));
}
메시지코드는 배열로 여러값을 전달할 수 있다. arguments로 Object[]{1000, 1000000}
을 사용해서 치환할 값을 전달해준다.
하지만 이 방법은 너무 번거롭다
rejectValue()나 reject()를 쓴다.
if (!StringUtils.hasText(item.getItemName())) {
bindingResult.rejectValue("itemName", "required");
}
rejectvalue
void rejectValue(@Nullable String field, String errorCode,
@Nullable Object[] errorArgs, @Nullable String defaultMessage);
field
: 오류 필드명
errorCode
: 오류 코드(이 오류 코드는 메시지에 등록된 코드가 아니다. 뒤에서 설명할 messageResolver를 위한 오류 코드이다.)
errorArgs
: 오류 메시지에서 {0} 을 치환하기 위한 값
defaultMessage
: 오류 메시지를 찾을 수 없을 때 사용하는 기본 메시지
검증 오류 코드로 메시지 코드들을 생성해준다.
FieldError
-> 네 가지 오류 코드를 자동 생성
code.object name.field
code.field
code.field type
code
ObjectError
code.object name
code
구체적인것부터 덜 구체적인것까지 찾아가면서 찾는다.
지금은 컨트롤러에 검증 로직이 다 있어서 컨트롤러가 하는일이 너무 많다 그러므로 검증 로직을 분리해 주자
ItemValidator
@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);
}
}
}
}
supports()
: 해당 검증기를 지원하는지?
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 를 스프링 빈으로 주입 받아서 직접 호출했다.
@InitBinder
public void init(WebDataBinder dataBinder) {
log.info("init binder {}", dataBinder);
dataBinder.addValidators(itemValidator);
}
스프링의 파라미터 바인딩의 역할을 해주고 검증 기능도 내부에 포함해주기 위해 ValidationItemControllerV2에 추가한다
@PostMapping("/add")
public String addItemV6(@Validated @ModelAttribute Item item, BindingResult
bindingResult, RedirectAttributes redirectAttributes) {
if (bindingResult.hasErrors()) {
log.info("errors={}", bindingResult);
return "validation/v2/addForm";
}
validator 호출 부분 삭제, 검증 대상 앞에 @Validated 추가
@Validated
는 검증기를 실행하라는 애노테이션이다.
이 애노테이션이 붙으면 앞서 WebDataBinder 에 등록한 검증기를 찾아서 실행한다. 그런데 여러 검증기를 등록한다면 그 중에 어떤 검증기가 실행되어야 할지 구분이 필요하다. 이때 supports() 가 사용된다. 여기서는 supports(Item.class) 호출되고, 결과가 true 이므로 ItemValidator 의 validate() 가 호출된다.