스프링 공부 (인프런 김영한 선생님)/스프링 MVC 2편

[스프링 웹 MVC 2편] 005. Validation (1)

2023. 8. 13. 12:09

상품 관리 시스템 등록/수정 폼에 다음과 같은 검증로직을 추가한다

  • 타입 검증
  • 필드 검증
  • 특정 필드 범위 넘어서는지 검증

컨트롤러의 역할 중 하나 HTTP 요청이 정상인지 검증한다

클라이언트 검증 : 조작 가능 / 보안에 취약
서버 검증 : 즉각적인 고객 사용성, 즉 새로고침 REQUEST/RESONSE가 와야해서 사용성이 떨어짐
둘이 적절히 섞되, 서버 검증은 필수로 이루어져야한다.

API 방식 이용시 스펙을 잘 정의해서 검증 오류를 API 응답으로 잘 전달해야 함.


addItem() 컨트롤러 메서드를 통해 검증 로직 구현 / 발전시키기 V1
@PostMapping("/add")  
public String addItem(@ModelAttribute Item item, RedirectAttributes redirectAttributes,  
                      Model model) {  
    // Validation Result  
    Map<String, String> errors = new HashMap<>();  

    // Validation Logic  
    if(!StringUtils.hasText(item.getItemName())){  
        errors.put("itemName", "상품 이름은 필수입니다");  
    }  

    if(item.getPrice() == null || item.getPrice() < 1000 || item.getPrice() > 1000000){  
        errors.put("price", "가격은 1000원에서 100000원 사이로 허용합니다");  
    }  

    if(item.getQuantity() == null || item.getQuantity() >= 9999){  
        errors.put("quantity", "수량은 최대 9,999까지입니다");  
    }  

    // ModelAttribute는 값을 자동으로 넣어줌 -> th:object 그대로 남아있음.  
    // 메모리상에 남아있는 객체 자료 재활용함. 위에 new Item()으로 선언한 이유임  

    // Combinational Validation  
    if(item.getPrice() != null && item.getQuantity() != null){  
        int resultPrice = item.getPrice() * item.getQuantity();  
        if(resultPrice < 10000){  
            errors.put("globalError", " 가격 * 수량의 합은 10000원 이상이어야 함");  
        }  

    }  

    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}";  
}

Map<String, String> errors = new HashMap(); - 어떤 검증에서 오류가 발생하는지 정보 담아둠

if(!StringUtils.hasText(item.getItemName())){  
        errors.put("itemName", "상품 이름은 필수입니다");  
    }```

`if(!StringUtils.hasText(item.getItemName())))`

해당 필드에 텍스트가 없으면 상품 이름은 필수입니다라는 오류 메세지를 key, value 형태로 저장한다.
이때 부정의 부정은 코드의 가독성이 떨어지기 때문에 따로 `is_empty`식으로 method extraction을 통해 가독성을 향상시키는게 좋다

```java
// Combinational Validation  
    if(item.getPrice() != null && item.getQuantity() != null){  
        int resultPrice = item.getPrice() * item.getQuantity();  
        if(resultPrice < 10000){  
            errors.put("globalError", " 가격 * 수량의 합은 10000원 이상이어야 함");  
        }  

    }```

특정 필드를 넘어서는 오류를 처리해야할 경우 필드 이름을 넣을 수 없기 때문에 (여러 필드에 해당)
`globalError`라는 `key`를 사용한다.

```java
if(!errors.isEmpty()){  
        model.addAttribute("errors", errors);  
        return "validation/v1/addForm";  
    }

다음 코드는 입력 오류가 발생한 경우 즉 errors에 내용이 있는 경우 오류 메세지 출력을 위해
model에 errors를 담고 입력 폼이 있는 뷰 템플릿으로 다시 보내는 것이다.
이렇게 되면 item 객체를 저장하기 이전에 validation을 진행하고, validation에 오류가 있는 경우 미리 뷰 템플릿으로 다시 보냄으로써 잘못된 값의 입력을 방지할 수 있다.

템플릿 수정

그렇다면 이제 컨트롤러에서 담은 errors값을 템플릿에서 사용자에게 보이기 위해 html 코드를 수정하겠다.

<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:class="${errors?.containsKey('price')} ? 'form-control field-error' : 'form-control' "  
                   th:field="*{price}" 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:class="${errors?.containsKey('quantity')} ? 'form-control field-error' : 'form-control' "  
                   th:field="*{quantity}" class="form-control" placeholder="수량을 입력하세요">  
            <div class="field-error" th:if="${errors?.containsKey('quantity')}" th:text="${errors['quantity']}">  
               수량 확인  
            </div>  
        </div>  
    </form>  
