728x90
반응형
SMALL

참고자료

 

스프링 MVC 2편 - 백엔드 웹 개발 활용 기술 강의 | 김영한 - 인프런

김영한 | 웹 애플리케이션 개발에 필요한 모든 웹 기술을 기초부터 이해하고, 완성할 수 있습니다. MVC 2편에서는 MVC 1편의 핵심 원리와 구조 위에 실무 웹 개발에 필요한 모든 활용 기술들을 학습

www.inflearn.com

 

검증 

전달된 데이터에 대한 검증은 웹 애플리케이션에서 빠질 수 없는 요소이다. 누군가는 "앞단에서 검증 처리를 하면 되지 않나요?"라고 말할 수 있겠지만 앞단과 뒷단 모두 검증 처리를 당연히 해야 한다. 

 

왜냐하면, 앞단 검증은 조작이 가능하다. 예를 들면, 이름을 입력하는 필드에 문자가 들어갈텐데 여기에 SQL문을 작성해도 아무런 문제가 되지 않을수도 있다. 같은 문자열이고 문자열 길이에 제한이 없는 경우라면 말이다. 

뒷단만으로 검증을 한다면 즉각적인 고객 피드백이 부족해진다. 예를 들어, 숫자 입력 필드에 문자를 입력함과 동시에 바로 반응을 해주면 고객 친화적인 애플리케이션이 될텐데 서버에서만 검증하면 결국 데이터를 날리기 전까지는 모르니까 말이다.

 

따라서, 둘을 적절하게 섞어서 사용해야 한다. API 방식을 사용하면 API 스펙을 잘 정의해서 검증 오류를 API 응답 결과에 잘 남겨주어야 한다. 

 

스프링의 도움 없이 검증을 직접 해보기

우선, 검증을 직접 처리할때는 어떻게 하는지를 먼저 보고 이를 스프링이 얼마나 편하게 해주는지를 알아보자.

상품을 저장하는 로직이 있다고 하면 상품 저장에 성공 하거나 실패하는 케이스가 있을것이다. 

 

성공하는 케이스의 흐름은 다음과 같다.

1. 사용자가 상품 저장 화면 요청

2. 상품 저장 화면 응답

3. 상품 저장 화면에서 데이터 입력

4. 상품 저장 요청

5. 상품 저장 완료

 

실패하는 케이스의 흐름은 다음과 같다.

1. 사용자가 상품 저장 화면 요청

2. 상품 저장 화면 응답

3. 상품 저장 화면에서 데이터 입력

4. 상품 저장 요청

5. 검증 실패

6. 실패 원인 데이터와 함께 상품 저장 화면을 다시 출력

 

중요한 건, 사용자 경험을 위해 어떤 부분에서 어디가 잘못됐는지에 대한 내용을 포함한 원래 화면이 다시 나와야 한다는 것이다.

이제 직접 처리하는 코드를 작성해보자.

 

우선, @ModelAttributeItem 객체는 이렇게 생겼다.

package hello.itemservice.domain.item;

import lombok.Data;

@Data
public class Item {

    private Long id;
    private String itemName;
    private Integer price;
    private Integer quantity;

    public Item() {
    }

    public Item(String itemName, Integer price, Integer quantity) {
        this.itemName = itemName;
        this.price = price;
        this.quantity = quantity;
    }
}

서버단 처리

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

    // 검증 오류 결과를 보관
    Map<String, String> errors = new HashMap<>();

    // 검증 로직
    if (!StringUtils.hasText(item.getItemName())) {
        errors.put("itemName", "Item name is required");
    }
    if (item.getPrice() == null || item.getPrice() < 1_000 || item.getPrice() > 1_000_000) {
        errors.put("price", "Price must be between 1000 and 1,000,000");
    }
    if (item.getQuantity() == null || item.getQuantity() < 1 || item.getQuantity() > 9999) {
        errors.put("quantity", "Quantity must be between 1 and 9999");
    }

    // 특정 필드가 아닌 복합 룰 검증
    if (item.getPrice() != null && item.getQuantity() != null) {
        int resultPrice = item.getPrice() * item.getQuantity();
        if (resultPrice < 10_000) {
            errors.put("globalError", "price * quantity must be greater than 10,000. current price is " + resultPrice);
        }
    }

    // 에러가 있는 경우, 다시 폼을 보여줌
    if (hasError(errors)) {
        log.debug("[addItem] 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}";
}

private boolean hasError(Map<String, String> errors) {
    return !errors.isEmpty();
}

 

  • 상품 데이터를 저장 요청해서 POST 요청을 날리면 검증을 해야 한다. 그래서 컨트롤러의 POST 요청에서 검증을 수행한다.
  • 먼저 데이터에 에러가 있는 경우 어떤 데이터의 어떤 에러인지를 담기 위한 Map을 선언한다.
  • 차례대로 하나씩 검증을 시작한다. 검증 요구사항은 당연히 애플리케이션마다 천지차별이다. 여기서는 다음과 같다.
    • itemName은 없으면 안된다.
    • price는 없거나, 1,000원보다 작거나, 1,000,000원보다 크다면 잘못된 데이터이다.
    • quantity는 없거나, 1개보다 작거나, 9999개보다 크다면 잘못된 데이터이다.
    • price * quantity의 값이 10,000원을 넘지 않는다면 잘못된 데이터이다.
  • 에러가 있는 경우 modelerrors를 담아서 다시 상품 저장 폼을 보여준다.

이렇게 하면 끝이다. 그리고 만약 에러가 있어서 다시 상품 저장 화면이 출력되면, 타임리프 문법을 통해 errors에서 에러가 있는 경우 그 데이터를 출력만 해주면 된다.

 

검증 단계를 하나씩 살펴보자.

1. 에러가 있는 경우에 해당 에러를 보관할 Map을 선언

// 검증 오류 결과를 보관
Map<String, String> errors = new HashMap<>();

 

2. itemName은 없으면 안된다.

if (!StringUtils.hasText(item.getItemName())) {
    errors.put("itemName", "Item name is required");
}

 

3. price는 없거나, 1,000원보다 작거나, 1,000,000원보다 크면 안된다.

if (item.getPrice() == null || item.getPrice() < 1_000 || item.getPrice() > 1_000_000) {
    errors.put("price", "Price must be between 1000 and 1,000,000");
}

 

4. quantity는 없거나, 1보다 작거나, 9999보다 크면 안된다.

if (item.getQuantity() == null || item.getQuantity() < 1 || item.getQuantity() > 9999) {
    errors.put("quantity", "Quantity must be between 1 and 9999");
}

 

5. price * quantity의 값이 10,000원을 넘어야 한다.

if (item.getPrice() != null && item.getQuantity() != null) {
    int resultPrice = item.getPrice() * item.getQuantity();
    if (resultPrice < 10_000) {
        errors.put("globalError", "price * quantity must be greater than 10,000. current price is " + resultPrice);
    }
}

 

6. 이렇게 검증을 직접 처리를 한 후, 검증 결과를 확인해서 에러가 있는 경우 해당 에러 데이터를 포함해서 다시 등록 폼을 보여준다.

if (hasError(errors)) {
    log.debug("[addItem] errors: {}", errors);
    model.addAttribute("errors", errors);
    return "validation/v1/addForm";
}

 

여기까지가 서버에서 해 줄 작업이다. 그럼 앞단에서는 상품 등록 폼에서 에러가 있으면 해당 에러를 보여주면 된다.

 

앞단 처리

addForm.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;
        }
    </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>
            <label for="itemName" th:text="#{label.item.itemName}">상품명</label>
            <input type="text" id="itemName" th:field="*{itemName}" class="form-control" placeholder="이름을 입력하세요">
        </div>
        <div>
            <label for="price" th:text="#{label.item.price}">가격</label>
            <input type="text" id="price" th:field="*{price}" class="form-control" placeholder="가격을 입력하세요">
        </div>
        <div>
            <label for="quantity" th:text="#{label.item.quantity}">수량</label>
            <input type="text" id="quantity" th:field="*{quantity}" class="form-control" placeholder="수량을 입력하세요">
        </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>
  • 이렇게 타임리프로 만들어진 상품 등록 폼이 있다. 이 폼에 하나씩 에러 처리 부분을 적용해보자.

