framework/spring

스프링 mvc2 - 2 검증 1 (Validation)

wooweee 2023. 5. 5. 19:15
728x90

 

추가할 것 :th:errors

bindingResult에 errors를 타임리프 th: errors로 쓴다던데 errors란게 원래 있는거야?
ChatGPT
BindingResult는 스프링에서 제공하는 유효성 검사 결과를 저장하는 객체입니다. BindingResult는 @Valid와 함께 사용되어 폼 데이터의 유효성 검사를 수행하고, 유효성 검사 결과를 저장합니다.
th:errors는 Thymeleaf 템플릿 엔진에서 제공하는 유효성 검사 결과를 출력하는 태그입니다. BindingResult에서 FieldError나 ObjectError 등의 유효성 검사 결과가 있으면, 해당 결과를 th:errors 태그를 통해 출력할 수 있습니다.
th:errors 태그를 사용할 때 path 속성을 이용하여 출력하려는 필드나 객체의 이름을 지정할 수 있습니다. th:errors 태그는 해당 필드나 객체에 대한 유효성 검사 결과를 출력합니다. 이때 출력되는 메시지는 message.properties와 같은 프로퍼티 파일에서 정의할 수 있습니다.


User
그러면 errors를 타임리프가 쓰려면 controller에서 errors라는 객체에 값을 담아서 보내주는거야?
ChatGPT
네, 맞습니다. Controller에서 Thymeleaf로 데이터를 전달할 때, errors라는 객체에 에러 정보를 담아서 전달하면 타임리프에서 해당 객체를 참조하여 에러 메시지를 출력할 수 있습니다.
Spring MVC에서는 BindingResult 클래스를 사용하여 데이터 바인딩 과정에서 발생한 에러 정보를 담을 수 있습니다. 이 BindingResult 객체는 타임리프에서 th:errors 속성을 사용하여 접근할 수 있습니다.


User
BindingResult 이 클래스 안에 errors라는 객체가 있는거야?
ChatGPT
BindingResult 클래스에는 errors라는 이름의 객체가 있지는 않습니다. BindingResult는 스프링 MVC에서 데이터 바인딩 결과를 보유하고 검증 오류를 포함하는 객체입니다. BindingResult 객체를 사용하여 컨트롤러에서 전송된 데이터의 유효성을 검사하고 오류를 처리할 수 있습니다. 이때 BindingResult 객체는 errors라는 이름의 오브젝트를 사용하여 Thymeleaf 템플릿에서 오류 메시지를 표시할 수 있습니다. errors 오브젝트는 BindingResult의 getFieldErrors 및 getGlobalErrors 메서드를 통해 생성됩니다. 이 메서드들은 각각 필드 단위와 전체 폼 단위의 오류를 반환합니다.


User
그럼 실제로 전달되는 객체는 bindingResult라는 객체인데 타임리프에서는 해당 객체를 사용할 때 th:errors를 쓴다는 거지?
ChatGPT
네, 맞습니다. BindingResult는 Spring MVC에서 검증 결과를 담고 있는 객체이며, 해당 객체를 이용하여 에러 메시지를 추출하여 th:errors와 같은 Thymeleaf 태그를 이용해 화면에 렌더링할 수 있습니다. Thymeleaf에서는 th:errors를 이용하여 해당 객체의 에러 메시지를 출력하게 됩니다.

 

1. 검증 요구사항

 

요구사항: 검증 로직 추가

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

 

controller 중요 역할 중 하나: Http 요청이 정상인지 검증하는 것

 

 

검증

  • client 검증: 조작가능해서 보안에 취약하다
  • server 검증: 즉각적인 고객 사용성이 부족
  • API 방식 : 검증 오류를 API 응답 결과에 잘 남겨줘야 함

 

2. 검증 직접 처리 _ 소개

 

 

3. 검증 직접 처리 _ 개발

 

  • controllerV1
    • model.addAttribute("item", new Item()); 
      왜 사용했는지 주석 잘 읽어보기. 중요
// ValidationControllerV1.java

@GetMapping("/add")
public String addForm(Model model) {
    model.addAttribute("item", new Item());
    // view에 비어있는 model을 먼저 보여줌
    // thymeleaf에서 item을 이용하는데 해당 model에 item을 보내주지 않으면 error가 발생한다.
    // 그리고 이런식으로 해야 나중에 postMapping에서 검증 실패 반환을 할때 이전에 작성한 form의 정보를 유지한채로
    // forward가 가능해진다.
    return "validation/v1/addForm";
}

