이 글은 인프런 김영한님의 Spring 강의를 바탕으로 개인적인 정리를 위해 작성한 글입니다.


PRG 패턴의 상품 저장 검증에 관한 내용이다.

 

 

기본적인 검증

StringUtils.hasText()

스프링 프레임워크의 org.springframework.util.StringUtils 클래스에 포함된 유틸리티 메서드 중 하나이다. 이 메서드는 주어진 문자열이 실제로 텍스트를 포함하고 있는지 확인하는 데 사용된다.

구체적으로, 문자열이 null이 아니며, 길이가 0보다 크고, 하나 이상의 비공백 문자를 포함하고 있을 때 true를 반환한다.

public static boolean hasText(@Nullable String str)
  • 파라미터: str - 검사할 문자열
  • 반환값: 문자열이 null이 아니고, 길이가 0보다 크며, 최소한 하나의 비공백 문자를 포함하고 있으면 true를 반환한다. 그렇지 않으면 false를 반환한다.

 

컨트롤러

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

 

타임리프(addForm)

<!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">
                <!-- errors에 globalError 가 있으면 오류 메시지 출력 -->
                <!-- .? -> Safe Navigation Operator -->
                <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>

                    <!-- errors에 itemName가 있으면  class = form-control field-error, 없으면 form-control -->
                    <input type="text" id="itemName" th:field="*{itemName}"
                           th:class="${errors?.containsKey('itemName')} ? 'form-control field-error' : 'form-control'"
                           placeholder="이름을 입력하세요">

                    <!-- errors에 itemName가 있으면 오류 메시지 출력 -->
                    <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>

                    <!-- errors에 price 가 있으면 form-control field-error, 없으면 form-control -->
                    <input type="text" id="price" th:field="*{price}"
                           th:class="${errors?.containsKey('price')} ? 'form-control field-error' : 'form-control'"
                           placeholder="가격을 입력하세요">

                    <!-- errors에 price 가 있으면 오류 메시지 출력 -->
                    <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>

                    <!-- errors에 quantity 가 있으면 form-control field-error, 없으면 form-control -->
                    <input type="text" id="quantity" th:field="*{quantity}"
                           th:class="${errors?.containsKey('quantity')} ? 'form-control field-error' : 'form-control'"
                           placeholder="수량을 입력하세요">

                    <!-- errors에 quantity 가 있으면 오류 메시지 출력 -->
                    <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>

 

Safe Navigation Operator

errors?. 은 errors 가 null 일때 NullPointerException 이 발생하는 대신, null 을 반환하는 문법이다.
th:if 에서 null 은 실패로 처리되므로 오류 메시지가 출력되지 않는다.

 

BindingResult를 이용한 검증

컨트롤러

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

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

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

    //특정 필드 예외가 아닌 전체 예외
    //public ObjectError(String objectName, String defaultMessage)
    //objectName : @ModelAttribute 의 이름
    //defaultMessage : 오류 기본 메시지
    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

컨트롤러에서 입력값 검증(validation) 후 그 결과를 저장하는 데 사용된다.

이 객체는 주로 폼 입력과 같은 사용자의 입력값에 대한 검증 결과를 담고 있으며, 검증 과정에서 발생한 오류들을 저장하고 관리한다. BindingResult는 검증 대상 객체 바로 뒤에 매개변수로 위치해야 한다.

 

예를 들어, 사용자로부터 어떤 입력을 받아 이를 검증해야 하는 경우, 컨트롤러 메소드에서 @Valid 또는 @Validated 애노테이션을 사용해 입력값에 대한 검증을 요청할 수 있다.

 

검증해야 할 객체 뒤에 BindingResult 매개변수를 추가함으로써, 검증 과정에서 발생한 오류들을 BindingResult 객체가 포착하게 된다. 이후 개발자는 BindingResult 객체를 사용하여 오류 여부를 확인하고, 필요한 경우 사용자에게 오류 메시지를 표시할 수 있다.

 

필드 오류 - FieldError

FieldError 생성자 요약

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

필드에 오류가 있으면 FieldError 객체를 생성해서 bindingResult 에 담아두면 된다.

  • objectName : @ModelAttribute 이름
  • field : 오류가 발생한 필드 이름
  • defaultMessage : 오류 기본 메시지

 

글로벌 오류 - ObjectError

ObjectError 생성자 요약

public ObjectError(String objectName, String defaultMessage)

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

  • objectName : @ModelAttribute 의 이름
  • defaultMessage : 오류 기본 메시지

 

뷰(타임리프)