globalError

우선, 어떤 특정 필드 처리가 아니라 전체적으로 적용되는 에러가 있었다. 

그 에러가 발생하는 경우는 `가격 * 수량 < 10,000`인 경우이다.

 

그래서 form 태그 안에 바로 위에 이 부분을 넣어주자.

<div th:if="${errors?.containsKey('globalError')}">
    <p class="field-error" th:text="${errors['globalError']}">글로벌 에러 라인</p>
</div>
  • 만약, 에러가 발생해서 다시 상품 등록 폼으로 돌아왔다면, 에러 내용을 담은 errors라는 Map이 있을 것이다.
  • errorsglobalError가 있다면 이 부분이 렌더링된다.

그래서 이 globalError를 추가한 전체 코드는 이렇게 된다. (위에 스타일도 들어간 것 확인. 아무래도 빨간색이 에러 내용에 적합하니)

<!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}" class="form-control" placeholder="이름을 입력하세요">
        </div>
        <div>
            <label for="price" th:text="#{label.item.price}">가격</label>
            <input type="text" id="price" th:field="*{price}" class="form-control" placeholder="가격을 입력하세요">
        </div>
        <div>
            <label for="quantity" th:text="#{label.item.quantity}">수량</label>
            <input type="text" id="quantity" th:field="*{quantity}" class="form-control" placeholder="수량을 입력하세요">
        </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>

 

그래서 이 상태에서 내가 globalError를 발생시키는 데이터를 전송하면 다음과 같이 나오게 된다. 

 

 

itemName

이번엔 itemName에 대한 에러 내용을 출력해보자.

<div>
    <label for="itemName" th:text="#{label.item.itemName}">상품명</label>
    <input type="text" id="itemName" th:field="*{itemName}" class="form-control" placeholder="이름을 입력하세요">
    <div class="field-error" th:if="${errors?.containsKey('itemName')}" th:text="${errors['itemName']}">상품명 오류</div>
</div>

 

  • itemName 필드를 출력하는 부분에 다음과 같이 에러 출력 div 태그 하나를 추가해주면 된다.

그럼 이렇게 보여진다. 간단하다.

근데, 지금 상태에서 조금 더 고객에 친절한 애플리케이션이 되고 싶다면 필드의 border-color를 추가해주면 좋을것 같다.

<input type="text" id="itemName" th:field="*{itemName}" th:class="${errors.containsKey('itemName')} ? 'form-control field-error' : 'form-control'" placeholder="이름을 입력하세요">
  • 타임리프의 도움을 받아 th:class로 클래스를 추가할 수 있는데, 여기서 errorsitemName을 가지고 있냐 없냐에 따라 분기할 수 있다.

그래서 최종적으로 이렇게 만들 수 있다.

<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'" placeholder="이름을 입력하세요">
    <div class="field-error" th:if="${errors?.containsKey('itemName')}" th:text="${errors['itemName']}">상품명 오류</div>
</div>

그리고 이렇게 한 결과를 보면 조금 더 이쁘다는 것을 알 수 있다.

타임리프에서 지원하는 classappend를 사용해도 된다.

<div>
    <label for="itemName" th:text="#{label.item.itemName}">상품명</label>
    <input type="text" id="itemName" th:field="*{itemName}" class="form-control" th:classappend="${errors?.containsKey('itemName')} ? 'field-error' : _" placeholder="이름을 입력하세요">
    <div class="field-error" th:if="${errors?.containsKey('itemName')}" th:text="${errors['itemName']}">상품명 오류</div>
</div>

저 값이 있으면 추가하고자 하는 클래스만 작성하고, 없으면 `_(아무것도 하지 않음)` 처리를 해주는 것도 가능하다.

 

price

이제 가격 차례다. 위 itemName이랑 똑같이 하면 된다.

<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'" placeholder="가격을 입력하세요">
    <div class="field-error" th:if="${errors?.containsKey('price')}" th:text="${errors['price']}">상품명 오류</div>
</div>

 

quantity

<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'" placeholder="수량을 입력하세요">
    <div class="field-error" th:if="${errors?.containsKey('quantity')}" th:text="${errors['quantity']}">상품명 오류</div>
</div>

 

 

여기까지 하면, 뭔가 검증 다 끝난거 같은데? 하면 큰 오산이다. 우선 남은 문제점이 있다.

  • 타입 안정성이 해결되지 않았다. 예를 들어, 가격에 'qqq'라고 작성해도 검증이 되는게 아니라 아예 컨트롤러 자체가 호출이 되지 않을 것이다. 왜냐하면 Item이라는 객체에 데이터를 담아서 그 값들을 하나씩 검증해야 하는데 Item안에 price 필드는 Integer 타입이라 스트링 값 자체가 들어가지 않는다. 
  • 뷰 템플릿에서 중복 처리가 많다.

이런 것들을 직접 처리한다면 얼마나 괴로울까? 스프링이 다 도와준다. 이제 스프링이 도와주는 것들을 하나씩 차근차근 알아보자.

 

스프링의 도움을 받아 검증 처리하기

이제 스프링이 도와주는 것들을 사용해서 하나씩 세련된 코드를 작성해보자. 

BindingResult

바로 이 BindingResult라는 스프링이 만들어 놓은 좋은 녀석을 사용하면 된다.

근데, 이 녀석 사용할때 주의할 점은 @ModelAttribute 바로 뒤에 와야한다는 것이다.

