[스프링 MVC 2편] 검증1 - Validation
포스트
취소

[스프링 MVC 2편] 검증1 - Validation

검증 요구사항

  • 지금까지 만든 웹 애플리케이션은 폼 입력시 숫자를 문자로 작성하거나해서 검증 오류가 발생하면 오류 화면으로 바로 이동한다.
    • 이렇게 되면 사용자는 처음부터 해당 폼으로 다시 이동해서 입력을 해야 한다.
  • 웹 서비스는 폼 입력시 오류가 발생하면, 고객이 입력한 데이터를 유지한 상태로 어떤 오류가 발생했는지 친절하게 알려주어야 한다.
  • 컨트롤러에서 이런 HTTP 요청이 정상인지 검증하는 작업이 매우 중요하다.

요구사항 추가

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

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

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

프로젝트 설정 (v1)

  • 스프링 이니셜라이저를 통해 프로젝트를 생성하자.
    • 프로젝트 선택
      • Project
        • Gradle - Groovy Project
      • Language
        • Java
      • Spring Boot
        • 3.x.x
    • Project Metadata
      • Group
        • hello
      • Artifact
        • validation
      • Name
        • validation
      • Package name
        • hello.validation
      • Packaging
        • Jar (주의!)
      • Java
        • 17 또는 21
    • Dependencies
      • Spring Web
      • Thymeleaf
      • Lombok

추가 설정

  • 이전 프로젝트인 message에서 일부 소스를 가져오자.
    • .java 파일의 패키지는 message 부분을 validation으로 변경하자.
    • 가져올 목록
      • src/main
        • java/hello/itemservice
          • domain/item
            • Item.java
            • ItemRepository.java
        • web/item/basic
          • BasicItemController.java
      • resources
        • static
          • css
            • bootstrap.min.css
          • index.html
        • templates/basic
          • addForm.html
          • editForm.html
          • item.html
          • items.html
        • messages.properties
        • messages_en.properties
  • 이번에는 프로젝트는 버전별로 검증 단계가 변화하는 과정을 확인할 것이다.
    • /basic을 일괄 변경으로 /validation/v1으로 변경하자.
  • BasicItemController의 이름을 ValidationItemControllerV1으로 변경하자.
  • ValidationItemControllerV1에서 basic/validation/v1으로 변경하자.
  • resources/templates/basic에 있는 html 파일들을 resources/templates/validation/v1 폴더 생성 후 이동하자.

검증 직접 처리 - 정의

  • 사용자가 상품 등록 폼에서 정상 범위의 데이터를 입력하면, 서버에서는 검증 로직이 통과하고, 상품을 저장하고, 상품 상세 화면으로 redirect한다.
  • 고객이 상품 등록 폼에서 상품명을 입력하지 않거나, 가격, 수량 등이 너무 작거나 커서 검증 범위를 넘어서면, 서버 검증 로직이 실패해야 한다.
    • 이렇게 검증에 실패한 경우 고객에게 다시 상품 등록 폼을 보여주고, 어떤 값을 잘못 입력했는지 친절하게 알려주어야 한다.

검증 직접 처리 - 개발

컨트롤러

  • ValidationItemControllerV1에서 addItemV1 ~ addItemV6까지 제거하자.
  • 대신에 아래 코드를 추가하자.
@PostMapping("/add")
public String addItem(@ModelAttribute Item item, RedirectAttributes redirectAttributes, Model model) {

    //검증 오류 결과를 보관
    Map<String, String> errors = new HashMap<>();

    //검증 로직
    //org.springframework.util.StringUtils 필요
    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}";
}
  • 검증시 오류가 발생하면 errors 에 담아둔다.
    • 이 때 어떤 필드에서 오류가 발생했는지 구분하기 위해 오류가 발생한 필드명을 key로 사용한다.
    • 이후 뷰에서 이 데이터를 사용해서 고객에게 친절한 오류 메시지를 출력할 수 있다.
  • 특정 필드를 넘어서는 오류를 처리해야 할 수도 있다.
    • 이때는 필드 이름을 넣을 수 없으므로 globalError라는 key를 사용한다.
  • 만약 검증에서 오류 메시지가 하나라도 있으면 오류 메시지를 출력하기 위해서는
    model에 errors를 담고, 입력 폼이 있는 뷰 템플릿으로 보낸다.

