Super Kawaii Cute Cat Kaoani [스프링 MVC 2편] 4. 검증1 - Validation

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

2024. 1. 25. 15:38
728x90
SMALL

📌 검증 요구사항

⚡ 요구사항 : 검증 로직 추가

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

 

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

cf) 클라이언트 검증, 서버 검증

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

 

 

 

📌 검증 직접 처리

⚡ 소개

✔ 상품 저장 성공

  • 사용자가 상품 등록 폼에서 정상 범위의 데이터를 입력하면, 서버에서는 검증 로직이 통과하고, 상품을 저장하고, 상품 상세 화면으로 redirect

 

✔ 상품 저장 검증 실패

  • 고객이 상품 등록 폼에서 상품명을 입력하지 않거나, 가격, 수량 등이 너무 작거나 커서 검증 범위를 넘어서면, 서버 검증 로직이 실패
  • 검증에 실패한 경우 고객에게 다시 상품 등록 폼을 보여주고, 어떤 값을 잘못 입력했는지 친절하게 알려줘야 함

 

⚡ 개발

ValidationItemControllerV1

addItem() 수정

    @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()>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}";
    }

✔ 검증 오류 보관

Map<String, String> errors = new HashMap<>();

 

✔ 검증 로직

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

import org.springframework.util.StringUtils; 추가 필요

  • 검증시 오류가 발생하면 errors 에 담아둠
  • 어떤 필드에서 오류가 발생했는지 구분하기 위해 오류가 발생한 필드명을 key 로 사용
  • 이후 뷰에서 이 데이터를 사용해서 고객에게 친절한 오류 메시지 출력 가능

 

✔ 특정 필드의 범위를 넘어서는 검증 로직

//특정 필드의 범위를 넘어서는 검증 로직
if (item.getPrice() != null && item.getQuantity() != null) {
    int resultPrice = item.getPrice() * item.getQuantity();
    if (resultPrice < 10000) {
        errors.put("globalError", "가격 * 수량의 합은 10,000원 이상이어야 합니다. 현재 값 = " + resultPrice);
    } 
}

특정 필드를 넘어서는 오류를 처리해야 할 수도 있음
➡ 필드 이름을 넣을 수 없으므로 globalError 라는 key 를 사용

 

✔ 검증에 실패하면 다시 입력 폼으로

if (!errors.isEmpty()) {
    model.addAttribute("errors", errors);
    return "validation/v1/addForm";
}
  • 만약 검증에서 오류 메시지가 하나라도 있으면 오류 메시지를 출력하기 위해 modelerrors 를 담고, 입력 폼이 있는 뷰 템플릿으로 보냄

 

 

add.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: #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-controlfield-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-controlfield-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-controlfield-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>

 

✔ CSS 추가

.field-error {
    border-color: #dc3545;
    color: #dc3545;
}

오류 메시지를 빨간색으로 강조

 

✔ 글로벌 오류메시지

 <div th:if="${errors?.containsKey('globalError')}">
     <p class="field-error" th:text="${errors['globalError']}">전체 오류 메시지</p> 
</div>
  • 오류 메시지는 errors 에 내용이 있을 때만 출력하면 됨
  • 타임리프의 th:if 를 사용하면 조건에 만족할 때만 해당 HTML 태그를 출력할 수 있음

cf)
Safe Navigation Operator

  • errors가 null이라면 errors.containsKey()를 호출하는 순간 NullPointerException 발생
  • errors?. errors가 null일 때 NullPointerException이 발생하는 대신 null을 반환하는 문법
    • th:if에서 null은 실패로 처리되므로 오류메시지가 출력되지 않음

 

 

SpringEL이 제공하는 문법
참고 링크

 

 

✔ 필드 오류 처리

<input type="text" th:classappend="${errors?.containsKey('itemName')} ? 'field-error' : _"
    class="form-control">
  • classappend 를 사용해서 해당 필드에 오류가 있으면 field-error 라는 클래스 정보를 더해서 폼의 색깔을 빨간색으로 강조
  • 만약 값이 없으면 _ (No-Operation)을 사용해서 아무것도 하지 않음

 

✔ 필드 오류 처리 - 입력 폼 색상 적용

<input type="text" class="form-control field-error">

 