@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() > 1000_0000){
        errors.put("price", "가격은 1000원~100만원까지 허용 합니다.");
    }
    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", "가격*수량의 합은 10000원 이상이어야 합니다. 현재값 = " + 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}";
}

 

  • class
    • StringUtils : springFramework에서 제공하는 추상 클래스. String을 다루는 편리 기능 제공
    • StringUtils.hasText() : text를 가지고 있는지 boolean으로 리턴함

 

  • logic 순서
  1. if문으로 error 생겼을 때 값을 errors라는 Map에 넣음
    1. 특정 filed
    2. 복합

  2. errors가 비어있지 않으면 errors를 model에 담아서 해당 form으로 리턴
    • 원래 form으로 리턴시, 이 때 부터는 해당 Item에 오류로 된 값들이 저장이 되어있다.
      @ModelAttribute Item item : 2가지 기능, 1) item에 정보 담기, 2) item을 model에 담기
    • New Item 덕분에 리턴 된 html에서는 오류로 나온 값이 form에 나타나게 된다. - client에게 좋음

    • template 에서 애초부터 item을 받는 동적 작용을 시작해서 getMapping(/add)에서는 model을 addAttribute()하는 것이 무의미 해보이지만 결과적으로 더 유용하게 작용하게 된다.

  3. errors가 없으면 성공 로직 - redirect 시킨다.

 

  •  html 적용
<!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: red;
            color: red;
        }
    </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">
    <!-- model로 넘어온 item-->

        <div th:if="${errors?.containsKey('globalError')}">
            <!-- model로 넘어온 errors controller에 존재하는 객체 들이다. 헷갈리지 말 것-->
            <p class="field-error" th:text="${errors['globalError']}">전체 오류 메시지</p>
<!--   동일  <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')}">
                <p class="field-error" th:text="${errors['itemName']}"> 상품명 오류</p>
            </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')}">
                <p class="field-error" th:text="${errors['price']}"> 가격 오류</p>
            </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')}">
                <p class="field-error" th:text="${errors['quantity']}"> 수량 오류</p>
            </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>

 

<!--/* 해당 class가 errors의 itemName을 가질 시 field-error 를 class 명으로 가진다 */-->
<input type="text" id="itemName" th:field="*{itemName}"
       th:class="${errors?.containsKey('itemName')}? 'form-control field-error' : 'form-control'"
       class="form-control" placeholder="이름을 입력하세요">

<!--/* 해당 error를 가질 시 div tag가 존재한다. */-->
<div class="field-error" th:if="${errors?.containsKey('itemName')}">
    <p class="field-error" th:text="${errors['itemName']}"> 상품명 오류</p>
</div>

 

  1. errors가 존재시 나타나는 error 문구 관련 tag들

  2. errors 존재시 해당 field의 form box 표시하기 위해서 추가하는 class

  • validation에 걸린 error들은 error가 존재시에만 form에서 나타나야 하므로 if문을 통해서 class, tag들을 아예 없애거나 존재하게 해야함


  • th:if="${errors?.contaionsKey('globalError')}"
    • ?: nullPointException 방지 위한 thymeleaf SpringEL이 제공하는 문법 - errors가 null 일때 null을 반환
    • containKey(): Java Map 문법
    • th:if 는 해당 결과가 false이면 관련 tag를 없는 것으로 취급

 

참고: classappend와 ?문법 사용

  • thymeleaf 기능-  ?_ 는 각각 사용가능하다. 
  • ?가 if랑만 사용되는게 아니고
  • _가 elvis나 No-operation에서만 사용가능한 것이 아니다.
<input th:classappend="${errors?.containKey('itemName')} ? 'field-error' : _" class="form-control" >

 

 

 

 

남은 문제점

  1. 뷰 템플릿에 중복 처리가 많다
  2. 타입 오류 처리가 안된다. - 가격란에 문자 들어가면 난리남
  3. 타입 오류가 난 문자 또한 화면에 남겨야 한다. - 타입의 오류기 때문에 item에는 보관 못하고 어딘가에 별도로 관리되야 한다.

 

 

