BackEnd/Spring MVC

[Spring] 검증1 - Validation - 2

샤아이인 2022. 3. 6.

내가 공부한것을 올리며, 중요한 단원은 저 자신도 곱씹어 볼겸 상세히 기록하고 얕은부분들은 가겹게 포스팅 하겠습니다.

 

3. BindingResult 1

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

 

● addItemV1

우선 이전 글에서 하나하나 검증하던 코드가 어떻게 변경되었는지 확인해 보자.

@PostMapping("/add")
public String addItemV1(@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("item", "가격 * 수량의 합은 10,000원 이상이어야 합니다."));
        }
    }

    // 검증에 실패한 경우
    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}";
}
 

우선 addItemV1 컨트롤러의 인자로 BindingResult 가 추가되었다. 이전에 오류를 저장해두는 errors Map과같은 역할을 한다.

BindingResult bindingResult 파라미터의 위치는 @ModelAttribute Item item 다음에 와야 한다.

 

- 필드 오류 : FieldError

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

필드에 오류가 있는 경우, 이전과 달리 FieldError이라는 객체를 만들어 bindingResult에 저장한다.

 

인자로는 객체이름(@ModelAttribute 이름), 필드이름오류메시지 를 넘긴다. 생성자는 다음과 같은 형태를 갖기 때문이다.

public FieldError(String objectName, String field, String defaultMessage) {}
 

(ps, 함수의 파라미터 확인하는 단축키 => cmd + P)

 

- 글로벌 오류 : ObjectError

bindingResult.addError(new ObjectError("item", "가격 * 수량의 합은 10,000원 이상 이어야 합니다. 현재 값 = " + resultPrice));
 

특정 필드를 넘어서는 오류가 있으면 ObjectError 객체를 생성해서 bindingResult 에 담아두면 된다.

 

● addForm.html 수정

<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>
    // 생략
</form>
 

타임리프는 스프링의 BindingResult를 활용한 검증오류를 표현하는 편리한 기능들을 제공한다.

#fields : 타임리프가 제공하는 기본 객체이다. #fields로 BindingResult가 제공하는 검증 오류에 접근할 수 있다.

th:errors : 해당 필드에 오류가 있는 경우에 태그를 출력한다. th:if 의 편의 버전이다.

th:errorclass : th:field 에서 지정한 필드 이름으로 BindfingResult에 오류가 있으면 class 정보를 추가한다.

 

- 글로벌 오류 처리

<div th:if="${#fields.hasGlobalErrors()}">
      <p class="field-error" th:each="err : ${#fields.globalErrors()}" th:text="${err}">전체 오류 메시지</p> 
</div>
 

글로벌 오류같은 경우 하나가 아닐수가 있다. 여러 글로벌 에러가 담겨있을수도 있다.

따라서 th:each 반복문을 통하여 출력하도록 하면, 오류가 여러개일 경우 <p></p> 태그를 여러번 사용하여 모두 출력하게 된다.

 

- 필드 오류 처리

<input type="text" id="itemName" th:field="*{itemName}" th:errorclass="field-error" class="form-control" placeholder="이름을입력하세요">
<div class="field-error" th:errors="*{itemName}">
상품명 오류
</div>
 

itemName field상에 오류가 있음을 인식하면 th:errorclass의 내용을 기존의 class에 추가시켜 준다.

그럼 th:errorclass 에서는 필드명을 지정하지 않았는데 어떻게 필드에 오류가 발생했는지 알수있을까?

=> th:field="*{itemName}" 덕분이다!

 

"field-error" 가 빨간 태두리를 만들어주는 class이기 때문에 오류가 발생하면 해당 class가 추가되어 빨간 태두리가 생기게 된다.

 

th:errors="*{itemName}" 는 itemName 필드에 해당하는 오류가 있다면 해당 <div>상품명 오류</div>태그를 출력해 준다.

기존의 th:if문으로 확인하던 코드에 비하면 확 줄어들었다.

기존 코드
th:if="${errors?.containsKey('quantity')}" th:text="${errors['quantity']}"
 

 

4. BindingResult 2

BindingResult는 스프링이 제공하는 검증 오류를 보관하는 객체이다. 검증 오류가 발생하면 여기에 보관하면 된다.

BindingResult 가 있으면 @ModelAttribute 에 데이터 바인딩 시 오류가 발생해도 컨트롤러가 호출된다!

 

● @ModelAttribute에 바인딩 시 타입 오류가 발생하면 어떻게 될까?

- BindingResult 가 없으면, 400 오류가 발생하면서 컨트롤러가 호출되지 않고, 스프링이 보여주는 오류 페이지로 이동한다.

- BindingResult 가 있으면, 오류 정보( FieldError )를 BindingResult 에 담아서 컨트롤러를 정상 호출한다.

 

● BindingResult 로 오류를 검증하는 3가지 방법

1) @ModelAttribute의 객체에 타입 오류 등으로 바인딩이 실패하는 경우 스프링이 FieldError를 생성해서 BindingResult 에 넣어준다.(스프링이 바인딩 오류가 발생시 추가해주는)

2) 개발자가 검증 로직을 구현하여 직접 추가해 준다. (비지니스 로직에서의 검증에서 추가)