HTML

  • resources/templates/validation/v1/addForm.html을 아래와 같이 수정하자.
  • th:text="${errors['globalError']}">처럼 에러 메시지를 표기하자.
<!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 th:text="#{page.addItem}">상품 등록</h2>
    </div>
    <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" 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="price" th:text="#{label.item.price}">가격</label>
        <input type="text" id="price" th:field="*{price}" th:class="${errors?.containsKey('price')} ? 'form-control field-error' : 'form-control'" class="form-control" placeholder="가격을 입력하세요">
        <div class="field-error" th:if="${errors?.containsKey('price')}" th:text="${errors['price']}">
          가격 오류
        </div>
      </div>
      <div>
        <label for="quantity" th:text="#{label.item.quantity}">수량</label>
        <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>
      </div>
      <hr class="my-4">
      <div class="row">
        <div class="col">
          <button class="w-100 btn btn-primary btn-lg" type="submit" th:text="#{button.save}">저장</button>
        </div>
        <div class="col">
          <button class="w-100 btn btn-secondary btn-lg"
                  onclick="location.href='items.html'"
                  th:onclick="|location.href='@{/validation/v1/items}'|"
                  type="button" th:text="#{button.cancel}">취소</button>
        </div>
      </div>
    </form>
  </div> <!-- /container -->
  </body>
</html>

Safe Navigation Operator

  • 만약 th:text="${errors['globalError']}">에서 error가 null이면 어떻게 될까?
    • 등록폼에 진입한 시점에는 errors 가 없다.
    • 그래서 errors.containsKey()를 호출하는 순간 NullPointerException이 발생한다.
  • 그래서 errors.containsKey('quantity')errors?.containsKey('quantity')처럼 변경해서 사용한다.
    • ?. 앞에 붙이게 되면 대상 객체가 null이 아닐 때만 동작하게 된다.

프로젝트 설정 (v2)

  • 이번에는 다음 스텝으로 넘어가보자.
  • ValidationItemControllerV1를 그대로 복사해서 ValidationItemControllerV2를 만들자.
  • ValidationItemControllerV2에서 클래스명을 제외하고 모든 v1v2로 변경하자.
  • resources/templates/validation 경로에서 v1 폴더에 있는 모든 파일을 v2 폴더를 생성한 후 복사 및 붙여넣기를 해주자.

BindingResult (1)

  • 스프링이 제공하는 검증 오류 처리 방법을 알아보자.
  • 스프링에서는 BindingResult을 통해 오류를 검증한다.

컨트롤러

  • ValidationItemControllerV2에서 기존의 addItem을 주석 처리한 후에 아래의 코드를 추가하자.
    • 여기서 주의할 점은 BindingResult bindingResult 파라미터의 위치는 @ModelAttribute Item item 다음에 와야 한다.
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) {}
  • 필드에 오류가 있으면 FieldError 객체를 생성해서 bindingResult에 담아두면 된다.
  • 파라미터
    • objectName
      • @ModelAttribute 이름
    • field
      • 오류가 발생한 필드 이름
    • defaultMessage
      • 오류 기본 메시지

ObjectError

  • public ObjectError(String objectName, String defaultMessage) {}
  • 특정 필드를 넘어서는 오류가 있으면 ObjectError 객체를 생성해서 bindingResult에 담아두면 된다.
  • 파라미터
    • objectName
      • @ModelAttribute 의 이름
    • defaultMessage
      • 오류 기본 메시지

HTML

  • resources/templates/validation/v2/addForm.html에서 form 내부의 오류 메시지 영역을 아래와 같이 수정하자.
<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>

