스프링 MVC(9)

5 minute read

검증1 - Validation

컨트롤러의 중요한 역할중 하나는 HTTP 요청이 정상인지 검증하는 것이다. 그리고 정상 로직보다 이런 검증 로직을 잘 개발하는 것이 더 어려울 수 있다.

참고 : 클라이언트 검증, 서버 검증

  • 클라이언트 검증은 조작할 수 있으므로 보안에 취약
  • 서버만으로 검증하면, 즉각적인 고객 사용성이 부족해진다.
  • 둘을 적절히 섞어서 사용하되, 최종적으로 서버 검증은 필수
  • API 방식을 사용하면 API 스펙을 잘 정의해서 검증 오류를 API응답 결과에 잘 남겨주어야 함

BindingResult1

  • 사용 입력폼 예시
<form action="item.html" th:action th:object="${item}" method="post">
    <div th:if="${#fields.hasGlobalErrors()}">
    <p class="field-error" th:each="err : ${#fields.globalErrors()}"
    th:text="${err}">글로벌 오류 메시지</p>
    </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>
        <div>
        <label for="price" th:text="#{label.item.price}">가격</label>
        <input type="text" id="price" th:field="*{price}"
        th:errorclass="field-error" class="form-control"
        placeholder="가격을 입력하세요">
            <div class="field-error" th:errors="*{price}">
            가격 오류
            </div>
        </div>
        <div>
        <label for="quantity" th:text="#{label.item.quantity}">수량</label>
        <input type="text" id="quantity" th:field="*{quantity}"
        th:errorclass="field-error" class="form-control"
        placeholder="수량을 입력하세요">
    <div class="field-error" th:errors="*{quantity}">
    수량 오류
    </div>
 </div>
  • #fields : BindingResult가 제공하는 검증 오류에 접근 가능
  • th:errors : 해당 필드에 오류가 있는 경우 태그를 출력한다. th:if의 편의 버전
  • th:errorclass : th:field에 지정한 필드에 오류가 있으면 class 정보를 추가한다.
@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}";
}
  • FieldError
    • public FieldError(String objectName, String field, String defaultMessage) {}
    • 필드에 오류가 있을때 사용
    • objectName : @ModelAttribute 이름
    • field : 오류가 발생한 필드 이름
    • defaultMessage : 오류 기본 메시지
  • ObjectError : 글로벌 오류

BindingResult2

  • 사용자 입력 오류 메시지가 화면에 남도록 하자.