이유는 무엇이냐면, ResultBinding한다는 것은 사용자가 입력한 데이터를 Result 해서 어떤 객체에 바인딩시켜야 한다는 것을 의미한다. 그렇다면 바인딩 할 대상이 있어야 하고 그 대상을 스프링에서 지정하기를 바로 앞에 있는 @ModelAttribute로 선언된 객체라고 정의한 것이다.

서버단 처리

@PostMapping("/add")
public String addItemV1(@ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes) {
    // 검증 로직
    if (!StringUtils.hasText(item.getItemName())) {
        bindingResult.addError(new FieldError("item", "itemName", "Item name is required"));
    }
    if (item.getPrice() == null || item.getPrice() < 1_000 || item.getPrice() > 1_000_000) {
        bindingResult.addError(new FieldError("item", "price", "Price must be between 1 and 1_000_000"));
    }
    if (item.getQuantity() == null || item.getQuantity() < 1 || item.getQuantity() > 9999) {
        bindingResult.addError(new FieldError("item", "quantity", "Quantity must be between 1 and 9999"));
    }

    // 특정 필드가 아닌 복합 룰 검증
    if (item.getPrice() != null && item.getQuantity() != null) {
        int resultPrice = item.getPrice() * item.getQuantity();
        if (resultPrice < 10_000) {
            bindingResult.addError(new ObjectError("item", "price * quantity must be greater than 10,000. current price is " + resultPrice));
        }
    }

    // 에러가 있는 경우, 다시 폼을 보여줌
    if (bindingResult.hasErrors()) {
        log.debug("[addItemV1] bindingResult 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}";
}
  • 파라미터 부분에 @ModelAttribute Item 바로 뒤에 BindingResult가 나온것을 알 수 있다. 이게 규칙이기 때문에 반드시 이렇게 해야한다.
  • 코드를 보니 뭔가 엄청 깔끔해진 것 같고 코드 양도 줄어보인다.

검증 단계를 하나씩 살펴보자.

1. 에러가 있는 경우에 해당 에러를 보관할 Map을 선언

BindingResult를 파라미터로 선언한 순간부터 더 할 작업이 없다.

 

2. itemName은 없으면 안된다.

if (!StringUtils.hasText(item.getItemName())) {
    bindingResult.addError(new FieldError("item", "itemName", "Item name is required"));
}
  • BindingResult가 가지고 있는 addError()를 호출한다.
  • addError()는 파라미터로 ObjectError를 받는다. 그것을 상속받는 FieldErrornew로 생성한다.
  • FieldError는 말 그대로 특정 필드에 대한 에러를 저장하는 객체이다. 그리고 파라미터는 3가지를 받는다.
    • objectName → @ModelAttribute로 선언한 객체를 의미한다.
    • field → 위 objectName으로 선언한 객체의 특정 필드인 itemName을 의미한다.
    • defaultMessage → 보여줄 에러 메시지를 의미한다.

3. price는 없거나, 1,000원보다 작거나, 1,000,000원보다 크면 안된다.

if (item.getPrice() == null || item.getPrice() < 1_000 || item.getPrice() > 1_000_000) {
    bindingResult.addError(new FieldError("item", "price", "Price must be between 1 and 1_000_000"));
}

 

4. quantity는 없거나, 1보다 작거나, 9999보다 크면 안된다.

if (item.getQuantity() == null || item.getQuantity() < 1 || item.getQuantity() > 9999) {
    bindingResult.addError(new FieldError("item", "quantity", "Quantity must be between 1 and 9999"));
}

 

5. price * quantity의 값이 10,000원을 넘어야 한다.

if (item.getPrice() != null && item.getQuantity() != null) {
    int resultPrice = item.getPrice() * item.getQuantity();
    if (resultPrice < 10_000) {
        bindingResult.addError(new ObjectError("item", "price * quantity must be greater than 10,000. current price is " + resultPrice));
    }
}
  • 이번엔 특정 필드가 아니라 글로벌 에러를 저장할 때 예시다. 
  • 이번엔 ObjectErrornew로 생성해서 넣으면 된다. 이 객체는 두가지 파라미터를 받는다.
    • objectName → @ModelAttribute로 선언한 객체를 의미한다.
    • defaultMessage → 보여줄 에러 메시지를 의미한다.

6. 이렇게 검증을 직접 처리를 한 후, 검증 결과를 확인해서 에러가 있는 경우 해당 에러 데이터를 포함해서 다시 등록 폼을 보여준다.

if (bindingResult.hasErrors()) {
    log.debug("[addItemV1] bindingResult errors: {}", bindingResult);
    return "validation/v2/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">

        <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}" class="form-control" th:errorclass="field-error" 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}" class="form-control" th:errorclass="field-error" 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}" class="form-control" th:errorclass="field-error" placeholder="수량을 입력하세요">
            <div class="field-error" th: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/v2/items}'|"
                        type="button" th:text="#{button.cancel}">취소</button>
            </div>
        </div>

    </form>

</div> <!-- /container -->
</body>
</html>

 

globalError

<div th:if="${#fields.hasGlobalErrors()}">
    <p class="field-error" th:each="err : ${#fields.globalErrors()}" th:text="${err}">글로벌 에러 라인</p>
</div>
  • 글로벌 에러는 복수로 존재할 수 있다. 그래서 th:if="${#fields.hasGlobalErrors()}" 이 부분을 보면 글로벌 에러가 있는지 먼저 체크하는데 거기에 hasGlobalErrors라고 복수형태로 확인하는 것을 볼 수 있다.
  • #fields: #fieldsBindingResult가 제공하는 검증 오류에 접근할 수 있다.

 

itemName

<div>
    <label for="itemName" th:text="#{label.item.itemName}">상품명</label>
    <input type="text" id="itemName" th:field="*{itemName}" class="form-control" th:errorclass="field-error" placeholder="이름을 입력하세요">
    <div class="field-error" th:errors="*{itemName}">상품명 오류</div>
</div>
  • th:errors: 해당 필드에 오류가 있는 경우에 태그를 출력한다. th:if의 편의 버전이다. 
  • th:errorclass: th:field에서 지정한 필드에 오류가 있으면 class 정보를 추가한다.

price

<div>
    <label for="price" th:text="#{label.item.price}">가격</label>
    <input type="text" id="price" th:field="*{price}" class="form-control" th:errorclass="field-error" placeholder="가격을 입력하세요">
    <div class="field-error" th:errors="*{price}">상품명 오류</div>
</div>

 

quantity

<div>
    <label for="quantity" th:text="#{label.item.quantity}">수량</label>
    <input type="text" id="quantity" th:field="*{quantity}" class="form-control" th:errorclass="field-error" placeholder="수량을 입력하세요">
    <div class="field-error" th:errors="*{quantity}">상품명 오류</div>
</div>

 

