검증1 - BindingResult랑 에러코드,오류메세지 설정

민지·2024년 6월 18일
0

SpringMVC2

목록 보기
2/6

핵심

이 강의안의 핵심은

  • BindingResult의 작동원리
  • 에러코드와 오류메세지 설정은 어떻게 하는지, 를 파악하는 것.
1. BindingResult 작동원리 (p.~18)
	- BindingResult와 FieldError, ObjectError + 타임리프 검증오류기능
    
2. 오류코드와 메세지 처리 (p.19~)
	- FieldError/ObjectError 대신 rejectValue, reject 함수 도입 !!
    - rejectValue/reject의 errorCode - MessageCodesResolverTest - new FieldError 객체의 messageCodes 의 흐름

3. !! 전체흐름 remind !! 

1. BindingResult 작동원리

핵심

  1. BindingResult가 있으면 @ModelAttribute에 데이터바인딩 시 오류가 발생해도 컨트롤러가 호출된다 !! (에러메세지 안 뜨고)

  2. BindingResult에 검증오류 적용하는 2가지 방법

    1. @ModelAttribute 객체에 타입오류 등으로 바인딩이 실패하는 경우, '스프링'이 FieldError 생성해서 알아서 BindingResult에 넣어준다.

    2. 개발자가 직접 BindingResult에 넣어준다.

  3. (주의!)

    1. BindingResult는 순서가 중요하다. 예를 들어, @ModelAttribute Item item, 바로 다음에 BindingResult가 와야 한다.
    2. BindingResult는 Model에 자동으로 포함된다.
    3. BindingResult는 인터페이스이고, Errors 인터페이스를 상속받고 있다.

아래 스텝별로 따라가서 BindingResult의 작동원리를 파악하자.

BindingResult와 FieldError / ObjectError + 타입리프의 검증오류기능

  • FieldError - ver1 (addItemV1을 의미)

    객체의 '필드'에 에러가 있다면, FieldError 객체를 생성해서 bindingResult에 담아두면 된다.

    if (!StringUtils.hasText(item.getItemName())) {
     bindingResult.addError(new FieldError("item", "itemName", "상품 이름은 필수입니
    다."))

    이때 FieldError 생성자는 다음과 같다.

    public FieldError(String objectName, String field, String defaultMessage) {}
    
    -- objectName : @ModelAttribute 이름
    -- field : 오류가 발생한 필드 이름
    -- defaultMessage : 오류기본메세지

  • ObjectError - ver1
    하지만, 객체의 필드가 아닌 '객체자체'에 오류가 있다면 ObjectError 객체를 생성해서 bindingResult에 담아두면 된다.

    // 특정 필드가 아닌 복합 룰 검증
    if (item.getPrice() != null && item.getQuantity() != null){
     int resultPrice = item.getPrice() * item.getQuantity();
    
     if (resultPrice < 10000){
     // 특정 필드 예외가 아닌 전체 예외
     bindingResult.addError(new ObjectError("item", "가격 * 수량의 합은 10,000원 이상이어야 합니다. 현재값 : " + resultPrice));
        }
    }

    ObjectError 생성자는 다음과 같다.

    public ObjectError(String objectName, String defaultMessage) {}

하지만 위와 같이 간단한 FieldError와 ObjectError 생성자로는, 오류가 발생하는 경우 기존에 고객에 입력했던 정보가 모두 사라진다.
이 경우는, 보다 복잡한 FieldError와 ObjectError의 생성자로 해결가능하다.

  • FieldError - ver2 (addItemV2)

    역시 필드에 에러가 있다면, FieldError 객체를 만들어, bindingResult에 담으면 된다.

    if (!StringUtils.hasText(item.getItemName())) {
     bindingResult.addError(new FieldError("item", "itemName",
    item.getItemName(), false, null, null, "상품 이름은 필수입니다."));
    }

    이때, 오버로딩된 모든 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 : 오류가 발생한 객체이름 (@ModelAttribute 이름)
    -- field : 에러 필드
    -- rejectedValue : 사용자가 입력한 값 (이걸 입력해서, 에러난 값 기억하는것)
    -- bindingFailure : 타입오류가 났으면 바인딩 실패, 아니면 성공
    -- codes : 메세지 코드
    -- arguements : 메세지에서 사용하는 인자
    -- defaultMessage : 기본 오류 메세지

  • ObjectError - ver2 (addItemV2)
    ObjectError도 같은 방식으로 적용된다.

    //특정 필드 예외가 아닌 전체 예외
     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));
       }
     }
    
     -- 순서대로 인자정보는 objectName, codes, argumemts. defaultMessage이다.

  • 타임리프에서의 검증오류기능
    타입리프는 스프링의 BindingResult를 활용해 편리하게 검증오류를 표현해준다.

    • #fields : #fields로 BindingResult가 제공하는 검증오류에 접근 가능
    • th:errors : 해당 필드에 오류가 있는 경우, 태그 출력
    • th:errorclass : th:field에서 지정한 필드에 오류가 있으면 class 정보를 추가

    아래 코드 주석을 보면, th:errors랑 th:field가 보다 더 똑똑하게 작동하는 것 확인 가능 !!

    -- addForm.html body 태그만 복붙 (플젝 같이 보면 좋지요)
    
    <div class="container">
        <div class="py-5 text-center">
            <h2 th:text="#{page.addItem}">상품 등록</h2>
        </div>
    
        <form 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>
                <!--  th:errors로 ""BindingResult에서"" 설정한 에러메세지가 보여지고,
                      th:field는 정상 상태라면 모델 객체 값을, 에러가 발생했다면 FieldError에서 보관한 값으로 값 출력..
                      th:errorClass는 해당 th:field로 에러가 있다면 지정한 클래스(field-error)를, class에 추가..-->
            </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>
    
            <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/v3/items}'|"
                            type="button" th:text="#{button.cancel}">취소</button>
                </div>
            </div>
    
        </form>
      </div> <!-- /container -->

