검증1 - Validation

이성준·2022년 3월 10일
0

스프링 MVC

목록 보기
9/10

요구사항 추가

요구사항: 검증 로직 추가
타입 검증
가격, 수량에 문자가 들어가면 검증 오류 처리
필드 검증
상품명: 필수, 공백X
가격: 1000원 이상, 1백만원 이하
수량: 최대 9999
특정 필드의 범위를 넘어서는 검증
가격 * 수량의 합은 10,000원 이상

  • 컨트롤러의 중요한 역할 중 하나는 http 요청이 정상인지 검증하는것

클라이언트 검증, 서버 검증

클라이언트 검증은 조작가능,
서버만으로 검증하면, 즉각 고객 사용성이 부족함
결론 = 둘이 적절히 섞어서 사용하자

상품등록 메커니즘

오류 발생시

실패하면 검증 오류 결과를 포함하여 다시 상품등록 폼으로 간다.

v1 개발

 @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'"

  • 빨간박스가 쳐진다.

v1의 문제점

중복처리가 많음, 숫자 타입에 문자가 들어오면 컨트롤러에 진입하기도 전에 예외가 발생, 그리고 오류가 발생해도 고객이 입력한 값을 보여줘야한다.

v2 개발

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";
        }
  • BindingResult 파라미터 위치는 @ModelAttribute Item tiem 뒤에 와야함 왜냐면 @ModelAttribute 객체를 Binding 해야 하므로
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}

V3 개발

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}을 사용해서 치환할 값을 전달해준다.

V4 개발

하지만 이 방법은 너무 번거롭다
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 : 오류 메시지를 찾을 수 없을 때 사용하는 기본 메시지

MessageCodeResolver

검증 오류 코드로 메시지 코드들을 생성해준다.

  • FieldError
    -> 네 가지 오류 코드를 자동 생성
    code.object name.field
    code.field
    code.field type
    code

  • ObjectError
    code.object name
    code

  • 구체적인것부터 덜 구체적인것까지 찾아가면서 찾는다.

V5 개발

지금은 컨트롤러에 검증 로직이 다 있어서 컨트롤러가 하는일이 너무 많다 그러므로 검증 로직을 분리해 주자
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() : 해당 검증기를 지원하는지?

V5버젼 컨트롤러

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에 추가한다

V6 개발

@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() 가 호출된다.

0개의 댓글