아까보다 훨씬 코드가 깔끔해지고 중복도 제거됐음을 확인할 수 있다. 그러면, 아까 여전히 남아있던 문제점을 이 BindingResult는 해결해줄까? 

 

  • @ModelAttribute에 바인딩 시 타입 오류가 발생하면? 그러니까 가격에 'qqq'와 같은 문자열을 넣는다면?
    • BindingResult가 없으면 400 에러가 발생하면서 컨트롤러가 호출되지 않고 오류 페이지로 이동하게 된다.
    • BindingResult가 있으면 오류 정보를 BindingResult에 담아서 컨트롤러를 정상 호출한다. 결국 바인딩 하는 것 자체도 검증에 포함을 시켜주는 게 이 BindingResult인 것이다.
    • 그럼 어떻게? → @ModelAttribute의 객체에 타입 오류 등으로 바인딩이 실패하는 경우 스프링이 FieldError를 생성해서 BindingResult에 직접 넣어준다.

 

그럼 이제, BindingResult를 사용해서 훨씬 더 편리하게 검증을 할 수가 있다.

그런데 남은 문제가 있다. 오류가 발생하면 고객이 입력한 내용이 모두 사라진다는 점이다. 아래 사진을 보자.

 

고객의 요청데이터

오류가 발생한 결과

이렇듯 가격 필드에 입력했던 '10'이 사라졌다. 이것을 해결해보자.

 

FieldError의 생성자

bindingResult.addError(new FieldError("item", "itemName", "Item name is required"));

앞서 해본것처럼 어떤 필드에 에러가 있으면 이렇게 addError()를 호출해서 FieldError를 만들어 넣었다. 이때, FieldError는 여러 생성자를 가지는데 그 중에 사용자가 입력한 값을 보존하는 생성자가 있다.

 

그래서 위에서 처리했던, itemName, price, quantity 필드에 대한 에러를 추가할 때 이렇게 변경한다.

itemName

bindingResult.addError(new FieldError("item", "itemName", item.getItemName(), false, null, null, "Item name is required"));

 

price

bindingResult.addError(new FieldError("item", "price", item.getPrice(), false, null, null, "Price must be between 1 and 1_000_000"));

 

quantity

bindingResult.addError(new FieldError("item", "quantity", item.getQuantity(), false, null, null, "Quantity must be between 1 and 9999"));

 

  • 첫번째 인자로는, objectName을 그대로 받는다.
  • 두번째 인자로는, field를 그대로 받는다.
  • 세번째 인자로는, rejectedValue이다. 사용자가 입력했던 값을 집어넣는다. 즉, 사용자가 입력한 값을 @ModelAttribute에서 알아서 바인딩을 해주는데 그 값을 여기에 넣어주는 것이다.
  • 네번째 인자로는, 바인딩 자체가 실패했는지를 묻는다. 즉, Integer로 들어올 값이 String으로 들어왔다던가 이런 바인딩 자체의 실패의 여부이다. 여기서는 그건 아니니까 false를 모두 입력한다.
  • 다섯번째 인자로는, codes 이다. 이건 이후에 더 자세히 얘기한다.
  • 여섯번째 인자로는, arguments 이다. 이건 이후에 더 자세히 얘기한다.
  • 일곱번째 인자로는, 사용자에게 보여줄 메시지이다.

그래서 이렇게 FieldError의 또다른 생성자를 사용해서 넣어주면 된다. 다른 말로 FieldError가 가지고 있는 rejectedValue에 값을 넣어주면 타임리프에서 에러가 있는 경우 이 값을 보여주게 될 것이다. 

 

그래서 itemName으로 예시를 들자면,

<div>
    <label for="itemName" th:text="#{label.item.itemName}">상품명</label>
    <input type="text" id="itemName" th:field="*{itemName}" class="form-control" th:errorclass="field-error" placeholder="이름을 입력하세요">
    <div class="field-error" th:errors="*{itemName}">상품명 오류</div>
</div>
  • th:field="*{itemName}" 이 녀석이 정말 똑똑하게도 정상 상황에서는 모델 객체의 값을 사용하고, 에러가 발생했으면 FieldError에서 보관하고 있는 rejectedValue값을 사용해서 출력한다.

그래서 이제는 아래처럼 에러가 나도 사용자의 입력값을 보관한다.

 

 

남은 문제는 무엇일까?

바인딩 자체가 안되는 경우에 이러한 모습이 보여진다.

스프링이 이렇게 바인딩 자체에 실패한 경우에 BindingResult에 직접 해당 에러를 넣어주는데 그때 보이는 저 에러 메시지가 상당히 불친절하다. 사용자 입장에서는 이해할 수 없는 내용이다. 이 부분을 해결해보자.

 

FieldError생성자 2

위에서 말한 문제를 해결하기 앞서, 아까 봤던 FieldError의 생성자에 대해 좀 더 알아보는 시간을 먼저 가져보자.

잠깐 살펴봤지만 파라미터로 들어가는 값 중, codes, arguments가 있었다.

 

만약 이 웹 애플리케이션이 규모가 엄청 크고 비슷한 에러 문구가 나오는 화면이 여러개라면 그때마다 일일이 에러메시지를 작성하기는 싫을 것이다. 또한 그렇게 한 경우 변경할 필요가 생겼을 때 하나하나 다 변경해줘야 한다. 즉, 이 BindingResult로 에러를 저장하는 것도 메시지 기능을 사용할 수가 있다.

 

그래서 메시지 기능을 적용해보자. 우선 그러려면 스프링한테 메시지를 어디에 보관할건지 알려줘야 한다.

application.yaml

spring.messages.basename=messages,errors
  • 기본값이 저 messages라고 메시지, 국제화 포스팅에서 말한 바 있다.
  • 만약, 다른 파일이 더 있다면 추가적으로 저렇게 작성해줘야 한다. 나는 에러 메시지에 대해 파일을 만들거니까 이름을 errors라고 했다.

그리고 메시지 파일 하나를 만든다.

errors.properties

required.item.itemName=상품 이름은 필수입니다.
range.item.price=가격은 {0} ~ {1} 까지 허용합니다.
max.item.quantity=수량은 최대 {0} 까지 허용합니다.
totalPriceMin=가격 * 수량의 합은 {0}원 이상이어야 합니다. 현재 값 = {1}
  • 당연히 이 또한 국제화도 가능하다. errors_en.properties 파일을 만들어서 국제화 기능 충분히 사용할 수 있다.
  • 이렇게 보고 나니, codes 파라미터 말고 arguments 파라미터는 뭘 의미하는지 바로 알 것 같다. 바로 저 {0}, {1}에 들어갈 배열이고 숫자는 배열의 인덱스를 의미한다.

 