타임리프 스프링 검증 오류 통합 기능

  • 타임리프는 스프링의 BindingResult를 활용해서 편리하게 검증 오류를 표현하는 기능을 제공한다.
  • 종류
    • #fields
      • #fieldsBindingResult가 제공하는 검증 오류에 접근할 수 있다.
    • th:errors
      • 해당 필드에 오류가 있는 경우에 태그를 출력한다.
      • th:if의 편의 버전이다.
    • th:errorclass
      • th:field에서 지정한 필드에 오류가 있으면 class 정보를 추가한다.
  • 메뉴얼

BindingResult (2)

  • BindingResult는 스프링이 제공하는 검증 오류를 보관하는 객체이다.
    • 검증 오류가 발생하면 여기에 보관하면 된다.
  • BindingResult가 있으면 @ModelAttribute에 데이터 바인딩 시 오류가 발생해도 컨트롤러가 호출된다!

@ModelAttribute에 바인딩 시 타입 오류가 발생하면?

  • BindingResult가 없는 경우
    • 400 오류가 발생하면서 컨트롤러가 호출되지 않고, 오류 페이지로 이동한다.
  • BindingResult가 있는 경우
    • 오류 정보(FieldError)를 BindingResult에 담아서 컨트롤러를 정상 호출한다.

BindingResult에 검증 오류를 적용하는 3가지 방법

  • @ModelAttribute 의 객체에 타입 오류 등으로 바인딩이 실패하는 경우 스프링이 FieldError 생성해서 BindingResult 에 넣어준다.
  • 개발자가 직접 넣어준다.
  • Validator 사용

BindingResult 사용 시 알아야 사항

  • 메서드의 파라미터로 사용할 때 BindingResult는 검증할 대상 바로 다음에 와야한다.
  • 만약에 Item에 대해 검증하려고 한다면 @ModelAttribute Item item 바로 다음에 BindingResult가 와야 한다.
  • BindingResult는 Model에 자동으로 포함된다

BindingResult와 Errors

  • BindingResult는 인터페이스이고, Errors 인터페이스를 상속받고 있다.
    • 실제 넘어오는 구현체는 BeanPropertyBindingResult이다.
    • 그런데 BeanPropertyBindingResultBindingResultErrors 둘다 구현하고 있다.
    • 그래서 BindingResult 대신에 Errors를 사용해도 된다.
  • Errors 인터페이스는 단순한 오류 저장과 조회 기능을 제공한다.
    • BindingResult는 여기에 더해서 추가적인 기능들을 제공한다.
    • addError()BindingResult가 제공한다.
  • 주로 관례상 BindingResult 를 많이 사용한다.
  • 패키지
    • BindingResult
      • org.springframework.validation.BindingResult
    • Errors
      • org.springframework.validation.Errors

FieldError, ObjectError

  • 이번에는 데 오류가 발생하는 경우 고객이 입력한 내용이 모두 사라지는 현상을 고쳐보자.

컨트롤러

  • ValidationItemControllerV2에서 addItemV1을 주석 처리하고 아래의 코드를 추가하자.
@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 생성자

  • 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
      • 기본 오류 메시지
  • ObjectError 도 유사하게 두 가지 생성자를 제공한다.
  • 사용자의 입력 데이터가 컨트롤러의 @ModelAttribute 에 바인딩되는 시점에 오류가 발생하면 모델 객체에 사용자 입력 값을 유지하기 어렵다.
    • 만약에 가격에 숫자가 아닌 문자가 입력된다면 가격은 Integer 타입이므로 문자를 보관할 수 있는 방법이 없다.
    • 그래서 오류가 발생한 경우 사용자 입력 값을 보관하는 별도의 방법이 필요하다.
    • 그리고 이렇게 보관한 사용자 입력 값을 검증 오류 발생시 화면에 다시 출력하면 된다.
  • FieldError는 오류 발생시 사용자 입력 값을 저장하는 기능을 제공한다.
    • FieldError에서 rejectedValue가 바로 오류 발생시 사용자 입력 값을 저장하는 필드다.
    • bindingFailure는 타입 오류 같은 바인딩이 실패했는지 여부를 적어주면 된다.
      • 여기서는 바인딩이 실패한 것은 아니기 때문에 false 를 사용한다.