</div> <!-- /container -->  
</body>  
</html>
<input type="text" id="itemName" th:field="*{itemName}"  
                  th:class="${errors?.containsKey('itemName')} ? 'form-control field-error' : 'form-control' "  
                  class="form-control" placeholder="이름을 입력하세요">  

th:class 속성에서 ?. Operator는 Safe Navigation Operator로 errors가 null이라고 가정한다면, 즉 등록폼에 진입한 시점에서는 errors에 아무런 값이 담겨있지 않기 때문에 null에 해당하는데

errors.containsKey()를 호출하면 NullPointerException이 발생하게 된다.

?. operator는 이와 같이 특정 객체가 null에 해당할 경우 NullPointerException을 반환하는 대신 null을 반환하도록 하는 문법에 해당한다.

th:if 속성의 경우 null의 경우 실패로 처리되기 때문에 오류 메세지 출력이 없다.

th:class 속성에서 조건식이 구성되어있는데 해당 조건식이 만족될 경우 form-control field-error가 만족되지 않을 경우 form-control 이 태그의 속성으로 입력된다.

.field-error{  
 color:red;  
}

.field-error 속성은 다음과 같이 기술되어, 오류메세지 출력에 사용된다.


addItem() 컨트롤러 메서드를 통해 검증 로직 구현 / 발전시키기 V2

위 V1 검증 로직에는 몇가지 불편한 점이 있다.

  • 타입 오류 처리가 안된다
  • 문자 보관이 어렵다(원래 입력했던 값)
  • 바인딩이 안되면, 오류처리가 안된다(일단 바인딩 즉, 값이 들어와야 검증이 가능함)
    • 객체 데이터 타입이 맞지 않으면 그냥 오류 발생(애초에 값 검증은 객체 필드에 값이 있음을 전제)
  • 고객이 입력한 값이 어딘가에 별도로 관리가 되어야 다시 띄워줄 수 있음
//@PostMapping("/add")  
public String addItemV1(@ModelAttribute Item item,  
                      BindingResult bindingResult, // ModelAttribute 바로 뒤에 와야 함! (순서 중요)  
                      RedirectAttributes redirectAttributes,  
                      Model model) {  

    // Validation Logic  
    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", "가격은 ~ 입니다"));  
    }  

    if(item.getQuantity() == null || item.getQuantity() >= 9999){  
        bindingResult.addError(new FieldError("item", "quantity", "수량 에러 최대 9,999"));  
    }  

    // ModelAttribute는 값을 자동으로 넣어줌 -> th:object 그대로 남아있음.  
    // 메모리상에 남아있는 객체 자료 재활용함. 위에 new Item()으로 선언한 이유임  

    // Combinational Validation  
    if(item.getPrice() != null && item.getQuantity() != null){  
        int resultPrice = item.getPrice() * item.getQuantity();  
        if(resultPrice < 10000){  
            bindingResult.addError(new ObjectError("item", "가격과 수량의 합은 10000 이상이여야 합니다"));  
        }  

    }  

    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의 도입

BindingResult를 사용하게 되면서 기존에 이용했던 HashMap() 의 errors 객체는 더 이상 불필요하게 됐다.

이 BindingResult 객체를 사용하려면 @ModelAttribute 를 사용하는 객체 다음에 위치해야한다.
(target 객체를 제대로 잡기 위함 / 내가 이 객체를 검증하겠다!)

그 대신 오류 메세지를 저장하기 위해 FieldError와 ObjectError가 등장하게 됐는데, 용례는 다음과 같다.

FieldError

필드에 오류가 있는 경우 FieldError 객체를 생성하여 다음과 같이 bindingResult에 담아두면 된다.

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

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

특정 필드를 넘어서는 다중 필드 오류가 있는 경우 다음과 같이 ObjectError를 생성하여 BindingResult에 담아두면 된다.

public ObjectError(String objectName, String defaultMessage){}

// Combinational Validation  
    if(item.getPrice() != null && item.getQuantity() != null){  
        int resultPrice = item.getPrice() * item.getQuantity();  
        if(resultPrice < 10000){  
            bindingResult.addError(new ObjectError("item", "가격과 수량의 합은 10000 이상이여야 합니다"));  
        }  

    } 