에러 메시지 파일을 만들었으면 이것을 가져다가 사용하면 된다. 아래처럼.

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() < 1_000 || item.getPrice() > 1_000_000) {
    bindingResult.addError(new FieldError("item", "price", item.getPrice(), false, new String[]{"range.item.price"}, new Object[]{1_000, 1_000_000}, null));
}
if (item.getQuantity() == null || item.getQuantity() < 1 || item.getQuantity() > 9999) {
    bindingResult.addError(new FieldError("item", "quantity", item.getQuantity(), false, new String[]{"max.item.quantity"}, new Object[]{9999}, null));
}
  • arguments는 여러개가 있을 수 있으니 배열로 받는게 이해가 된다. 근데 왜 codes도 배열일까?
  • 그 이유는 0번째 인덱스가 가장 우선순위가 높고 여기서부터 찾으면 바로 적용을 하지만 만약 그 코드를 찾지 못하면 다음 인덱스를 찾아가면서 제일 먼저 찾아진 코드를 메시지로 적용하게 설계됐기 때문이다.
  • 그리고 배열에 모든 인덱스를 다 뒤져도 값을 찾지 못한 경우 가장 마지막 파라미터인 defaultMessage값을 적용하는데 이 값마저 null이면 에러가 발생할 것이다.

 

이렇게 사용되는게 FieldErrorcodes, arguments 파라미터이다.

근데, 솔직히 이런 생각이 든다. "불편한데..?" 맞다. 솔직히 파라미터가 너무 많고 사용하기가 꽤 불편하다. 근데 스프링이 이렇게 불편하다고 느끼는 것들을 가만히 놔두지 않는다. 더 개선한다. 그래서 어떻게 개선할 수 있을까?

 

BindingResult의 위치

위에서 말했듯 이 BindingResult는 무조건 @ModelAttribute 객체 바로 다음에 와야 한다고 했다. 그 말은, 이미 BindingResult는 어떤 객체에 데이터를 바인딩 해야 하는지 알고 있다는 뜻이다. 그래서 이 BindingResult에는 이런 메서드가 있다.

 

  • getTarget()
  • getObjectName()

그래서 한번 이렇게 코드를 작성해보자.

@PostMapping("/add")
public String addItemV4(@ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes) {
    log.debug("target: {}", bindingResult.getTarget());
    log.debug("objectName: {}", bindingResult.getObjectName());
    
    ...
}

 

2024-08-31 17:54:24.562 DEBUG 51870 --- [nio-8080-exec-1] h.i.w.v.ValidationItemControllerV2       : target: Item(id=null, itemName=, price=null, quantity=null)
2024-08-31 17:54:24.571 DEBUG 51870 --- [nio-8080-exec-1] h.i.w.v.ValidationItemControllerV2       : objectName: item

이미 targetobjectName을 알고 있다. 그 말은 FieldError()라는 객체를 만들 때, objectName 이런 값을 굳이 넣을 필요가 없을 것 같다. 그래서 BindingResult가 다음과 같은 메서드를 제공한다.

 

if (!StringUtils.hasText(item.getItemName())) {
    bindingResult.rejectValue("itemName", "required");
}
if (item.getPrice() == null || item.getPrice() < 1_000 || item.getPrice() > 1_000_000) {
    bindingResult.rejectValue("price", "range", new Object[]{1_000, 1_000_000}, null);
}
if (item.getQuantity() == null || item.getQuantity() < 1 || item.getQuantity() > 9999) {
    bindingResult.rejectValue("quantity", "max", new Object[]{9999}, null);
}
  • rejectValue() 메서드를 사용하면, 그냥 어떤 필드이고 에러 메시지에 대한 코드가 어떤 코드인지만 작성하면 끝난다. 근데 여기서 코드를 그냥 "required"만 적었다. 우리가 작성한 코드는 "required.item.itemName"이었다. 어떻게 저렇게만 작성해도 될까? 이후에 자세히 알아보자.
  • 그리고 세번째 인자와 네번째 인자가 필요한 경우는 이제 codearguments가 필요할 때이다.
if (item.getPrice() != null && item.getQuantity() != null) {
    int resultPrice = item.getPrice() * item.getQuantity();
    if (resultPrice < 10_000) {
        bindingResult.reject("totalPriceMin", new Object[]{10_000, resultPrice}, null);
    }
}
  • globalError의 경우, reject()라는 메서드를 사용하면 된다. 특정 필드에 한정 짓는게 아니기 때문에 필드 이름은 필요없다.

이렇게 작성해서 실행해도 에러 메시지를 잘 출력해준다. 흐름 자체는 이해가 쉽다. 근데 저 오류 코드가 저렇게만 작성해도 정상적으로 동작한다는 게 받아들이기 쉽지 않다. 이 내용에 대해 자세히 알아보자!

 

스프링에서는 언제나 디테일한게 우선순위가 높다

굉장히 중요한 제목이다. 위 codes의 비밀도 이것과 연관이 있다. 다음과 같이 errors.properties 파일을 수정해보자.

errors.properties

required=필수값 입니다.
required.item.itemName=상품 이름은 필수입니다.


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

이렇게 되어 있을때, 아래 코드는 어떤 메시지를 출력할까?

bindingResult.rejectValue("itemName", "required");

 

`required.item.itemName`을 출력했음을 알 수 있다. 

 

"엥?! 왜 정확히 `required`라는 키가 있는데 저걸 출력하지?" 이게 스프링에서 우선순위를 결정하는 방식이다.

단순히 `required` 이렇게만 작성한 에러 메시지는 굉장히 범용적이다. 그래서 여기저기서 가져다가 사용할 수 있을 것 같다.

근데 개발을 하다 보니 어떤 화면에서는 조금 더 사용자 친화적으로 자세한 내용을 알려주고 싶은 필수 필드 내용이 있는 것이다. 

그런 경우에 이렇게 더 디테일하게 키를 작성하면 그 값이 스프링은 더 우선순위를 높게 친다. 

 

그래서, `required.item.itemName`, `required` 이렇게 두개가 나란히 있어도 더 자세한 값인 `required.item.itemName`를 출력하는 것이다. 그래서 개발하는 입장에서는 단순하게 그냥 아래처럼 코드를 작성해두면,

bindingResult.rejectValue("itemName", "required");

추후에 더 자세한 메시지가 필요하다고 느껴져서 erros.properties 파일에 자세한 키를 추가만 하면 된다. 개발 코드에 아무런 변화를 주지 않아도 된다는 뜻이다. 이게 가장 중요하다. 

 

근데 의문이 들것이다. "아니 그럼 `required.item.itemName`을 어떻게 만들어내는 것인가? 그냥 단순히 앞에 `required`가 있는 에러 메시지 키를 다 찾는것인가?" 스프링은 에러 코드를 만들기 위한 구현체도 다 있다. MessageCodesResolver라는 인터페이스를 구현한 구현체를 통해 저 에러 코드를 만들어 낸다. 

 

다음 코드를 보자.

MessageCodesResolverTest

package hello.itemservice.validation;

import org.junit.jupiter.api.Test;
import org.springframework.validation.DefaultMessageCodesResolver;
import org.springframework.validation.FieldError;
import org.springframework.validation.MessageCodesResolver;

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

public class MessageCodesResolverTest {

    MessageCodesResolver messageCodesResolver = new DefaultMessageCodesResolver();