타임리프의 사용자 입력 값 유지

  • 타임리프의 th:field 는 매우 똑똑하게 동작한다.
    • 정상 상황에는 모델 객체의 값을 사용한다.
    • 오류가 발생하면 FieldError에서 보관한 값을 사용해서 값을 출력한다.
  • th:field="*{price}"같은 경우를 의미한다.

스프링의 바인딩 오류 처리

  • 타입 오류로 바인딩에 실패하면 스프링은 FieldError를 생성하면서 사용자가 입력한 값을 넣어둔다.
    • 그리고 해당 오류를 BindingResult에 담아서 컨트롤러를 호출한다.
  • 즉, 타입 오류 같은 바인딩 실패시에도 사용자의 오류 메시지를 정상 출력할 수 있다.

오류 코드와 메시지 처리1

  • 이번에는 오류 메시지를 관리하는 방법을 알아보자.
  • 기존의 messages.properties를 사용해도 되긴 하다.
    • 하지만 쉬운 관리를 위해 errors.properties를 생성하자.
  • errors.properties도 스프링이 알아서 인식할 수 있게 application.properties를 수정하자.
    • spring.messages.basename=messages,errors
  • 참고로 spring.messages.basename으로 정의한 이름들은 모두 국제화가 가능하다.
    • 즉, errors_en.properties라고 만들면 영문 버전이 되는 것이다.

메세지 추가

  • errors.properties 추가 후 아래 내용을 추가하자.
required.item.itemName=상품 이름은 필수입니다.
range.item.price=가격은 {0} ~ {1} 까지 허용합니다.
max.item.quantity=수량은 최대 {0} 까지 허용합니다.
totalPriceMin=가격 * 수량의 합은 {0}원 이상이어야 합니다. 현재 값 = {1}

컨트롤러

  • ValidationItemControllerV2에서 addItemV2을 주석 처리하고 아래의 코드를 추가하자.
@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 "validation/v2/addForm";
    }
    //성공 로직
    Item savedItem = itemRepository.save(item);
    redirectAttributes.addAttribute("itemId", savedItem.getId());
    redirectAttributes.addAttribute("status", true);
    return "redirect:/validation/v2/items/{itemId}";
}
  • FieldError에 배열을 넘기면 errors.properties에 정의된 변수 영역에 값을 치환할 수 있다.

오류 코드와 메시지 처리2

  • FieldError, ObjectError는 다루기 너무 번거롭다.
  • 그러면 이번에는 BindingResult가 제공하는 rejectValue()reject()를 사용해보자.

컨트롤러

  • ValidationItemControllerV2에서 addItemV3을 주석 처리하고 아래의 코드를 추가하자.
@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}";
}
  • errors.properties에 있는 코드를 직접 입력하지 않았는데 오류 메시지가 정상 출력된다.

rejectValue()

  • void rejectValue(@Nullable String field, String errorCode, @Nullable Object[] errorArgs, @Nullable String defaultMessage);
  • 파라미터 종류
    • field
      • 오류 필드명
    • errorCode
      • 오류 코드
      • 이 오류 코드는 메시지에 등록된 코드가 아니다.
      • messageResolver라는 것을 위한 오류 코드이다.
    • errorArgs
      • 오류 메시지에서 {0}을 치환하기 위한 값
    • defaultMessage
      • 오류 메시지를 찾을 수 없을 때 사용하는 기본 메시지
  • BindingResult는 어떤 객체를 대상으로 검증하는지 target을 이미 알고 있다.
    • 그래서 target(item)에 대한 정보는 없어도 된다.