4. BindingResult1

 

  • 스프링이 제공하는 검증 오류 처리 방법 : BindingResult

 

4.1. controller 수정

  • controllerV2
@PostMapping("/add")
// public String addItem(@ModelAttribute Item item, RedirectAttributes redirectAttributes, Model model) {
public String addItemV1(@ModelAttribute Item item, BindingResult bindingResult, 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() > 1000_0000){
        errors.put("price", "가격은 1000원~100만원까지 허용 합니다.");
    }
    if (item.getQuantity() == null || item.getQuantity() > 9999) {
        errors.put("quantity", "수량은 최대 9,999 까지 입니다.");
    }
    */
    
    if (!StringUtils.hasText(item.getItemName())){
    	bindingResult.addError(new FieldError("item", "itemName", "상품이름은 필수 입니다."));
    }
    if (item.getPrice() == null || item.getPrice() <1000 || item.getPrice() > 1000_0000){
        bindingResult.addError(new FieldError("item", "price", "가격은 1,000 ~ 1,000,000 원 사이입니다."));

    }
    if (item.getQuantity() == null || item.getQuantity() > 9999) {
        bindingResult.addError(new FieldError("item", "quantity", "수량은 최대 9999까지 허용합니다."));
    }

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

    // 검증에 실패하면 다시 입력 폼으로
    /*
    if (!errors.isEmpty()) {
        log.info("errors = {}", errors);
        model.addAttribute("errors", errors);
        return "validation/v1/addForm";
    }
    */
    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/v1/items/{itemId}";
}

 

 

  • bindingResult

    • 특징
      1. 이전에는 errors Map을 생성한 후 상품을 만들었지만 스프링이 제공하는 bindingResult에 에러를 담는다.

      2. -> 타입 오류 처리를 해결. 타입 오류가 발생시 error를 FieldError에 집어넣고 FieldError를 BindingResult에 넣는다.
        error -> FieldError || ObjectError -> Bindingresult -> Model

 

  • method()
bindingResult.addError(new FieldError(...));

bindingResult.addError(new ObjectError(...));

if (bindingResult.hasErrors()) {}

 

 

  • bindingResult

    • method
      1. addError(Object object)
        Object object : FieldError, ObjectError 존재

        • FieldError: public FieldError(String objectName, String field, String defaultMessage) {}
          • objectName: @ModelAttribute 이름 - item

          • field: 오류가 발생한 field명 == item 객체의 iv명

          • defaultMessage: 오류 기본 메시지

        • ObjectError: public ObjectError(String objectName, String defaultMessage) {}
          • objectName: @ModelAttribute 이름

          • defaultMessage: 오류 기본 메시지


      2. hasErrors()
        • errors 객체 존재 유무 확인 후 boolean 반환


 

  • bindingResult 위치
    • public String addItemV1(@ModelAttribute Item item, BindingResult bindingResult )

    • 항상 @ModelAttriibute Item item 다음에 와야한다. -BindingResult는 Model에 자동으로 포함되야 하기 때문

    • 정상적으로 binding 된 값들은 item에 담기고 typeError로 바인팅 실패한 값은 bindingResult에 먼저 담긴 후 model에 담긴다.

 

  • BindingResult 검증 오류 적용 3가지 방법
    1. @ModelAttribute의 객체에 타입 오류 등으로 바인딩이 실패하는 경우 스프링이 FieldError 생성해서 BindingResult에 넣어준다.

    2. 검증 code 작성

    3. Validator 사용

 

  • BindingResult 와 Errors
    • 둘 다 interface인데 Errors가 조상 interface이다.
    • 실제 구현체는 BeanPropertyBindingResult이다. - 둘다 구현된다.
    • BindingResult 제공하는 것이 많으므로 사용 권장

 

 

4.2. html 수정

  • html
<form action="item.html" th:action th:object="${item}" method="post">    <!-- model로 넘어온 item-->

<!--이전 버전-->
<div th:if="${errors?.containsKey('globalError')}">
    <p class="field-error" th:text="${errors['globalError']}">전체 오류 메시지</p>
</div>

<!--BindingResult 활용 버전-->
<div th:if="${#fields.globalErrors()}">
    <p class="field-error" th:each="err : ${#fields.globalErrors()}" th:text="${err}">글로벌 오류 메시지</p>