    @Test
    void messageCodesResolverObject() {
        String[] messageCodes = messageCodesResolver.resolveMessageCodes("required", "item");
        for (String messageCode : messageCodes) {
            System.out.println("messageCode = " + messageCode);
        }

        assertThat(messageCodes).containsExactly("required.item", "required");
    }
}
  • MessageCodesResolver의 기본 구현체로 스프링은 DefaultMessageCodesResolver를 사용한다.
  • 그리고 그 구현체는 resolveMessageCodes(String s, String s1)라는 메서드를 가지고 있는데, 여기에서 저렇게 두 가지 문자열인 "required", "item" 이라는 값을 넣으면 어떻게 출력되냐면 다음과 같이 출력된다.
messageCode = required.item
messageCode = required

 

그러니까 넘겨 받은 파라미터를 통해 디테일 한 코드부터 디테일하지 않은 코드를 만들어내는 것이다.

 

다음 코드도 보자.

@Test
void messageCodesResolverField() {
    String[] messageCodes = messageCodesResolver.resolveMessageCodes("required", "item", "itemName", String.class);
    for (String messageCode : messageCodes) {
        System.out.println("messageCode = " + messageCode);
    }
}
  • resolveMessageCodes(String s, String s1, String s2, Class<?> aClass) 메서드도 존재하는데 이 메서드에 파라미터로 "required", "item", "itemName", String.class라고 넣으면 어떤 값이 나올까?
messageCode = required.item.itemName
messageCode = required.itemName
messageCode = required.java.lang.String
messageCode = required

 

이렇게 나오게 된다. 그리고 BindingResultreject(), rejectValue() 메서드는 내부에서 이 MessageCodesResolver를 사용한다. 

 

"하지만? rejectValue()는 파라미터로 필드 이름하고 코드만 넘기고, reject()는 코드 이름만 넘기는데요?"

그렇다, 근데 BindingResult는 뭘 알고 있냐? 바로 objectName을 알고 있다. 파라미터의 위치로 이미 알고 있는 상태이다. 그래서! 결국 resolveMessageCodes("required", "item", "itemName", String.class) 라는 메서드를 똑같이 사용할 수 있고 그 메서드를 통해 얻은 반환값을 통해 사실은 아래 코드를 만든다.

new FieldError("item", "itemName", item.getItemName(), messageCodes, null, null);
  • 그럼 저 String[] codes 파라미터 부분에 들어가는 messageCodes 위에서 만든 messageCodes 그대로 들어가게 된다.
  • 그리고 저 부분을 얘기하면서 0번 인덱스가 가장 우선순위가 높고 찾지 못하면 다음 인덱스 순서로 에러메시지를 찾아서 타임리프가 뿌려준다고 했다. 
  • 그럼 0번 인덱스는? "required.item.itemName"이다. 가장 마지막 인덱스는? "required"이다.

그래서 아래와 같은 코드와 에러 메시지가 있을 때 우선순위는 required.item.itemName이 적용되어 이 메시지가 화면에 보이는 것이다.

bindingResult.rejectValue("itemName", "required");

errors.properties

required=필수값 입니다.
required.item.itemName=상품 이름은 필수입니다.


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

 

이제야 의문이 해소가 됐다. 참고로, 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"

 

BindingResult는 이런 숨겨진 내부 로직이 존재하는 것이다! 즉, 디테일한 코드를 먼저 생성해내기 때문에 디테일 한 코드부터 제일 디테일하지 않은 코드를 쭉 순회하면서 먼저 찾아진 코드를 그대로 사용한다. 

 

다시 강조하지만 중요한건, 메시지 코드의 변경이 필요할때, 예를 들어, "필수값 입니다." → "이름 필드는 필수값 입니다" 이런식으로 메시지를 바꾸고 싶을 때 애플리케이션 코드를 변경하는 게 아니라 메시지를 디테일하게 추가함으로써 원하는 결과를 얻어낼 수 있다는 것이다.

 

참고로, ValidationUtils 라는 유틸 클래스가 있다. 이걸 사용하면 코드 양을 좀 줄여주는 효과가 있다.

ValidationUtils.rejectIfEmptyOrWhitespace(bindingResult, "itemName", "required");
if (!StringUtils.hasText(item.getItemName())) {
    bindingResult.rejectValue("itemName", "required");
}

위 두 코드가 같은 코드이다. 근데 ValidationUtils는 저렇게 빈 문자열인 경우에 검증 처리같은 매우 간단한 기능만 있기 때문에 그냥 참고정도로만 알아두면 될 것 같다.

 

 

스프링이 직접 처리해주는 TypeMismatch 처리

이제 드디어 Integer 필드에 문자열을 입력했을 때 나오는 이상한 에러문구에 대한 원인을 이해할 수 있다.

자 일단 그 에러를 마주해보자.

  • "가격은 1,000 ~ 1,000,000까지 허용합니다." 이 에러메시지는 내가 작성한 에러메시지다. 내가 체크하는 검증 로직에도 실패했으니 당연히 저 에러 메시지가 보여야 한다. 
  • 그 부분을 제외한 위에 부분은 어디서 나온걸까?

 

우리가 BindingResult에 에러가 있으면 에러를 출력하는 로그를 찍은적이 있다. 그 로그를 봐보자.