축약된 오류 코드

  • FieldError()를 직접 다룰 때는 오류 코드를 range.item.price와 같이 모두 입력했다.
    • 그런데 rejectValue()를 사용하고 부터는 오류 코드를 range로 간단하게 입력했다.
    • 그래도 오류 메시지를 잘 찾아서 출력한다.
  • MessageCodesResolver를 통한 규칙이 이를 가능하게 한다.

오류 코드와 메시지 처리3

  • 오류 코드는 다양하게 만들 수 있다.
    • 자세한 버전
      • range.item.price =상품의 가격 범위 오류 입니다.
    • 간단한 버전
      • range=범위 오류 입니다.
  • 단순하게 만들면 범용성이 좋아서 여러곳에서 사용할 수 있다.
    • 대신에 메시지를 세밀하게 작성하기 어렵다.
  • 그렇다고 너무 자세하게 만들면 범용성이 떨어진다.
  • 그래서 가장 좋은 방법은 범용성으로 사용하다가, 세밀하게 작성해야 하는 경우에는 세밀한 내용이 적용되도록 메시지에 단계를 두는 방법이다.
  • required.item.itemNamerequired를 비교하면 당연히 required.item.itemName가 세밀한 메시지 코드다.
    • 스프링은 메시지 코드의 우선순위에 따라 자동으로 알맞는 메시지를 가져온다.
    • 이는 스프링이 MessageCodesResolver을 통해 제공하는 기능이다.
    • xxx.yyy.zzz처럼 .이 많이 들어갈 수록 세밀하다고 판단하여 높은 우선순위로 보여지는 메시지가 된다.

메시지 추가

  • errors.properties에 아래와 같이 추가하자.
    • 코드가 겹치니 첫번째 required.item.itemName는 주석 처리하자.
#Level1
required.item.itemName: 상품 이름은 필수 입니다.

#Level2
required: 필수 값 입니다. 

오류 코드와 메시지 처리4

  • 테스트를 통해 MessageCodesResolver에 대해 알아보자.

테스트 생성

package hello.validation;

import org.junit.jupiter.api.Test;
import org.springframework.validation.DefaultMessageCodesResolver;
import org.springframework.validation.MessageCodesResolver;
import static org.assertj.core.api.Assertions.assertThat;

public class MessageCodesResolverTest {
    MessageCodesResolver codesResolver = new DefaultMessageCodesResolver();

    @Test
    void messageCodesResolverObject() {
        String[] messageCodes = codesResolver.resolveMessageCodes("required", "item");
        assertThat(messageCodes).containsExactly("required.item", "required");
    }
    @Test
    void messageCodesResolverField() {
        String[] messageCodes = codesResolver.resolveMessageCodes("required", "item", "itemName", String.class);
        assertThat(messageCodes).containsExactly(
                "required.item.itemName",
                "required.itemName",
                "required.java.lang.String",
                "required"
        );
    }
}

MessageCodesResolver

  • 검증 오류 코드로 메시지 코드들을 생성한다.
  • MessageCodesResolver는 인터페이스이고, 기본 구현체는 DefaultMessageCodesResolver이다.
  • 주로 ObjectError와 FieldError를 함께 사용한다.

DefaultMessageCodesResolver의 기본 메시지 생성 규칙

  • 객체 오류
    1. code + . + object name
    2. code

      예) 오류 코드: required, object name: item
      1.: required.item
      2.: required

  • 필드 오류
    1. code + . + object name + . + field
    2. code + . + field
    3. code + . + field type
    4. code

      예) 오류 코드: typeMismatch, object name “user”, field “age”, field type: int

      1. “typeMismatch.user.age”
      2. “typeMismatch.age”
      3. “typeMismatch.int”
      4. “typeMismatch”

동작 방식

  • rejectValue()와 reject()는 내부에서 MessageCodesResolver를 사용한다.
    • 여기에서 메시지 코드들을 생성한다.
  • FieldErrorObjectError의 생성자를 보면, 오류 코드를 하나가 아니라 여러 오류 코드를 가질 수 있다.
    • MessageCodesResolver를 통해서 생성된 순서대로 오류 코드를 보관한다.