FieldError, ObjectError 공통

1) objectName : @ModelAttribute의 이름
2) defaultMessage : 오류 기본 메세지

HTML 코드 수정

컨트롤러 코드가 BindingResult를 이용하여 리팩토링 되었기에 해당 내용을 템플릿 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:errorclass="field-error"  
               th:field="*{price}" class="form-control" placeholder="가격을 입력하세요">  
     <div class="field-error" th:errors="*{price}">  <!--에러 있음 렌더링 없음 X -->      가격 오류  
     </div>  
    </div>    <div>        <label for="quantity" th:text="#{label.item.quantity}">수량</label>  
        <input type="text" id="quantity" th:errorclass="field-error"  
               th:field="*{quantity}" class="form-control" placeholder="수량을 입력하세요">  
     <div class="field-error" th:errors="*{quantity}">  
      수량 확인  
     </div>  
    </div>

Thymeleaf는 스프링의 bindingResult를 활용 / 검증 오류를 쉽게 렌더링 할 수 있도록 지원한다

#fields : #fields를 통해 BindingResult가 제공하는 검증 오류에 접근할 수 있다.
th:errors : 해당 필드에 오류가 있는 경우 태그를 출력한다 th:if 보다 편리하다.
th:errorclass : th:field에서 지정한 필드에 오류가 있으면 class 정보를 추가한다.
th:attrappend 와 비슷하다고 보면 될듯!

'스프링 공부 (인프런 김영한 선생님) > 스프링 MVC 2편' 카테고리의 다른 글

[스프링 웹 MVC 2편] 007. Bean Validation  (0) 2023.08.15
[스프링 웹 MVC 2편] 006. Validation (2)  (0) 2023.08.15
[스프링 웹 MVC 2편] 004. 국제화 메세지  (0) 2023.08.11
[스프링 웹 MVC 2편] 003. 스프링 통합과 폼  (0) 2023.08.11
[스프링 웹 MVC 2편] 002. 타임리프 기본기능(2)  (0) 2023.08.11
'스프링 공부 (인프런 김영한 선생님)/스프링 MVC 2편' 카테고리의 다른 글
  • [스프링 웹 MVC 2편] 007. Bean Validation
  • [스프링 웹 MVC 2편] 006. Validation (2)
  • [스프링 웹 MVC 2편] 004. 국제화 메세지
  • [스프링 웹 MVC 2편] 003. 스프링 통합과 폼
ProgYun.
ProgYun.
인내, 일관성, 그리고 꾸준함을 담습니다.
ProgYun.
Perseverance, Consistency, Continuity
ProgYun.
전체
오늘
어제
  • 분류 전체보기
    • 칼럼
    • 일상생활
      • 월별 회고
      • 인생 이야기 (대학생활)
      • 취준
      • 운동인증
      • 제품 사용 후기와 추천
    • 스프링 공부 (인프런 김영한 선생님)
      • 스프링 핵심원리
      • 스프링 MVC 1편
      • 스프링 MVC 2편
    • 면접 준비
    • 전공
      • OOP 정리
      • Design Pattern
    • 스터디
    • English
      • Electronics(Laptop)
      • 1일 1단어

블로그 메뉴

  • 홈
  • 태그
  • 방명록

공지사항

인기 글

태그

  • 하이닉스
  • 확진자
  • NVME
  • 편입생
  • 해외직구
  • 윈도우10
  • 피로그래밍
  • 대학생
  • ssd
  • 오미크론
  • 포맷
  • mason
  • 컴공
  • 코로나
  • 자존감
  • 자가격리
  • p31
  • 코로나19
  • 윈도우재설치
  • 일상

최근 댓글

최근 글

hELLO · Designed By 정상우.
ProgYun.
[스프링 웹 MVC 2편] 005. Validation (1)
상단으로

티스토리툴바

개인정보

  • 티스토리 홈
  • 포럼
  • 로그인

단축키

내 블로그

내 블로그 - 관리자 홈 전환
Q
Q
새 글 쓰기
W
W

블로그 게시글

글 수정 (권한 있는 경우)
E
E
댓글 영역으로 이동
C
C

모든 영역

이 페이지의 URL 복사
S
S
맨 위로 이동
T
T
티스토리 홈 이동
H
H
단축키 안내
Shift + /
⇧ + /

* 단축키는 한글/영문 대소문자로 이용 가능하며, 티스토리 기본 도메인에서만 동작합니다.