Field error in object 'item' on field 'price': rejected value [qq]; \
codes [typeMismatch.item.price,typeMismatch.price,typeMismatch.java.lang.Integer,typeMismatch]; \
arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [item.price,price]; \
arguments []; \
default message [price]]; \
default message [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: "qq"]
  • 보기 좋게 조금 단락을 나누었다. 
  • 이 에러를 보면 price라는 필드에서 에러가 발생했고 rejected value [qq]라고 되어 있다.
  • 그리고 그 때 codes를 보면 이렇게 되어 있다.
    • typeMismatch.item.price
    • typeMismatch.price
    • typeMismatch.java.lang.Integer
    • typeMismatch
  • 즉, 스프링이 이 경우에 rejectedValue("qq", typeMismatch")라고 나 대신에 메서드 호출해준 것이다.
  • 그러니까 이제 에러 메시지를 출력하기 위해 저 코드를 내가 선언한 errors라는 메시지 파일을 찾아서 보는데 코드가 없기 때문에? defaultMessage를 출력하게 된다.
  • 위 로그에서 defaultMessage는 다음과 같다.
    • 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: "qq"

이제야 이 에러 메시지의 비밀이 풀리게 되는 순간이다. 그럼 우리가 저 보기 싫은 에러 메시지를 없애고 사용자 친화적인 메시지를 보여주려면? 저 codes를 추가하면 된다. 나는 딱 이렇게만 넣었다.

typeMismatch.java.lang.Integer=숫자 필드입니다.

 

그랬더니 다음과 같은 아름다운 결과가 도출된다. 

 

누군가는 그럴수도 있다. "왜 에러메시지가 두개나 보여지죠? 헷갈리니까 하나만 보이면 좋을 것 같아요!"

나는 이 경우도 문제가 되지 않는다고 생각하지만 만약 타입 미스매치로 바인딩 자체에 실패가 된 경우 바로 다시 등록 폼을 보여주는 방법을 택해도 된다. 다음 코드처럼 맨 앞에 바인딩 자체에 실패했는지 로직을 추가하면 끝난다. (이건 원하는 사람만 이렇게 하면 된다)

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

    if (bindingResult.hasErrors()) {
        log.debug("[addItemV2] bindingResult errors: {}", bindingResult);
        return "validation/v2/addForm";
    }

    // 검증 로직
    if (!StringUtils.hasText(item.getItemName())) {
        bindingResult.rejectValue("itemName", "required");
    }
    if (item.getPrice() == null || item.getPrice() < 1_000 || item.getPrice() > 1_000_000) {
        bindingResult.rejectValue("price", "range", new Object[]{1_000, 1_000_000}, null);
    }
    if (item.getQuantity() == null || item.getQuantity() < 1 || item.getQuantity() > 9999) {
        bindingResult.rejectValue("quantity", "max", new Object[]{9999}, null);
    }

    // 특정 필드가 아닌 복합 룰 검증
    if (item.getPrice() != null && item.getQuantity() != null) {
        int resultPrice = item.getPrice() * item.getQuantity();
        if (resultPrice < 10_000) {
            bindingResult.reject("totalPriceMin", new Object[]{10_000, resultPrice}, null);
        }
    }

    // 에러가 있는 경우, 다시 폼을 보여줌
    if (bindingResult.hasErrors()) {
        log.debug("[addItemV2] bindingResult 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를 통해 컨트롤러에서 들어온 데이터를 검증하고 검증 실패 시 어떤 필드에 어떤 에러가 있는지 잘 담아 다시 사용자에게 화면에 뿌려줄 수 있었다. 그리고 이 과정에서 에러 메시지를 유연하게 사용하는 방법을 배웠다. 

 

여기서 핵심은, 내가 rejectValue(item.getItemName(), "required") 라고 codes 부분에 가장 덜 디테일한 `required`를 적어도 스프링이 내부적으로 MessageCodesResolver를 사용해서 만들어내는 가장 디테일한 코드부터 가장 덜 디테일한 코드까지를 모두 가져다가 사용해서 가장 디테일한 코드부터 먼저 찾는순서로 에러 메시지를 찾아내기 때문에, 추후에 더 자세하거나 다른 에러 메시지가 필요해진 경우에 애플리케이션 코드를 변경할 필요 없이 그냥 에러 메시지만 추가해주면 된다는 것이다. 

 

근데, 한가지 불편한 점이 있다. 컨트롤러에 검증 로직이 대부분을 차지한다는 점. 즉, 역할과 책임이 너무 가중되어 있다. 이 부분을 해결하기 위해 Validator라는 인터페이스를 사용해서 검증 로직을 분리할 수 있다. 그 부분을 이제 알아보자.

 

Validator 인터페이스

우리는 이제 스프링이 제공하는 Validator라는 인터페이스를 구현한 구현체로 검증 로직을 분리하려고 한다.

무엇을 검증하느냐? Item을 검증한다. 그래서 이름을 ItemValidator라고 작성한 클래스를 만든다.

 

ItemValidator

package hello.itemservice.domain.item;

import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.validation.Errors;
import org.springframework.validation.Validator;

@Component
public class ItemValidator implements Validator {
    @Override
    public boolean supports(Class<?> aClass) {
        return Item.class.isAssignableFrom(aClass); // Item 클래스와 Item 클래스의 자식 클래스까지 검증 지원
    }

    @Override
    public void validate(Object o, Errors errors) {
        Item item = (Item) o;

        // 검증 로직
        if (!StringUtils.hasText(item.getItemName())) {
            errors.rejectValue("itemName", "required");
        }
        if (item.getPrice() == null || item.getPrice() < 1_000 || item.getPrice() > 1_000_000) {
            errors.rejectValue("price", "range", new Object[]{1_000, 1_000_000}, null);
        }
        if (item.getQuantity() == null || item.getQuantity() < 1 || item.getQuantity() > 9999) {
            errors.rejectValue("quantity", "max", new Object[]{9999}, null);
        }

        // 특정 필드가 아닌 복합 룰 검증
        if (item.getPrice() != null && item.getQuantity() != null) {
            int resultPrice = item.getPrice() * item.getQuantity();
            if (resultPrice < 10_000) {
                errors.reject("totalPriceMin", new Object[]{10_000, resultPrice}, null);
            }
        }
    }
}
  • Validator 인터페이스는 두 가지 클래스를 구현해야 한다.
    • supports(Class<?> aClass)
    • validate(Object o, Errors errors)
  • support() 메서드 안에는 어떤 클래스를 검증할 수 있는지를 작성한다. 그래서 우리는 Item이라는 객체를 검증할 것이기 때문에 Item.class.isAssignableFrom(aClass)라고 작성한다. 여기서 isAssignableFrom()은 넘겨받은 클래스와 그 클래스의 자식까지 지원한다. 
  • validate() 메서드 안에 기존에 검증 로직을 그대로 가져다가 복사-붙여넣기 하면 끝이다. 
  • 그리고 ItemValidator를 컴포넌트 스캔이 가능하도록 @Component 애노테이션을 사용한다.

그럼 이제 다시 컨트롤러로 돌아와서, 일단 이 ItemValidator를 주입받는다.

package hello.itemservice.web.validation;

import hello.itemservice.domain.item.Item;
import hello.itemservice.domain.item.ItemRepository;
import hello.itemservice.domain.item.ItemValidator;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.util.StringUtils;
import org.springframework.validation.BindingResult;
import org.springframework.validation.FieldError;
import org.springframework.validation.ObjectError;
import org.springframework.validation.ValidationUtils;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;

import java.util.HashMap;
import java.util.List;
import java.util.Map;

@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) {

        itemValidator.validate(item, bindingResult);

        if (bindingResult.hasErrors()) { // 에러가 있는 경우, 다시 폼을 보여줌
            log.debug("[addItemV2] bindingResult 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}";
    }

    @GetMapping("/{itemId}/edit")
    public String editForm(@PathVariable Long itemId, Model model) {
        Item item = itemRepository.findById(itemId);
        model.addAttribute("item", item);
        return "validation/v2/editForm";
    }

    @PostMapping("/{itemId}/edit")
    public String edit(@PathVariable Long itemId, @ModelAttribute Item item) {
        itemRepository.update(itemId, item);
        return "redirect:/validation/v2/items/{itemId}";
    }
}
  • 검증하는 부분에 주입받은 ItemValidatorvalidate()을 호출하면 끝이다. 
  • BindingResult의 부모가 Errors이기 때문에 validate(Class<?> aClass, Errors errors)의 두번째 파라미터로 넘기는데 아무런 문제가 없다.

그런데, 생각해보면 별로다. 뭐가 별로냐면 이걸 굳이 주입받고, 빈으로 등록하고, Validator 인터페이스를 구현하고 이럴 필요 없이 그냥 아래와 같은 클래스를 만든 다음,

package hello.itemservice.domain.item;

import org.springframework.util.StringUtils;
import org.springframework.validation.Errors;

public class ItemValidator {
    
    public void validate(Object o, Errors errors) {
        Item item = (Item) o;

        // 검증 로직
        if (!StringUtils.hasText(item.getItemName())) {
            errors.rejectValue("itemName", "required");
        }
        if (item.getPrice() == null || item.getPrice() < 1_000 || item.getPrice() > 1_000_000) {
            errors.rejectValue("price", "range", new Object[]{1_000, 1_000_000}, null);
        }
        if (item.getQuantity() == null || item.getQuantity() < 1 || item.getQuantity() > 9999) {
            errors.rejectValue("quantity", "max", new Object[]{9999}, null);
        }

        // 특정 필드가 아닌 복합 룰 검증
        if (item.getPrice() != null && item.getQuantity() != null) {
            int resultPrice = item.getPrice() * item.getQuantity();
            if (resultPrice < 10_000) {
                errors.reject("totalPriceMin", new Object[]{10_000, resultPrice}, null);
            }
        }
    }
}

 

컨트롤러에서는 아래처럼 그냥 new로 객체 생성해서 validate() 메서드를 호출해도 될 것 같다. 맞다, 된다.

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

    new ItemValidator().validate(item, bindingResult);

    if (bindingResult.hasErrors()) { // 에러가 있는 경우, 다시 폼을 보여줌
        log.debug("[addItemV2] bindingResult 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}";
}

 

 

근데 굳이 굳이 이렇게 스프링이 만든 Validator 인터페이스를 구현하고 빈으로 등록한 다음 주입 받아 사용하는데는 이유가 있다. 더 편리하게 사용할 수 있기 때문이다.

 

@Validated, @InitBinder

스프링이 제공하는 @Validated 애노테이션으로 다음 코드도 없앨 수 있다.

itemValidator.validate(item, bindingResult);

 

다음 컨트롤러를 보자.

package hello.itemservice.web.validation;

import hello.itemservice.domain.item.Item;
import hello.itemservice.domain.item.ItemRepository;
import hello.itemservice.domain.item.ItemValidator;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.util.StringUtils;
import org.springframework.validation.BindingResult;
import org.springframework.validation.FieldError;
import org.springframework.validation.ObjectError;
import org.springframework.validation.ValidationUtils;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.WebDataBinder;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;

import java.util.HashMap;
import java.util.List;
import java.util.Map;

@Slf4j
@Controller
@RequestMapping("/validation/v2/items")
@RequiredArgsConstructor
public class ValidationItemControllerV2 {

    private final ItemRepository itemRepository;
    private final ItemValidator itemValidator;

    @InitBinder
    public void init(WebDataBinder binder) {
        binder.addValidators(itemValidator);
    }

    ...

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

        if (bindingResult.hasErrors()) { // 에러가 있는 경우, 다시 폼을 보여줌
            log.debug("[addItemV2] bindingResult 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}";
    }

    @PostMapping("/{itemId}/edit")
    public String edit(@PathVariable Long itemId, @ModelAttribute Item item) {
        itemRepository.update(itemId, item);
        return "redirect:/validation/v2/items/{itemId}";
    }
}
  • Validator 인터페이스를 구현한 구현체를 빈으로 등록하고 검증이 필요한 컨트롤러에서 주입을 받는다.
  • @InitBinder라는 애노테이션으로 이 컨트롤러가 호출될 때마다 검증할 Validator 구현체를 집어넣는다.
  • 여기까지 하면, 이 컨트롤러에 한하여 어떤 요청이 들어와도 ItemValidator를 통해 검증을 할 수 있게 된다. 그래서 "/add" 라는 pathPOST요청이 들어왔을 때 이 검증기를 실행시키고 싶으면, @Validated 애노테이션을 @ModelAttribute Item item 옆에 붙이면 끝난다. (addItemV5)

근데, 보다시피 @Validated 애노테이션만 붙이면 어떤 Validator가 사용될지 어떻게 알까? 보니까 binder.addValidators(Validator... validator)는 여러 Validator 구현체가 들어갈 수 있는데 말이다. 아래처럼 말이다.

binder.addValidators(itemValidator, categoryValidator, productValidator);

이때 바로 Validator 인터페이스의 메서드인 supports(Class<?> aClass)가 사용되는 것이다. 그래서 @Validated 애노테이션이 붙은 바로 옆 객체인 Itemsupports()에 넘어가게 된다. 그래서 이 메서드가 `true`를 반환하는 Validator를 선택하여 검증하게 된다.

 

 

글로벌 설정

참고로, 글로벌로 설정할 수도 있다. 지금은 현재 이 @InitBinder 애노테이션이 달린 컨트롤러에 한하여 검증이 가능하게 설정했지만 모든 컨트롤러에 다 적용할 수도 있다. 다만, 그럴일이 매우 드물지만 가능은 하다.

 

글로벌 설정 방법

아래와 같이 WebMvcConfigurer 인터페이스를 구현한 구현체를 만든다. 단순하게 하기 위해 여기서는 메인 클래스로 적용했다.

package hello.itemservice;

import hello.itemservice.domain.item.ItemValidator;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.validation.Validator;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@SpringBootApplication
public class ItemServiceApplication implements WebMvcConfigurer {

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

	@Override
	public Validator getValidator() {
            return new ItemValidator();
	}
}
  • 그리고 Validator를 반환하는 메서드를 만들고 원하는 Validator를 반환하면 된다.
  • 글로벌로 적용했으니 특정 컨트롤러에 @InitBinder는 제거해야 한다. (아래 코드처럼 주석 처리)
//private final ItemValidator itemValidator;

/*@InitBinder
public void init(WebDataBinder binder) {
    binder.addValidators(itemValidator);
}*/
  • 글로벌로 적용해도 어떤 컨트롤러를 호출할 때 검증할지 설정하는 @Validated 애노테이션은 당연히 넣어야 한다. (아래 코드처럼)
@PostMapping("/add")
public String addItemV5(@Validated @ModelAttribute Item item,
                        BindingResult bindingResult,
                        RedirectAttributes redirectAttributes) {

    if (bindingResult.hasErrors()) { // 에러가 있는 경우, 다시 폼을 보여줌
        log.debug("[addItemV2] bindingResult 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}";
}

 

여기까지가 글로벌 설정이다. 그런데, 글로벌로 설정하는 경우는 거의 없다. 그냥 필요한 컨트롤러에서 @InitBinder를 통해 사용하자.

728x90
반응형
LIST

+ Recent posts