</div>


<!--이전 버전-->
<!--th:field는 form tag에 th:objcet로 받아온 객체의 iv를 편리하게 사용할 때 사용하는 문법이다.-->
<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')}">
    <p class="field-error" th:text="${errors['itemName']}"> 상품명 오류</p>
</div>

<!--BindingResult 활용 버전-->
<input type="text" id="itemName" th:field="*{itemName}"
       th:errorclass="field-error" class="form-control" placeholder="이름을 입력하세요">

<div class="field-error" th:errors="*{itemName}">
    상품명 오류
</div>

 

4.2.1. bindingError 정리

 

 

 

특징

  • @ModelAttribute로 지정한 item과 BindingResult가 연관되게 움직인다.

  • fieldError 에서 주로 사용하는 property : th:errors, th: errorclass
  • objectError 에서 주로 사용하는 property : #fields

 

 

  • #fields - BindingResult 가 제공하는 검증 오류에 접근할 수 있다.
    • ObjecError() 에서 사용하는 경우 : #fields.globalErrors(): 필드 에러가 아닌 에러들 - ObjectError

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

  • th:errors - 해당 필드에 오류가 있는 경우에 태그를 출력, error 없으면 tag자체를 없애버림
    • th:if 편의 버전 - th:if는 false일때 해당 tag를 없앰
    • th:errors="*{itemName}" 해당 필드에 오류가 있을 때 나타남
    • th:errors 속성은 자동으로 해당 필드의 에러 메시지를 보여주는 기능.

    • thymeleaf는 자동으로 BindingResult 객체를 찾아서 해당 객체의 필드 에러 정보를 확인

    • Spring MVC에서 제공하는 BindingResult 객체에서 유효성 검증에 실패한 필드와 에러 메시지를 추출해서 화면에 표시할 때 사용하는 속성
    • th:errors에서 사용하는 errors는 BindingResult에서 유효성 검증에 실패한 필드와 에러 메시지를 담고 있는 객체

 

  • th: errorclass - th:field 에서 지정한 필드에 오류가 있으면 class 정보를 추가
    • th:field="*{itemName}" 이면 itemName에 관한 오류가 존재시 class 정보추가
    • 추가 예시 : class="form-control field-error" 

 

 

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

 

 

남은 문제점

  1. 뷰 템플릿에 중복 처리가 많다
  2. 타입 오류 처리가 안된다. - 가격란에 문자 들어가면 난리남 (해결)
  3. BindingResult 사용하니깐 error 값이 사라짐 (새로 생긴 문제)

 

5. FieldError, ObjectError

 

  • V2 - v1 controller 수정