@PostMapping("/add")
public String addItemV2(@ModelAttribute Item item, BindingResult bindingResult,RedirectAttributes redirectAttributes) {
    if (!StringUtils.hasText(item.getItemName())) {
    bindingResult.addError(new FieldError("item", "itemName",
    item.getItemName(), false, null, null, "상품 이름은 필수입니다."));
    }
    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 까지 허용합니다."));
    }
    if (item.getQuantity() == null || item.getQuantity() > 10000) {
    bindingResult.addError(new FieldError("item", "quantity",
    item.getQuantity(), false, null, null, "수량은 최대 9,999 까지 허용합니다."));
    }
    //특정 필드 예외가 아닌 전체 예외
    if (item.getPrice() != null && item.getQuantity() != null) {
    int resultPrice = item.getPrice() * item.getQuantity();
    if (resultPrice < 10000) {
    bindingResult.addError(new ObjectError("item", null, null, "가격 * 
    수량의 합은 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}";
}

  • 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 : 오류가 발생한 객체 이름
      • field : 오류 필드
      • rejectedValue : 사용자가 입력한 값(거절된 값)
      • bindingFailure : 타입 오류 같은 바인딩 실패인지, 검증 실패인지 구분 값
      • codes : 메시지 코드
      • arguments : 메시지에서 사용하는 인자
      • defaultMessage : 기본 오류 메시지
    • th:field = “*{price}”
      • 타임리프의 th:field는 매우 똑똑하게 동작하는데 오류가 발생하면 FieldError에서 보관한 값을 가져와서 출력한다.

오류 코드와 메시지 처리1

  • errors 메시지 파일 생성
    • messages.properties 를 사용해도 되지만, 오류 메시지를 구분하기 쉽게 errors.properties 라는별도의 파일로 관리해보자.
    • errors.properties 생성
  • 스프링 부트 메시지 설정 추가
    • application.properties -> spring.messages.basename=messages,errors
  • errors.properties 예시
    required.item.itemName=상품 이름은 필수입니다.
    range.item.price=가격은 {0} ~ {1} 까지 허용합니다.
    max.item.quantity=수량은 최대 {0} 까지 허용합니다.
    totalPriceMin=가격 * 수량의 합은 {0} 이상이어야 합니다. 현재  = {1}
    
@PostMapping("/add")
public String addItemV4(@ModelAttribute Item item, BindingResult bindingResult,RedirectAttributes redirectAttributes) {
    log.info("objectName={}", bindingResult.getObjectName());
    log.info("target={}", bindingResult.getTarget());
    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 "validation/v2/addForm";
    }
    //성공 로직
    Item savedItem = itemRepository.save(item);
    redirectAttributes.addAttribute("itemId", savedItem.getId());
    redirectAttributes.addAttribute("status", true);
    return "redirect:/validation/v2/items/{itemId}";
}
  • rejectValue()
    • void rejectValue(@Nullable String field, String errorCode,@Nullable Object[] errorArgs, @Nullable String defaultMessage);
      • field : 오류 필드 명
      • errorCode : 오류 코드(이 오류 코드는 메시지에 등록된 코드가 아니라 messageResolver를 위한 오류 코드)
      • errorsArgs : 오류 메시지에서 {0}을 치환하기 위한 값
      • defaultMessage : 오류 메시지를 찾을 수 없을 때 사용하는 기본 메시지

-> 오류 메시지가 정상 출력된다. 그런데 errors.proeprties에 있는 코드를 직접 입력하지 않았는데 어떻게 된 것일까? 예를들어 아래와 같이 코드가 있다고 가정하자

required = 필수 값 입니다.
required.item.itemName : 상품 이름은 필수 값 입니다.
  • required라고 오류 코드를 사용한다고 가정하면 required보단 required.item.itemName이 우선순위가 더 높아진다.
  • 스프링은 MessageCodesResolver라는 것으로 이러한 기능을 지원

Validator 분리 1

  • 복잡한 검증 로직이 많아 Controller에서 정상 로직보다 검증 로지깅 비중을 더 많이 차지하게 된다. 별도로 분리하자.

ItemValidator 클래스 만들기

package hello.itemservice.web.validation;
import hello.itemservice.domain.item.Item;
import org.springframework.stereotype.Component;
import org.springframework.validation.Errors;
import org.springframework.validation.ValidationUtils;
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;
        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() {} : 해당 검증기를 지원하는지 여부 확인(class가 다르면 지원X)
  • validate(Object target, Errors errors) : 검증 대상 객체와 BindingResult

Contoller 로직

private final ItemValidator itemValidator;

@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) {
    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}";
}
  • @Validated없이 itemValidator를 직접 불러와서 사용해도 되지만 @Validated를 붙이면 init에 등록한 검증기를 실행한다.
  • 여러 검증기를 init에 등록한다면 supports() 메소드를 이용하여 구분한다.

참고

  • 검증시 @Validated와 @Valid 둘다 사용가능하다.
  • @Valid를 사용하려면 build.gradle 의존관계를 추가해야 한다.(implementation ‘org.springframework.boot:spring-boot-starter-validation’)
  • @Validated는 스프링 전용 검증 애노테이션이고, @Valid는 자바 표준 검증 애노테이션이다.

출처 - 김영한의 스프링 MVC 2편

Leave a comment