오류 코드와 메시지 처리5

  • 오류 코드는 관리하기 위한 전략이 필요하다.
  • 핵심은 구체적인 것을 먼저 만들고 덜 구체적인 것을 후순위로 만드는 것이다.

왜 이렇게 복잡하게 사용해야 할까?

  • 모든 오류 코드에 대해서 메시지를 각각 다 정의하면 개발자 입장에서 관리하기 너무 힘들다.
    • 극단적인 예시를 든다면 필드가 1,000건이 있다면 오류 메시지도 1,000건을 정의해야 한다.
  • 그래서 크게 중요하지 않은 메시지는 범용성 있는 requried같은 메시지로 간단하게 만든다.
  • 정말 중요한 메시지만 구체적으로 적어서 사용하는 방식이 더 효과적이다.

메시지 추가

  • 이번에는 errors.properties에 이런 오류 코드 전략을 도입해보자.
    • 기존 내용 대신에 아래 내용으로 덮어씌우자.
#required.item.itemName=상품 이름은 필수입니다.
#range.item.price=가격은 {0} ~ {1} 까지 허용합니다.
#max.item.quantity=수량은 최대 {0} 까지 허용합니다.
#totalPriceMin=가격 * 수량의 합은 {0}원 이상이어야 합니다. 현재 값 = {1}

#==ObjectError==

#Level1
totalPriceMin.item=상품의 가격 * 수량의 합은 {0}원 이상이어야 합니다. 현재 값 = {1}

#Level2 - 생략
totalPriceMin=전체 가격은 {0}원 이상이어야 합니다. 현재 값 = {1}

#==FieldError==

#Level1
required.item.itemName=상품 이름은 필수입니다.
range.item.price=가격은 {0} ~ {1} 까지 허용합니다.
max.item.quantity=수량은 최대 {0} 까지 허용합니다.

#Level2 - 생략

#Level3
required.java.lang.String = 필수 문자입니다.
required.java.lang.Integer = 필수 숫자입니다.
min.java.lang.String = {0} 이상의 문자를 입력해주세요.
min.java.lang.Integer = {0} 이상의 숫자를 입력해주세요.
range.java.lang.String = {0} ~ {1} 까지의 문자를 입력해주세요.
range.java.lang.Integer = {0} ~ {1} 까지의 숫자를 입력해주세요.
max.java.lang.String = {0} 까지의 문자를 허용합니다.
max.java.lang.Integer = {0} 까지의 숫자를 허용합니다.

#Level4
required = 필수 값 입니다.
min= {0} 이상이어야 합니다.
range= {0} ~ {1} 범위를 허용합니다.
max= {0} 까지 허용합니다.
  • 객체 오류와 필드 오류를 나누고, 또 그 안에서 다시 범용성에 따라 레벨을 나누어두었다.
  • 만약 itemName의 경우에는 required 검증 오류 메시지가 발생하면 아래 순서대로 메시지가 생성된다.
    1. required.item.itemName
    2. required.itemName
    3. required.java.lang.String
    4. required
  • 이렇게 생성된 메시지 코드를 기반으로 순서대로 MessageSource에서 구체적인 것에서 덜 구체적인 순서대로 메시지에서 찾는다.
    • 메시지에 1번이 없으면 2번을 찾고, 2번이 없으면 3번을 찾는다.
    • 즉, 크게 중요하지 않은 오류 메시지는 기존에 정의된 것을 재활용할 수 있다.

오류 코드와 메시지 처리6

  • 검증 오류 코드는 다음과 같이 2가지로 나눌 수 있다.
    • 개발자가 직접 설정한 오류 코드 → rejectValue()를 직접 호출
    • 스프링이 직접 검증 오류에 추가한 경우
      • 주로 타입 정보가 맞지 않는다.

ValidationUtils

  • 유효성 검증을 위한 간단한 유틸리티 클래스다.
  • 제공하는 기능은 Empty, 공백 같은 단순한 기능만 제공한다.

  • 기존에는 이런 코드였다고 가정해보자.