public String addItemV2(@ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes) {
    
    // 검증 로직
    if (!StringUtils.hasText(item.getItemName())){
     // bindingResult.addError(new FieldError("item", "itemName", "상품이름은 필수 입니다."));
        bindingResult.addError(new FieldError("item", "itemName", item.getItemName(), false, null, null,"상품이름은 필수 입니다."));
    }
    if (item.getPrice() == null || item.getPrice() <1000 || item.getPrice() > 1000_0000){
     // bindingResult.addError(new FieldError("item", "price", "가격은 1,000 ~ 1,000,000 원 사이입니다."));
        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", "수량은 최대 9999까지 허용합니다."));
        bindingResult.addError(new FieldError("item", "quantity", item.getQuantity(), false, null, null, "수량은 최대 9999까지 허용합니다."));
    }

    // 특정 필드가 아닌 복합 룰 검증
    if (item.getPrice() != null && item.getQuantity() != null){
        int resultPrice = item.getPrice() * item.getQuantity();
        if (resultPrice < 10000){
         // bindingResult.addError(new ObjectError("item", "가격*수량의 합은 10000원 이상이어야 합니다. 현재값 = " + resultPrice));
            bindingResult.addError(new ObjectError("item", null, null, "가격*수량의 합은 10000원 이상이어야 합니다. 현재값 = " + 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는 2가지의 생성자 제공
    1. public FieldError(
         String objectName,
         String field,
         String defaultMessage
      ) {}


    2. public FieldError(
          String objectName,
          String field
         @Nullable Object rejectedValue,
          boolean bindingFailure, 
         @Nullable String[] codes, 
         @Nullable Object[] arguments,
         @Nullable String defaultMessage
      )


      • objectName: 오류가 발생한 객체 이름
      • field: 오류 필드

      • rejectedValue: 사용자가 입력한 값 == 거절된값
      • bindingFailure: 바인딩 살패인지, 검증 실패인지 구분 값
        • 현재는 type에서 오류가 난 것이지 binding 자체가 안된 것이 아니므로 false 사용 - 큰 의미 두지 말 것
        • typeError 같은 경우는 default로 bindingFailure: true; 가 된다.
      • codes: 메시지 코드

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

      • defaultMessage: 기본 오류 메시지


  • ObjectError도 유사한 2가지의 생성자를 제공 - 생략

 

 

정리

  1. 숫자 칸에 문자 쓸 때 400 page가 나타난 이유
    • form에서 입력한 값이 post로 이동시 @ModelAttribute에 item이 담기게 되는데 type error로 인해서 binding 자체가 안되서 controller 작동이 멈춰 버림
    • BindingResult로 현 문제 해결 - 해당 error를 FieldError에 넣고 BindingResult에 넣기 때문

  2. 오류 문자 나타나게 하기
    rejectedValue, bindingFailure 가 오류 문자 나타내는데 중요한 역할을 한다.

    • FieldError는 생성자로 오류 발생시 사용자 입력 값을 저장하는 기능을 제공하는 params를 가진다. - rejectedValue 
      • rejectedValue가 false일 경우 typeError가 아니라고 인지하고 form에 

    • 타임리프의 th:field="*{price}"는 정상 상황에는 모델 객체의 값을 사용하지만, 오류가 발생하면 FieldError에서 보관한 값을 사용해서 값을 출력한다.

 

 

6. 오류 코드와 메시지 처리 1

 

  • 스프링 부터 메시지 설정 추가
# application.properties
spring.messages.basename=messages, errors

 

  • errors.properties 추가
# src/main/resources/errors.properties

required.item.itemName=상품 이름은 필수입니다.
range.item.price=가격은 {0} ~ {1} 까지 허용합니다.
max.item.quantity=수량은 최대 {0} 까지 허용합니다.
totalPriceMin=가격 * 수량의 합은 {0}원 이상이어야 합니다. 현재 값 = {1}

 

FiledError 와 ObjectError에는 메시지에 사용되는 params를 가지고 있다.

메시지 국제화 처럼 활용

  • codes: 메시지 코드
  • arguments: 메시지에서 사용하는 인자

이전에 했던 메시지,국제화 방법 적용

  1. resources에 errors.properties 생성
  2. application.properties: spring.messages.basename=messages, errors 작성
  3. errors.properties 내용 작성
 

 

  • V2 - v3 controller 수정
public String addItemV3(@ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes) {
    
    // 검증 로직
    if (!StringUtils.hasText(item.getItemName())){
     // bindingResult.addError(new FieldError("item", "itemName", "상품이름은 필수 입니다."));
     // bindingResult.addError(new FieldError("item", "itemName", item.getItemName(), false, null, null,"상품이름은 필수 입니다."));
        bindingResult.addError(new FieldError("item", "itemName", item.getItemName(), false, new String[]{"required.item.itemName","required.item.itemName2"}, null, null));

    }
    if (item.getPrice() == null || item.getPrice() <1000 || item.getPrice() > 1000_0000){
     // bindingResult.addError(new FieldError("item", "price", "가격은 1,000 ~ 1,000,000 원 사이입니다."));
     // bindingResult.addError(new FieldError("item", "price",item.getPrice(), false,null, null,"가격은 1,000 ~ 1,000,000 원 사이입니다."));
        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() > 9999) {
     // bindingResult.addError(new FieldError("item", "quantity", "수량은 최대 9999까지 허용합니다."));
     // bindingResult.addError(new FieldError("item", "quantity", item.getQuantity(), false, null, null, "수량은 최대 9999까지 허용합니다."));
        bindingResult.addError(new FieldError("item", "quantity", item.getQuantity(), false, new String[]{"max.item.quantity"}, new Object[]{99999}, null));

    }

    // 특정 필드가 아닌 복합 룰 검증
    if (item.getPrice() != null && item.getQuantity() != null){
        int resultPrice = item.getPrice() * item.getQuantity();
        if (resultPrice < 10000){
         // bindingResult.addError(new ObjectError("item", "가격*수량의 합은 10000원 이상이어야 합니다. 현재값 = " + resultPrice));
         // bindingResult.addError(new ObjectError("item", null, null, "가격*수량의 합은 10000원 이상이어야 합니다. 현재값 = " + resultPrice));
            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}";
}

 

  • bindingResult.addError(new ObjectError("item", new String[]{"totalPriceMin"}, new Object[]{10000, resultPrice}, null));
    • codes: new String[]{"totalPriceMin"} ==  totalPriceMin=가격 * 수량의 합은 {0}원 이상이어야 합니다. 현재 값 = {1}

    • arguments: new Object[]{10000, resultPrice}

 

 

7. 오류 코드와 메시지 처리2

 

FieldError, ObjectError 다루기 번거로움 -> 자동화 필요

 

 

BindingResult 가 알고 있는 정보

  • objectName
  • Target - field 들 ex) itemName, price, quantity
log.info("objectName={}", bindingResult.getObjectName());
// objectName=item
log.info("getTarget={}", bindingResult.getTarget());
// getTarget=Item(id=null, itemName=woowee, price=111, quantity=123)

 

7.1.  rejectValue(), reject()

 

rejectValue(), reject()

  • BindingResult가 제공하는 method로 FieldError, ObjectError 대신 사용 가능
  • 직접 작성하는 코드는 rejectValue(), reject() 이지만 실행시 내부적으로 FieldError(), ObjectError() 생성되면서 실질적으로는 FieldError(), ObjectError()에 메시지 코드를 보관

 

7.1.1. rejectValue() - fieldError

void rejectValue(@Nullable String field, String errorCode, @Nullable Object[] errorArgs, @Nullable String defaultMessage);
  • rejectValue() 형태
    • field: 오류 필드명 
    • errorCode: 오류 코드(메시지에 등록된 코드가 아니다. messageResolver를 위한 오류 코드이다.)
    • errorArgs: 오류 메시지에서 {} 배열에 들어갈 값을 치환하기 위한 값
    • defaultMessage: 오류 메시지 찾을 수 없을 때 사용

 

// 예시
bindingResult.rejectValue("price", "range", new Object[]{1000, 1000000}, null);

 

  • 어떤 object를 validation하는지 알기 때문에 "item" 정보 필요 없다.
  • new String[]{"range.item.price"} -> range로 오류 코드 축약. messageResolver 덕분

 

7.1.2. reject() - objectError

void reject(String errorCode, @Nullable Object[] errorArgs, @Nullable String defaultMessage);
  • reject() 형태
  • field가 따로 없기 때문에 바로 errorCode부터 시작. 나머지는 rejectValue()와 동일

 

 

 

  • V2 - v4 controller 수정

errors.properties 코드 입력하지 않았는데도 오류 메시지 정상 작동 이유 : messageResolver 있기에 가능하다.

 

public String addItemV4(@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", "required.item.itemName2"}, null, null));
        bindingResult.rejectValue("itemName", "required");
    }
    
    if (item.getPrice() == null || item.getPrice() <1000 || item.getPrice() > 1000_0000){
     // bindingResult.addError(new FieldError("item", "price", item.getPrice(), false, new String[]{"range.item.price"}, new Object[]{1000, 1000000}, null));
        bindingResult.rejectValue("price", "range", new Object[]{1000, 1000000}, null);
    }
    
    if (item.getQuantity() == null || item.getQuantity() > 9999) {
     // bindingResult.addError(new FieldError("item", "quantity", item.getQuantity(), false, new String[]{"max.item.quantity"}, new Object[]{99999}, null));
        bindingResult.rejectValue("quantity", "max", new Object[]{99999}, 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));
            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}";
}

 

 

8. 오류 코드와 메시지 처리3~5

 

  • 오류 메시지에 object 명과 field 명을 조합한 세밀한 메시지 코드가 존재하는지 확인하고 없으면 좀 더 범용적인 메시지를 선택하도록 한다.

  • spring이 이미 구현을 해 놓았다. - MessageCodesResolver
#Level1 
required.item.itemName: 상품 이름은 필수 입니다.

#Level2
required: 필수 값 입니다.

 

8.1. MessageCodesResolver

  • MessageCodesResolver  인터페이스이고  DefaultMessageCodesResolver 는 기본 구현체
  • rejectValue(), reject() 내부적으로 MessageCodesResovler를 사용한다.
  • FieldError(), ObjectError()의 errorCode == codes 는 Nullable String[] codes 로 배열이기 때문에 여러개를 받을 수 있다.
  • thymeleaf
    • 렌더링 시 th:errors 실행 - 생성된 오류 메시지 순서대로 찾다가 없으면 default Message 출력

 

 

  • DefaultMessageCodesResolver 기본 메시지 생성 규칙

    • 객체 오류
      1. errorCode + "." + object Name
      2. errorCode

    •  객체 오류 예시
      1. required.item
      2. required

    • 필드 오류
      1. errorCode + "." + object Name + "." + field Name
      2. errorCode + "." + field Name
      3. errorCode + "." + field type
      4. errorCode

    • 필드 오류 예시
      1. required.item.itemName
      2. required.itemName
      3. required.java.loang.String
      4. required

 

 

  • 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} 까지 허용합니다.

 

정리

  1. rejectValue()  호출
  2. MessageCodesResolver 를 사용해서 검증 오류 코드로 메시지 코드들을 생성
  3. new FieldError() 를 생성하면서 메시지 코드들을 보관
  4. th:errors 에서 메시지 코드들로 메시지를 순서대로 메시지에서 찾고, 노출

 

 

  • 동작 방식 
    1. rejectValue() ,  reject() 는 내부에서  MessageCodesResolver 를 사용한다. 여기에서 메시지
      코드들을 생성한다.

    2. FieldError ,  ObjectError 의 생성자를 보면, 오류 코드를 하나가 아니라 여러 오류 코드를 가질 수 있다.

    3. MessageCodesResolver 를 통해서 생성된 순서대로 오류 코드를 보관한다. 

 

 

9. ValidationUtils

 

제공 기능: Empty, 공백 과 같은 단순한 기능만 제공

// 검증 로직 if문 대신 사용가능한 유틸리티

// if(!StringUtils.hasText(itemgetItemName())){
//     bindingResult.rejectValue("itemName", "required");
// }
   ValidationUtils.rejectIfEnptyOrWhitespace(bindingResult, "itemName","requird");

 

 

10. 오류 코드와 메시지 처리6

 

  • type error에 관한 spring이 만든 error code 변경

 

검증 오류 코드 2가지

  1. 개발자가 직접 설정한 오류 코드 - if문의 조건에 맞지 않으면 price가 얼마 이하라는 등 rejectValue() 직접 호출

  2. spring이 직접 검증 오류에 추가한 경우 - price에 string을 넣은 경우 == 주로 타입 정보가 맞지 않는 경우 자동 호출
    • spring이 typeMismatch 라는 오류코드를 사용한다. *오류코드 == String errorCode

    • void rejectValue(@Nullable String field, String errorCode, @Nullable Object[] errorArgs, @Nullable String defaultMessage);

    • 오류코드를 통해서 4가지 메시지 코드 생성
      • type error인 경우 log-메시지코드
      • codes [typeMismatch.item.price, typeMismatch.price, typeMismatch.java.lang.Integer, typeMismatch];

    • spring이 생성한 typeMismatch 에러코드에 관한 메시지 코드가 존재하지 않기 때문에 스프링이 생성한 기본 메시지(읽기 싫게 생긴 것) 출력됨
      • errors.properties 수정하여서 기본 메시지 수정 가능
# 추가
typeMismatch.java.lang.Integer = 숫자를 입력해 주세요.
typeMismatch = 타입을 입력해 주세요

 

 

11. Validator 분리1

 

controller에 검증에 관한 코드가 너무 크다 - 별도의 class로 분리 필요

 

  • Validator Interface: 검증을 체계적으로 제공하기 위해 spring이 제공하는 interface
    • support: 해당 검증기를 지원하는 여부 확인
    • validate(Object target, Errors errors): 검증 대상 객체와 BindingResult *Errors가 BindingResult 조상
public interface Validator {
    boolean supports(Class<?> clazz);
    void validate(Object target, Errors errors); 
}

 

  • 검증로직을 ItemValidator.java로 분리
@Component
public class ItemValidator implements Validator {

    @Override
    public boolean supports(Class<?> clazz) {
        return Item.class.isAssignableFrom(clazz);
        // item == clazz
        // item == subItem
    }

    @Override
    public void validate(Object target, Errors errors) {
        Item item = (Item) target;
        // 검증 로직
        if (!StringUtils.hasText(item.getItemName())){ errors.rejectValue("itemName", "required"); }

        if (item.getPrice() == null || item.getPrice() <1000 || item.getPrice() > 1000_0000){
            errors.rejectValue("price", "range", new Object[]{1000, 1000000}, null);

        }
        if (item.getQuantity() == null || item.getQuantity() > 9999) {
            errors.rejectValue("quantity", "max", new Object[]{99999}, 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);
            }
        }

    }
}

 

 

  • V2 - v5 controller 수정
@Slf4j
@Controller
@RequestMapping("/validation/v2/items")
@RequiredArgsConstructor
public class ValidationItemControllerV2 {

    private final ItemRepository itemRepository;
    private final ItemValidator itemValidator;
    
    ...
    
    
    @PostMapping("/add")
    public String addItemV5(@ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes) {

        // 해당 검증 로직에서 errors에 잘못된 오류 값들을 담는다. *errors: errors.properties 의미
        // bindingResult라는 객체주소를 해당 검증기에 넘겨주어서 이 객체주소에 if문에 존재하는 error를 담고 model에 저장까지 한다.
        itemValidator.validate(item, bindingResult); // 나중에 spring이 없애줌

        // 검증에 실패하면 다시 입력 폼으로(여기의 bindingResult는 검증기를 돌리고 난 후의 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}";
    }

 

 

12. Validator 분리2

 

Validator 인터페이스를 사용해서 만든 검증기(Validator 분리1 ItemValidator class)는 추가적으로 spring이 제공하는 체계적인 검증 기능을 도입할 수 있다.

 

3가지 방법

  1. WebDataBinder
    • 스프링 파라미터 바인딩의 역할을 해주고 검증 기능도 내부에 포함
    • @Validated가 존재하는 @requestMapping의 params를 WebDataBinder가 정한 검증기에 넘겨줌
    • 깊이 알 필요 없다

  2. @InitBinder
    • 핸들러 메서드의 매개변수로 받는 WebDataBinder 객체를 커스터마이징하기 위해 사용. 이를 통해 WebDataBinder를 통해 바인딩할 객체에 대한 컨트롤을 할 수 있다.
    • 해당 controller 호출 시마다 새로 만들어진다.
    • 검증기를 자동으로 적용할 수 있다.

  3. @Validated 
    • 해당 애노테이션 존재시에만 WebDataBinder에 등록한 검증기를 찾아서 실행
    • 검증기가 여러개 등록되었을 시 구분이 필요해서 supports() 사용됨

 

  • V2 - v6 controller 수정
@Slf4j
@Controller
@RequestMapping("/validation/v2/items")
@RequiredArgsConstructor
public class ValidationItemControllerV2 {

    // 1. @InitBinder
    private final ItemValidator itemValidator;

    @InitBinder // 검증기 - 해당 source code의 controller 호출때마다 먼저 호출됨. 항상 새로운게 호출
    public void init(WebDataBinder dataBinder) {
    // WebDataBiner에 내가 만든 itemValidator 검증기 넣기
        dataBinder.addValidators(itemValidator);
    }
    
    
    // 2.@Validated
    // public String addItemV5(@ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes) {
    public String addItemV6(@Validated @ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes) {

    if (bindingResult.hasErrors()) {
        log.info("errors = {}", bindingResult);
        return "validation/v2/addForm";
    }

    // itemValidator.validate(item, bindingResult); @Validated로 대체

    // 나머지 동일
}

 

  • 동작 방식
    1. @InitBinder에 여러 검증기를 등록한다면 그 중에 어떤 검증기가 실행되어야 할지 구분이 필요하다. 이때  supports()가 사용된다.
    2. 여기서는  supports(Item.class)  호출되고, 결과가  true이므로  ItemValidator의  validate()가 호출된다.
      public String addItemV6(@Validated @ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes) {}

  • 검증기를 글로벌 설정할 수 있지만, 안하는 걸 권장