✔ 필드 오류 처리 - 메시지

<div class="field-error" th:if="${errors?.containsKey('itemName')}" th:text="${errors['itemName']}">
    상품명 오류
</div>
  • 글로벌 오류 메시지에서 설명한 내용과 동일하고, 필드 오류를 대상

 

실행
http://localhost:8080/validation/v1/items/add

 

 

⚡ 정리

  • 만약 검증 오류가 발생하면 입력 폼을 다시 보여줌
  • 검증 오류들을 고객에게 친절하게 안내해서 다시 입력할 수 있게 함
  • 검증 오류가 발생해도 고객이 입력한 데이터가 유지

 

⚡ 남은 문제점

  • 뷰 템플릿에서 중복 처리가 많음
  • 타입 오류 처리가 안됨
    • Itemprice , quantity 같은 숫자 필드는 타입이 Integer 이므로 문자 타입 으로 설정하는 것이 불가능
  • 숫자 타입에 문자가 들어오면 오류가 발생
    • 스프링 MVC에서 컨트롤러에 진입하기도 전에 예외가 발생하기 때문에, 컨트롤러가 호출되지도 않고, 400 예외가 발생 하면서 오류 페이지를 띄워줌
  • Itemprice 에 문자를 입력하는 것 처럼 타입 오류가 발생해도 고객이 입력한 문자를 화면에 남겨야 한다.
    • 만약 컨트롤러가 호출된다고 가정해도 ItempriceInteger 이므로 문자를 보관 불가능
  • 결국 문자는 바인딩이 불가능하므로 고객이 입력한 문자가 사라지게 되고, 고객은 본인이 어떤 내용을 입력해서 오류가 발생했는지 이해하기 어려움
  • 결국 고객이 입력한 값도 어딘가에 별도로 관리가 되어야 함

 

📌 BindingResult1

ValidationItemControllerV2 - addItemV1

    @PostMapping("/add")
    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}";
    }

❗ 주의

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

 

✔ 필드 오류 - FieldError

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

 

FieldError 생성자 요약

public FieldError(String objectName, String field, String defaultMessage) {} 
  • 필드에 오류가 있으면 FieldError 객체를 생성해서 bindingResult 에 담아두면 됨
    • objectName : @ModelAttribute 이름
    • field : 오류가 발생한 필드 이름
    • defaultMessage : 오류 기본 메시지

 

✔ 글로벌 오류 - ObjectError

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

 

ObjectError 생성자 요약

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

validation/v2/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>

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

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

 

✔ 글로벌 오류 처리

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

 

✔ 필드 오류 처리

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

 

⚡ BindingResult 정리

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

 

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

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

 

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

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

 

✔ 타입 오류 확인

  • 숫자가 입력되어야 할 곳에 문자를 입력해서 타입을 다르게 해서 BindingResult 를 호출하고 bindingResult 의 값을 확인

 

❗ 주의

  • BindingResult 는 검증할 대상 바로 다음에 와야함
  • 순서 중요
  • ex)
    • @ModelAttribute Item item , 바로 다음에 BindingResult 가 와야 함
    • BindingResult 는 Model에 자동으로 포함됨

 

⚡ BindingResult와 Errors

  • org.springframework.validation.Errors
  • org.springframework.validation.BindingResult

 

Errors

  • 인터페이스이고, 단순한 오류 저장과 조회 기능 제공

 

BindingResult

  • 인터페이스이고, Errors 인터페이스를 상속받고 있음
  • 실제 넘어오는 구현체는 BeanPropertyBindingResult 라는 것인데, 둘다 구현하고 있으므로 BindingResult 대신에 Errors 를 사용해도 됨
  • Errors에 더해서 추가적인 기능들을 제공
  • addError()BindingResult 가 제공하므로 여기서는 BindingResult 를 사용
  • 주로 관례상 BindingResult 를 많이 사용

 

📌 FieidError, ObjectError

  • 오류가 나면 고객이 입력한 내용이 모두 사라지는 문제 발생!

ValidationItemControllerV2 - addItemV2

    @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 도 유사하게 두 가지 생성자를 제공

 

✔ 오류 발생시 사용자 입력 값 유지