FieldError와 ObjectError의 각각 2가지 생성자를 BindingResult에 추가했다. 그리고 난뒤, 뷰에서 타임리프로 제공해주는 #fields, th:error, th:errorClass를 기반으로 에러메시지를 띄워줬다.

그런데, th:errors에서는 'BindingResult'에서 설정한 에러메세지를 보여준다고 했다. (없다면, 개발자가 생성자에서 설정한 defaultMessage, 걔도 없다면 스프링에서 생성한 디폴트에러메세지)

그런데, 이 에러메세지의 작동원리는 어떤지 정리해볼 필요가 있다.



2. 오류코드와 메세지 처리

(이 부분은 핵심만 기록하고, 강의안 / 코드를 같이 볼 것.)

앞서, FieldError 생성자와 ObjectError 생성자의 codes, arguments를 제공한 것을 확인했다. 이 인자는, 오류발생시 오류 코드로 메세지를 찾기 위해 사용된다 !!!

  • 사전 준비사항

    • errors 메세지 파일 생성
      messages.properties 사용해도 되지만 구분해주는 게 좋으니깐, errors.properties 별도의 파일을 만들어 관리하자.

    • application.properties
      spring.messages.basename=messages,errors
      추가한 에러파일을 인식할 수 있도록, 설정파일 수정
  • 버전별 addItem

    • addItemV3 - codes, arguments 채움

      //range.item.price=가격은 {0} ~ {1} 까지 허용합니다.
      new FieldError("item", "price", item.getPrice(), false, new String[]
      {"range.item.price"}, new Object[]{1000, 1000000}
      
      -- codes : required.item.itemName으로 메세지 코드 지정.
      -- arguments : 1000{0}, 1000000{1}로 치환할 값을 전달한다.

    • addItemV4 - rejectValue, reject함수 도입 !!

      FieldError, ObjectError를 풀로 다 쓰는 건 부담스럽다..

      BindingResult가 제공하는, rejectValuereject를 사용하면 각각 FieldError와 ObjectError를 생성하지 않고, 깔끔하게 검증오류를 할 수 있다 !!

          @PostMapping("/add")
      public String addItemV4(@ModelAttribute Item item, BindingResult bindingResult,
                              RedirectAttributes redirectAttributes) {
      
          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);
          }
          // rejectValue 메서드 안에서, MessageCodesResolver 인터페이스를 호출한다 (Test 코드 밑에 있음)
      
          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}";
      }

      void rejectValue(@Nullable String field, String errorCode,
      @Nullable Object[] errorArgs, @Nullable String defaultMessage);
      
      -- field : 오류 필드명
      -- errorCode : 오류코드( 이 오류코드는 메세지에 등록된 코드가 아니다. 뒤에서 설명할 messageResolver를 위한 오류코드)
      -- errorArgs : 오류 메세지에서 {0}을 치환하기 위한 값
      -- defaultMessage : 오류메세지 찾을 수 없을 때 사용하는 기본메세지
      
      void reject(String errorCode, @Nullable Object[] errorArgs, @Nullable String defaultMessage);

      rejectValue를 사용하고부터는 오류코드를 FieldError에서처럼 range.item.price로 입력할 필요없이, range로 간단하게 입력했다.

      그래도, 오류메세지를 잘 찾아서 출력해준다.

      이때, 오류메세지를 bindingResult를 자체를 log로 출력하면 잘 보인다 !!!

      오류메세지를 찾아서 출력해주는 원리는 (MessageCodesResolverTest.java)코드를 참고하자.

      public class MessageCodesResolverTest {
      MessageCodesResolver codesResolver = new DefaultMessageCodesResolver();
      
      @Test
      void messageCodesResolverObject(){ // 객체 오류 - 메세지 코드 2개 생성 (p.26)
          String[] messageCodes =
                  codesResolver.resolveMessageCodes("required", "item");
      
          for (String message : messageCodes){
              System.out.println("message : >>>>> " + message);
          }
      
          Assertions.assertThat(messageCodes).containsExactly("required.item", "required");
      }
      
      @Test
      void messageCodesResolverField(){ // 필드 오류 - 메세지 코드 4개 생성
          String[] messageCodes = codesResolver.resolveMessageCodes("required", "item",
                  "itemName", String.class);
      
          for (String messageCode : messageCodes){
              System.out.println("messageCode : >>>> " + messageCode);
          }
      
          /*
              ValidationItemControllerV2의 addItemV4 메서드에서, rejectValue나 reject 함수에서,
              bindingResult.rejectValue("itemName", "required"); 만 넘겨줘도..
      
              rejectValue 메서드 안에서, MessageCodesResolver 인터페이스를 호출한다!!
              그리고 해당 messageCodes(4개)를 만들어줌.. (왜 2개 만드냐면, 필드 에러는 4개, 객체 에러는 2개의 메세지 코드를 만들도록 되어있어서)
      
              new FieldError("item", "itemName", null, false, messageCodes, null, null);
                  즉, String[] codes를 넣는 5번째 argument에, messageCodes를 넘기는 것 !!
      
           */
      
          assertThat(messageCodes).containsExactly(
                  "required.item.itemName",
                  "required.itemName",
                  "required.java.lang.String",
                  "required"
          );
      }
      }

      결론만 말하자면,

      rejectValue나 reject 함수를 사용하면, 
      
      - rejectValue/reject에서의 인자 errorCode와 field를 기준으로
      	MessageCodesResolver 인터페이스를 호출해서 각각 4개 / 2개의 에러코드를 반들어줌
          
      - 그리고 난뒤 new FieldError로 만들어준 messageCodes를 넘겨줌.

      그니까, rejectValue나 reject함수에서 넘긴 errorCode를 기준으로 에러메세지를 스프링이 알아서 생성해주니까, 생성된 에러메시지 알고 싶으면 bindingResult 자체를 로그로 출력하면 보인다 !!

    위 사진속 생성된 codes랑 똑같이 errors.properties에 정의해주면, BindingResult에서 에러나면 제일먼저 해당 메세지를 출력해준다. (못찾으면, reject/rejectValue에서 정의한 defaultMessage - 걔도 없으면 스프링자체의 디폴트메세지 출력해줌..)


전체 흐름 remind

결론코드인 validationItemControllerV2.java와 해당 V2의 addForm뷰와 MeessgeCodesRosolverTest.java코드를 보면 된다.

컨트롤러에서 rejectValue/reject로 errorCode를 넣어주면, MessageCodesResolver 인터페이스에서 해당 에러코드를 기반으로 에러메세지를 만들어서, new FieldError의 messageCodes로 넘겨줬다.

그러면, addForm뷰의 th:errors와 th:field, th:errorClass는 BindingResult에 추가된 FieldError를 기준으로 오류메세지를 출력해준다 !!!!!

profile
배운 내용을 바로바로 기록하자!

0개의 댓글