3) Validator를 사용한다.

 

(주의! 위에서도 언급했지만, @ModelAttribute Item item 바로 다음에 BindingResult 가 와야 한다.)

 

숫자가 입력되야하는 칸에 문자를 입력하여 오류를 확인해 보자.

위와같이 int를 받는 가격부분에 "문자입력" 이라는 String을 전달하였다.

결과는 다음과 같다.

총 2가지 error가 확인 가능하다.

1) Field error가 'item'이라는 객체에서 발생했다고 나온다. price에는 [문자입력] 이라는 잘못 입력된 값 이 저장되어있다.

이는 Spring이 @ModelAttribute의 객체에 타입 오류 등으로 바인딩이 실패하였기에 자동으로 추가해준 것 이다.

 

2) 두번째로 추가된 Field error는 rejected value가 null로 되어있는데, 이는 우리가 직접 구현한 검증 로직에서 추가된 것 이다.

bindingResult.addError(new FieldError("item", "price", "가격은 1,000 ~ 1,000,000 까지 허용합니다."));
 

 

● BindingResult와 Errors

BindingResult 는 인터페이스이고, Errors 인터페이스를 상속받고 있다.

실제 넘어오는 구현체는 BeanPropertyBindingResult 라는 것인데, 둘다 구현하고 있으므로 BindingResult 대신에 Errors 를 사용해도 된다.

 

하지만 실제로 Errors로 변경하면 코드에서 오류가 발생하는데, 이는 Errors에는 없는 BindingResult 만의 메서드를 사용했기 때문이다.

 

 

BindingResult , FieldError , ObjectError 를 사용해서 오류 메시지를 처리하는 방법을 알아보았다. 남은 문제가 있는데...

오류가 발생하는 경우 고객이 입력한 내용이 모두 사라진다. 이 문제를 해결해보자.

 

5. FieldError, ObjectError

● addItemV2

@PostMapping("/add")
public String addItemV2(@ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model) {

    // 검증 로직
    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() >= 9999){
        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원 이상이어야 합니다."));
        }
    }

    // 검증에 실패한 경우
    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 는 두 가지 생성자를 제공한다. 위 컨트롤러에서도 변경된 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 : 타입 오류 같은 바인딩 실패인지?, 검증 실패인지? 를 구분 하는 값

(객체가 갖고 있는 맴버변수 자체에 바인딩이 실패했다면 true를 넘겨준다)

codes : 메시지 코드

arguments : 메시지에서 사용하는 인자

defaultMessage : 기본 오류 메시지

 

예를들어 가격부분에 범위를 넘어서는 가격을 추가했다고 해보자. 그렇다면 다음과 같은 FieldError가 추가될 것이다.

new FieldError("item", "price", item.getPrice(), false, null, null, "가격은 1,000 ~ 1,000,000 까지 허용합니다.")
 

item.getPrice() 을 통해 rejectdValue를 등록하고 있다. 따라서 폼으로 전달되어 해당 값(사용자가 잘못 입력한 그 값) 을 다시 확인가능.

 

사용자의 입력 데이터가 컨트롤러의 @ModelAttribute 에 바인딩되는 시점에 오류가 발생하면 모델 객체에 사용자 입력 값을 유지하기 어렵다. 예를 들어서 가격에 숫자가 아닌 문자가 입력된다면 가격은 Integer 타입이므로 문자를 보관할 수 있는 방법이 없다.

 

그래서 오류가 발생한 경우 사용자 입력 값을 보관하는 별도의 방법이 필요하다.

그리고 이렇게 보관한 사용자 입력 값을 검증 오류 발생시 화면에 다시 출력하면 된다.

FieldError 는 오류 발생시 사용자 입력 값을 저장하는 기능을 제공한다.

 

● 스프링의 바인딩 오류 처리

타입 오류로 바인딩에 실패하면 스프링은 FieldError 를 생성하면서 사용자가 입력한 값을 넣어둔다.

new FieldError("item", "price", "문자입력", true, null, null, "Spring의 에러 메시지")
 

아마 위와 같이 인자가 넘어오는데, rejectedValue에는 사용자가 잘못 입력한 값 "문자입력" 을 저장하며,

이번에는 값의 바인딩이 정상적으로 되지 않는 오류이기 때문에 true를 적어준다.

그리고 해당 오류를 BindingResult 에 담은 에서야 비로소 컨트롤러를 호출한다.

따라서 타입 오류 같은 바인싱 실패시에도 사용자의 오류 메시지를 정상 출력할 수 있다.

 

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

th:field="*{price}"
 

타임리프의 th:field 는 매우 똑똑하게 동작하는데, 정상 상황에는 모델 객체의 값을 사용하지만, 오류가 발생하면 FieldError 에서 보관한 값을 사용해서 값을 출력한다.

'BackEnd > Spring MVC' 카테고리의 다른 글

[Spring] 검증1 - Validation - 4  (0) 2022.03.06
[Spring] 검증1 - Validation - 3  (0) 2022.03.06
[Spring] 검증1 - Validation - 1  (0) 2022.03.05
[Spring] 메시지, 국제화  (0) 2022.03.05
[Spring] 스프링 MVC - 기본 기능 - 3  (0) 2022.02.28

댓글