new FieldError("item", "price", item.getPrice(), false, null, null, "가격은 1,000 ~ 1,000,000 까지 허용합니다.")
  • 사용자의 입력 데이터가 컨트롤러의 @ModelAttribute 에 바인딩되는 시점에 오류가 발생하면 모델 객체에 사용자 입력 값을 유지하기 어려움
  • ex) 가격에 숫자가 아닌 문자가 입력된다면 가격은 Integer 타입이므로 문자를 보관할 수 있는 방법이 없음
    • 오류가 발생한 경우 사용자 입력 값을 보관하는 별도의 방법이 필요
    • 이렇게 보관한 사용자 입력 값을 검증 오류 발생시 화면에 다시 출력
  • FieldError 는 오류 발생시 사용자 입력 값을 저장하는 기능을 제공
  • rejectedValue 가 바로 오류 발생시 사용자 입력 값을 저장하는 필드
  • bindingFailure 는 타입 오류 같은 바인딩이 실패했는지 여부를 적어주면 됨
    • 바인딩이 실패한 것은 아니기 때문에 false 를 사용

 

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

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

 

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

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

 

 

📌 오류 코드와 메시지 처리 1

⚡ 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 : 기본 오류 메시지

FieldError , ObjectError 의 생성자는 codes , arguments 제공
➡ 이것은 오류 발생시 오류 코드로 메시지를 찾기 위해 사용

 

✔ errors 메시지 파일 생성

  • messages.properties 를 사용해도 되지만, 오류 메시지를 구분하기 쉽게 errors.properties 라는 별도의 파일로 관리
  • 스프링 부트가 해당 메시지 파일을 인식할 수 있게 다음 설정 추가
  • messages.properties , errors.properties 두 파일을 모두 인식
  • 생략하면 messages.properties 를 기본으로 인식

 

✔ 스프링 부트 메시지 설정 추가

🔶 application.properties

spring.messages.basename=messages,errors

 

🔶 errors.properties 추가

required.item.itemName=상품 이름은 필수입니다.
range.item.price=가격은 {0} ~ {1} 까지 허용합니다.
max.item.quantity=수량은 최대 {0} 까지 허용합니다.
totalPriceMin=가격 * 수량의 합은 {0}원 이상이어야 합니다. 현재 값 = {1}
cf) errors_en.properties 파일을 생성하면 오류 메시지도 국제화 처리 가능

 

ValidationControllerV2 - addItemV3() 추가

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

✔ 코드 설명

//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 : Object[]{1000, 1000000} 를 사용해서 코드의 {0} , {1} 로 치환할 값을 전달

 

 

📌 오류 코드와 메시지 처리 2

  • 오류 코드를 좀 더 자동화하기
  • 컨트롤러에서 BindingResult 는 검증해야 할 객체인 target 바로 다음에 오기 때문에 BindingResult 는 이미 본인이 검증해야 할 객체인 target 을 알고 있음
 log.info("objectName={}", bindingResult.getObjectName());
 log.info("target={}", bindingResult.getTarget());

 

✔ 실행 결과

 

objectName=item //@ModelAttribute name
target=Item(id=null, itemName=상품, price=100, quantity=1234)

 

 

 

⚡ rejectValue() , reject()

  • BindingResult 가 제공하는 rejectValue() , reject() 를 사용하면 FieldError , ObjectError직접 생성하지 않고, 깔끔하게 검증 오류를 다룰 수 있음

ValidationControllerV2 - addItemV4() 추가

 @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.rejectValue("price", "range", new Object[]{1000, 1000000}, null)
  • BindingResult : 어떤 객체를 대상으로 검증하는지 target을 이미 알고 있음
  • target(item)에 대한 정보는 없어도 됨
  • 오류 필드명은 동일하게 price 사용

 

축약된 오류 코드

  • FieldError() 를 직접 다룰 때는 오류 코드를 range.item.price 와 같이 모두 입력
  • rejectValue() 를 사용하고 부터는 오류 코드를 range 로 간단하게 입력
  • MessageCodesResolver 이해

 

🔶 errors.properties 

range.item.price=가격은 {0} ~ {1} 까지 허용합니다. 

 

✔ reject()

 void reject(String errorCode, @Nullable Object[] errorArgs, @Nullable String
 defaultMessage);

 

 

📌 오류 코드와 메시지 처리 3