<!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">
                <!-- bindingResult 에 ObjectError 의 전체 오류 메시지 출력 -->
                <div th:if="${#fields.hasGlobalErrors()}"> <!-- field 가 global 오류가 있다면 -->
                    <p class="field-error" th:each="err : ${#fields.globalErrors()}" th:text="${err}">전체 오류 메시지</p> <!-- 오류의 내용을 순차적으로 출력 -->
                </div>

                <div>
                    <label for="itemName" th:text="#{label.item.itemName}">상품명</label>

                    <!-- text 박스 관련 -->
                    <!-- field 에 itemName 오류가 있으면 class = field-error -->
                    <input type="text" id="itemName" th:field="*{itemName}"
                           th:errorclass="field-error" class="form-control" placeholder="이름을 입력하세요"> <!-- itemname 에 해당하는 오류가 있다면 class에 field-error 추가 -->

                    <!-- 오류시 text 박스 아래 레이블 관련 -->
                    <!-- field 에 맞는 defaultMessage 가 출력됨 -->
                    <div class="field-error" th:errors="*{itemName}"> <!-- field 의 itemName 에 해당하는 오류가 있다면 오류 출력 -->
                        상품명 오류
                    </div>
                </div>

                <div>
                    <label for="price" th:text="#{label.item.price}">가격</label>

                    <!-- text 박스 관련 -->
                    <!-- field 에 price 오류가 있으면 class = field-error -->
                    <input type="text" id="price" th:field="*{price}"
                           th:errorclass="field-error" class="form-control" placeholder="가격을 입력하세요"> <!-- price 에 해당하는 오류가 있다면 class에 field-error 추가 -->

                    <!-- 오류시 text 박스 아래 레이블 관련 -->
                    <!-- field 에 맞는 defaultMessage 가 출력됨 -->
                    <div class="field-error" th:errors="*{price}"> <!-- field 의 price 에 해당하는 오류가 있다면 오류 출력 -->
                        가격 오류
                    </div>
                </div>

                <div>
                    <label for="quantity" th:text="#{label.item.quantity}">수량</label>

                    <!-- text 박스 관련 -->
                    <!-- field 에 price 오류가 있으면 class = field-error -->
                    <input type="text" id="quantity" th:field="*{quantity}"
                           th:errorclass="field-error" class="form-control" placeholder="수량을 입력하세요"> <!-- quantity 에 해당하는 오류가 있다면 class에 field-error 추가 -->

                    <!-- 오류시 text 박스 아래 레이블 관련 -->
                    <!-- field 에 맞는 defaultMessage 가 출력됨 -->
                    <div class="field-error" th:errors="*{quantity}"> <!-- quantity 의 price 에 해당하는 오류가 있다면 오류 출력 -->
                        수량 오류
                    </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/v2/items}'|"
                                type="button" th:text="#{button.cancel}">취소</button>
                    </div>
                </div>
            </form>
        </div> <!-- /container -->
    </body>
</html>

 

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

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

타임리프 템플릿에서 BindingResult의 오류 메시지를 표시하려면, 주로 th:errors 속성을 사용한다.

이 속성을 통해 특정 필드에 대한 검증 오류 메시지를 출력할 수 있다. 또한, th:if를 사용하여 오류 메시지가 존재하는 경우에만 오류 메시지를 표시하는 조건을 추가할 수 있다.

  • #fields : #fields 로 BindingResult 가 제공하는 검증 오류에 접근할 수 있다.
  • th:errors : 해당 필드에 오류가 있는 경우에 태그를 출력한다. th:if 의 편의 버전이다.
  • th:errorclass : th:field 에서 지정한 필드에 오류가 있으면 class 정보를 추가한다.

 

검증과 오류 메시지 공식 메뉴얼

https://www.thymeleaf.org/doc/tutorials/3.0/thymeleafspring.html#validation-and- error-messages

 

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

예를 들어 @ModelAttribute에 바인딩 시 타입 오류가 발생하면, BindingResult가 없으면 400 오류가 발생하면서 컨트롤러가 호출되지 않고, 오류 페이지로 이동한다.

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

BindingResult에 검증 오류를 적용하는 방법

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

 

주의 사항

BindingResult는 검증할 대상 바로 다음에 와야한다. 예를 들어서 @ModelAttribute Item item, 바로 다음에 BindingResult가 와야 한다.
BindingResult는 Model에 자동으로 포함된다.

 

BindingResult와 Errors

-org.springframework.validation.Errors
-org.springframework.validation.BindingResult

BindingResult가 인터페이스이고, Errors 인터페이스를 상속받고 있다.
실제 넘어오는 구현체는 BeanPropertyBindingResult라는 것인데, 둘다 구현하고 있으므로 BindingResult 대신에 Errors를 사용해도 된다.
Errors 인터페이스는 단순한 오류 저장과 조회 기능을 제공한다. BindingResult는 여기에 더해서 추가적인 기능들을 제공한다. addError()도 BindingResult가 제공하므로  BindingResult를 사용하는 것이 좋다.
주로 관례상 BindingResult를 많이 사용한다.

 

FieldError, ObjectError

용자의 입력 데이터가 컨트롤러의 @ModelAttribute 에 바인딩되는 시점에 오류가 발생하면 모델 객체에 사용자 입력 값을 유지하기 어렵다.

예를 들어서 가격에 숫자가 아닌 문자가 입력된다면 가격은 Integer 타입이므로 문자를 보관할 수 있는 방법이 없다.