if (!StringUtils.hasText(item.getItemName())) {
    bindingResult.rejectValue("itemName", "required", "기본: 상품 이름은 필수입니다.");
}
  • ValidationUtils를 사용하면 아래와 같이 바꿀 수 있다.
ValidationUtils.rejectIfEmptyOrWhitespace(bindingResult, "itemName", "required");

Validator 분리1

  • 컨트롤러에서 검증 로직이 차지하는 부분은 매우 크다.
  • 이런 경우 별도의 클래스로 역할을 분리하는 것이 좋다.

Validator 인터페이스

  • 스프링은 검증을 체계적으로 제공하기 위해 다음 인터페이스를 제공한다.
public interface Validator {
    boolean supports(Class<?> clazz);
    void validate(Object target, Errors errors);
}
  • supports()는 해당 검증기를 지원하는 여부를 확인한다.
  • validate(Object target, Errors errors)는 검증 대상 객체와 BindingResult를 통해 검증을 진행한다.

Validator 구현

  • 실제로 유효성 검사기를 구현해보자.
package hello.validation.web.item.validation;

import hello.validation.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);
            }
        }
    }
}
  • Item.class.isAssignableFrom를 통해 Item 클래스만 해당 검사기를 사용할 수 있게 하였다.

Validator 적용

  • ValidationItemControllerV2에서 addItemV4을 주석 처리하고 아래의 코드를 추가하자.
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}";
}
  • 유효성 검사기를 별도 클래스로 분리하여 컨트롤러의 검증 부분의 양이 엄청나게 줄었다.
  • 게다가 Item 클래스에 대해서는 해당 유효성 검사기는 재사용할 수 있다.

Validator 분리2

  • 스프링이 Validator 인터페이스를 별도로 제공하는 이유는 체계적으로 검증 기능을 도입하기 위해서다.
    • 그런데 앞에서는 검증기를 직접 불러서 사용했다.
    • 실제로 이렇게 사용해도 된다.
  • 그런데 Validator 인터페이스를 사용해서 검증기를 만들면 스프링의 추가적인 도움을 받을 수 있다.

WebDataBinder를 통해서 사용하기

  • WebDataBinder는 스프링의 파라미터 바인딩의 역할을 해주고 검증 기능도 내부에 포함한다.
  • ValidationItemControllerV2에 아래 코드를 추가하자.
@InitBinder
public void init(WebDataBinder dataBinder) {
    log.info("init binder {}", dataBinder);
    dataBinder.addValidators(itemValidator);
}

WebDataBinder 적용

  • ValidationItemControllerV2에서 addItemV5을 주석 처리하고 아래의 코드를 추가하자.
@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}";
}

addItemV6의 동작 방식

  • @Validated는 검증기를 실행하라는 애노테이션이다.
  • @Validated 애노테이션이 붙으면 앞서 WebDataBinder에 등록한 검증기를 찾아서 실행한다.
    • 그런데 여러 검증기를 등록한다면 그 중에 어떤 검증기가 실행되어야 할지 구분이 필요하다.
    • 이 때 supports() 가 사용된다.
    • 여기서는 supports(Item.class) 호출되고, 결과가 true이므로 ItemValidator의 validate()가 호출된다.

글로벌 설정

  • 모든 컨트롤러에 다 적용하는 방법도 있다.
  1. @SpringBootApplication 애노테이션이 있는 메인 클래스에 implements WebMvcConfigurer를 추가한다.
  2. 아래 코드를 추가한다.
//org.springframework.validation.Validator
@Override
public Validator getValidator() {
    //return WebMvcConfigurer.super.getValidator();

    return new ItemValidator();
}

글로벌 설정 시 주의사항

  • 글로벌 설정을 하면 BeanValidator라는 것이 자동 등록되지 않는다.
  • 게다가 글로벌 설정을 직접 사용하는 경우는 드물다.

@Validated와 @Valid

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

출처

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.