✔ 자세한 오류 코드

  • required.item.itemName : 상품 이름은 필수 입니다.
  • range.item.price : 상품의 가격 범위 오류 입니다.

✔ 단순한 오류 코드

  • required : 필수 값 입니다.
  • range : 범위 오류 입니다.
  • 여러 곳에서 사용 가능
required: 필수 값 입니다.
  • required 라는 메시지만 있으면 이 메시지를 선택해서 사용함
#Level1
required.item.itemName: 상품 이름은 필수 입니다. 
#Level2
required: 필수 값 입니다. 

우선순위로 사용

스프링은 MessageCodesResolver 라는 것으로 이러한 기능을 지원

 

 

📌 오류 코드와 메시지 처리 4

MessageCodesResolverTest

package hello.itemservice.validation;

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

import static org.assertj.core.api.Assertions.*;

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 인터페이스
  • DefaultMessageCodesResolver 는 기본 구현체
  • 주로 ObjectError , FieldError 함께 사용

 

 

⚡ DefaultMessageCodesResolver의 기본 메시지 생성 규칙

✔ 객체 오류

객체 오류의 경우 다음 순서로 2가지 생성 
1.: code + "." + object name 
2.: code

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

 

✔ 필드 오류

필드 오류의 경우 다음 순서로4가지 메시지 코드 생성 
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 를 사용
  • 여기에서 메시지 코드들 생성
  • FieldError , ObjectError 의 생성자를 보면, 오류 코드를 하나가 아니라 여러 오류 코드를 가질 수 있음
  • MessageCodesResolver 를 통해서 생성된 순서대로 오류 코드를 보관
  • 이 부분을 BindingResult 의 로그를 통해서 확인
codes [range.item.price, range.price, range.java.lang.Integer, range]

 

✔ FieldError

rejectValue("itemName", "required")
  • 다음 4가지 오류 코드를 자동으로 생성
    • required.item.itemName
    • required.itemName
    • required.java.lang.String
    • required

 

✔ ObjectError