그래서 오류가 발생한 경우 사용자 입력 값을 보관하는 별도의 방법이 필요하다. 그리고 이 렇게 보관한 사용자 입력 값을 검증 오류 발생시 화면에 다시 출력하면 된다.

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

 

FieldError

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

 

  • objectName : @ModelAttribute 이름
  • field : 오류가 발생한 필드 이름
  • defaultMessage : 오류 기본 메시지

 

 

public FieldError(String objectName, String field, @Nullable Object rejectedValue, boolean bindingFailure, @Nullable String[] codes, @Nullable Object[] arguments, @Nullable String defaultMessage)
  • objectName (String): 오류가 발생한 객체의 이름. 이는 해당 객체를 식별하는 데 사용되며, 보통 모델 객체의 이름이나 타입을 지칭한다.
  • field (String): 오류가 발생한 필드의 이름. 이 필드 이름은 객체 내에서 유일해야 하며, 입력 폼의 필드 이름과 일치해야 한다.
  • rejectedValue (Object): 오류가 발생했을 때 해당 필드에 입력되었던 값. 이 값은 사용자가 입력한 잘못된 값이나, 타입 불일치 등으로 인해 문제가 발생한 값을 나타낸다.
  • bindingFailure (boolean): 데이터 바인딩 실패 여부를 나타내는 플래그. 타입 불일치 등으로 인해 필드 값이 객체에 올바르게 바인딩되지 못한 경우 true가 된다.
  • codes (String[]): 오류 코드의 배열. 이 코드들은 메시지 소스 파일에서 해당 오류 메시지를 찾기 위해 사용된다. 계층적으로 구성될 수 있어, 보다 구체적인 오류 메시지부터 일반적인 오류 메시지 순으로 메시지를 정의하고 선택할 수 있다.
  • arguments (Object[]): 메시지 포맷에 사용될 인자의 배열. 오류 메시지 내에서 플레이스홀더를 대체하는 데 사용되는 값을 포함한다.
  • defaultMessage (String): 메시지 소스에서 적절한 오류 메시지 코드를 찾을 수 없을 때 사용될 기본 메시지. 이는 개발자가 사용자에게 보여줄 오류 메시지를 직접 지정하고 싶을 때 유용하다.

여기서 rejectedValue 가 바로 오류 발생시 사용자 입력 값을 저장하는 필드다.

bindingFailure 는 타입 오류 같은 바인딩이 실패했는지 여부를 적어주면 된다.

 

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

그리고 해당 오류를 BindingResult 에 담아서 컨트롤러를 호출한다. 따라서 타입 오류 같은 바인딩 실패시에도 사용자의 오류 메시지를 정상 출력할 수 있다.

 

ObjectError

ObjectError는 주로 객체 수준의 검증 오류를 나타낼 때 사용되며, 다양한 생성자를 통해 유연하게 오류 정보를 정의할 수 있다. 

public ObjectError(String objectName, String defaultMessage)

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

  • objectName : @ModelAttribute 의 이름
  • defaultMessage : 오류 기본 메시지

 

ObjectError(String objectName, String[] codes, Object[] arguments, String defaultMessage)
ObjectError(String objectName, String code, Object[] arguments, String defaultMessage)
  • objectName (String)
    오류가 발생한 객체의 이름
  • codes (String[])
    오류 코드의 배열이다. 이 코드들은 메시지 소스 파일에서 해당 오류 메시지를 찾기 위해 사용된다. 오류 코드는 계층적으로 구성될 수 있으며, 스프링은 이 배열에 있는 코드를 순서대로 검색하여 첫 번째로 발견한 메시지를 사용한다. 이를 통해 보다 구체적인 오류 메시지부터 보다 일반적인 오류 메시지 순으로 메시지를 정의하고 선택할 수 있다.
  • arguments (Object[])
    메시지 포맷에 사용될 인자의 배열이다. 이는 오류 메시지 내에서 placeholder 를 대체하는 데 사용되는 값을 포함한다. 예를 들어, 메시지 소스에 "{0}는(은) 유효하지 않습니다."라는 메시지가 정의되어 있고, arguments로 new Object[]{"이메일"}이 전달되면, 최종 메시지는 "이메일은 유효하지 않습니다."가 된다.
  • defaultMessage (String)
    메시지 소스에서 적절한 오류 메시지 코드를 찾을 수 없을 때 사용될 기본 메시지이다. 이는 개발자가 사용자에게 보여줄 오류 메시지를 직접 지정하고 싶을 때 유용하다. defaultMessage는 메시지 코드나 인자가 없거나, 메시지 소스에서 해당 오류 코드에 대한 메시지를 찾지 못했을 때 표시된다.

 

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

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

 

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

    //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 : 메시지 코드
    //arguments : 메시지에서 사용하는 인자
    //defaultMessage : 오류 기본 메시지

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

    //특정 필드 예외가 아닌 전체 예외
    //public ObjectError(String objectName, String defaultMessage)
    //objectName : @ModelAttribute 의 이름
    //defaultMessage : 오류 기본 메시지
    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}";
}