reject("totalPriceMin")`
  • 다음 2가지 오류 코드를 자동으로 생성
    • totalPriceMin.item
    • totalPriceMin

 

오류 메시지 출력

  • 타임리프 화면을 렌더링 할 때 th:errors 가 실행
  • 만약 이때 오류가 있다면 생성된 오류 메시지 코드를 순서대로 돌아가면서 메시지를 찾고 없으면 디폴트 메시지 출력

 

 

📌 오류 코드와 메시지 처리 5

⚡ 오류 코드 관리 전략

  • 핵심은 구체적인 것에서! 덜 구체적인 것으로!
  • MessageCodesResolver
    • required.item.itemName 처럼 구체적인 것을 먼저 만들어주고 
    • 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} 까지 허용합니다. 

 

 

  • 객체 오류와 필드 오류로 나눔
  • 범용성에 따라 레벨을 나눔

itemName 의 경우 required 검증 오류 메시지가 발생하면 다음 코드 순서대로 메시지가 생성

1. required.item.itemName
2. required.itemName
3. required.java.lang.String
4. required

 

➡ 순서대로 MessageSource 에서 메시지에서 찾음
구체적인 것에서 덜 구체적인 순서대로

 

⚡ ValidationUtils

✔ ValidationUtils 사용 전

if(!StringUtils.hasText(item.getItemName())){
	bindingResult.rejectValue("itemName", "required", "기본: 상품 이름은 필수입니다.");
}

 

✔ ValidationUtils 사용 후

ValidationUtils.rejectIfEmptyOrWhitespace(bindingResult, "itemName", "required");

 

⚡ 정리

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

 

📌 오류 코드와 메시지 처리 6

✔ 스프링이 직접 만든 오류 메시지 처리

  • 개발자가 직접 설정한 오류 코드 rejectValue() 를 직접 호출
  • 스프링이 직접 검증 오류에 추가한 경우(주로 타입 정보가 맞지 않음)

 

✔ 메시지 코드 전략의 강점

  • price 필드에 문자 "A"를 입력
  • 로그를 확인해보면 BindingResultFieldError 가 담겨있고, 다음과 같은 메시지 코드들 생성
codes[typeMismatch.item.price,typeMismatch.price,typeMismatch.java.lang.Integer,ty peMismatch]

 

  • typeMismatch.item.price
  • typeMismatch.price
  • typeMismatch.java.lang.Integer
  • typeMismatch

➡ 스프링은 타입 오류가 발생하면 typeMismatch 라는 오류 코드를 사용
➡  이 오류 코드가 MessageCodesResolver 를 통하면서 4가지 메시지 코드가 생성된 것

 

실행

  • 아직 errors.properties 에 메시지 코드가 없기 때문에 스프링이 생성한 기본 메시지가 출력됨
Failed to convert property value of type java.lang.String to required type java.lang.Integer for property price; nested exception is java.lang.NumberFormatException: For input string: "A"

 

🔶 error.properties 에 다음 내용을 추가

#추가
typeMismatch.java.lang.Integer=숫자를 입력해주세요.
typeMismatch=타입 오류입니다. 

 

반응형

 

📌 Validator 분리

➡  복잡한 검증 로직을 별도로 분리 (별도의 클래스로 역할을 분리, 재사용)

ItemValidator

package hello.itemservice.web.validation;

import hello.itemservice.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);
            }
        }
    }
}

 

✔ 스프링은 검증을 체계적으로 제공하기 위해 다음 인터페이스를 제공

 public interface Validator {
     boolean supports(Class<?> clazz);
     void validate(Object target, Errors errors);
}
  • supports() {} : 해당 검증기를 지원하는 여부 확인
  • validate(Object target, Errors errors) : 검증 대상 객체와 BindingResult

 

ItemValidator 직접 호출

ValidationItemControllerV2 - addItemV5()

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

 

⚡ WebDataBinder

  • 스프링이 Validator 인터페이스를 별도로 제공하는 이유는 체계적으로 검증 기능을 도입하기 위해서
  • Validator 인터페이스를 사용해서 검증기를 만들면 스프링의 추가적인 도움을 받을 수 있음
  • WebDataBinder : 스프링의 파라미터 바인딩의 역할을 해주고 검증 기능도 내부에 포함

 

ValidationControllerV2

@InitBinder
public void init(WebDataBinder dataBinder){
       
	log.info("init binder {}",  dataBinder);
    dataBinder.addValidators(itemValidator);
}
  • WebDataBinder 에 검증기를 추가하면 해당 컨트롤러에서는 검증기를 자동으로 적용할 수 있음
  • @InitBinder 해당 컨트롤러에만 영향을 줌(글로벌 설정은 별도로 해야함)

 

⚡ @Validated 적용

ValidationItemControllerV2 - addItemV6()

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

동작 방식

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

 

✔ 글로벌 설정 - 모든 컨트롤러에 다 적용

package hello.itemservice;

import hello.itemservice.web.validation.ItemValidator;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

import javax.xml.validation.Validator;

@SpringBootApplication
public class ItemServiceApplication {

    public static void main(String[] args) {
        SpringApplication.run(ItemServiceApplication.class, args);
    }

    @Override
    public Validator getValidator(){
        return new ItemValidator();
    }
}

주의❗
글로벌 설정을 하면 BeanValidator가 자동 등록되지 않음
➡ 글로벌 설정을 직접 사용하는 경우는 드물다.

cf)

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

 

 

김영한님의 <스프링 MVC 2편 - 백엔드 웹 개발 활용 기술_김영한>을 수강하고 작성한 글입니다

 

 


PREV

 

[스프링 MVC 2편] 3. 메시지, 국제화

📌 메시지, 국제화 소개 ⚡ 메시지 기능 상품명이라는 단어를 모두 상품이름으로 고쳐야할 때 사용 ex) 🔶 messages.properties 메시지 관리용 파일 생성 item=상품 item.id=상품 ID item.itemName=상품명 item.p

nyeroni.tistory.com

NEXT

 

[스프링 MVC 2편] 5. 검증2 - Bean Validation

📌 Bean Validation ⚡ 정의 검증 기능을 매번 코드로 작성하면 번거로움 Bean Validation : 검증 로직을 모든 프로젝트에 적용할 수 있게 공통화하고, 표준화한 것 특정한 구현체가 아니라 Bean Validation 2.

nyeroni.tistory.com

 

728x90
LIST

BELATED ARTICLES

more