[Spring] Validation

bien·2023년 9월 18일
0

Spring_MVC2

목록 보기
5/7

검증 요구사항

요구사항: 검증 로직 추가

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

사용자가 입력하는 데이터들에 대해서 적절한지 이를 검증하는 과정이 필요하다. 웹 서비스는 사용자가 입력한 데이터에 오류가 있다면, 이 데이터를 유지한 상태로 어떤 오류가 발생했는지 친절하게 알려주어야 한다.

컨트롤러의 중요한 역할 중 하나는 HTTP 요청이 정상인지 검증하는 것이다. 그리고 정상 로직보다 이같은 검증 로직을 개발하는 것이 더 어려울 수도 있다.

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

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

검증 직접 처리 -소개

상품 저장 성공

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

상품 저장 검증 실패

사용자가 입력한 데이터가 검증 범위를 넘어서면, 서버 검증 로직이 실패해야 한다. 이렇게 검증에 실패한 경우, 고객에게 다시 상품 등록 폼을 보여주고, 어떤 값을 잘못 입력했는지 알려줘야 한다.

Model에 1. 사용자가 입력한 데이터와 2. 어떤 부분에서 검증 오류가 발생했는지에 관한 정보를 담아서 반환해줘야 한다.


검증 직접 처리 - 개발

상품 등록 검증

상품 등록 검증 코드를 작성해보자.

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", "상품 이름은 필수입니다.");
 }

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

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

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

특정 필드를 넘어서는 오류인 경우, 필드의 이름을 넣을 수 없으므로 globarError라는 key를 사용한다.

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

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

검증에서 오류 메시지가 하나라도 있으면 오류 메시지를 출력하기 위해 model에 errors를 담고 입력 폼이 있는 뷰 템플릿으로 보낸다.

@ModelAttribute 애노테이션을 사용하면 자동으로 model에 객체를 담고, model.attribute로 객체명으로 객체를 넘겨준다. 따라서 다시 뷰로 돌아갔을때 (사용자가 입력한 데이터를 담고 있는) model 객체를 다시 가져와 뷰에 뿌려줄 수 있는 것이다!

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

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

글로벌 오류 메시지

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

오류 메시지는 errors에 내용이 있을때만 출력된다. 타임리프의 th:if를 사용하면 조건에 만족할때만 해당 HTML 태그를 출력할 수 있다.

Safe Navigation Operator

  • errors가 null이면 errors.containKey()를 호출하는 순간 NullPointerException이 발생한다.
  • errors?.은 errors가 null일때 NullPointerException이 발생하는 대신 null을 반환하는 문법이다. (th:if에서 null은 출력되지 않는다.
  • 스프링의 SpringEL이 제공하는 문법.

정리

  • 검증 오류 발생시 입력 폼을 다시 보여준다.
  • 검증 오류와 관련된 정보를 사용자에게 제공한다.
  • 검증 오류가 발생해도 데이터가 유지된다.

남은 문제점

  • 뷰 템플릿의 검증 코드 중복
  • 타입 오류 처리가 안된다.
    • Integer타입인 price, quantity에 String 입력 시 오류 발생
  • item의 price에 문자를 입력하면 바인딩이 불가능하므로 사용자가 입력한 데이터가 사라진다.
    • 따라서, 사용자가 어떤 내용을 입력해서 오류가 발생했는지 이해하기 어렵다.
    • 고객이 입력한 값도 어딘가에 별도로 관리가 되어야 한다.

BindingReulst1

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 다음에 와야 한다.

필드 오류 - Field Error

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

+) FieldError는 ObjectError의 자식

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:filed에서 지정한 필드에 오류가 있으면 class 정보를 추가한다.

글로벌 오류 처리

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

글로벌 오류가 여러개인 경우 th:each로 모두 출력할 수 있다.

필드 오류 처리

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

BindingResult2

  • 스프링이 제공하는 검증 오류 보관 객체. 검증 오류가 발생하면 여기에 보관하면 된다.
  • BindingResult가 있으면 @ModelAttribute에 데이터 바인딩시 오류가 발생해도 컨트롤러가 호출된다!
    (없으면 컨트롤러를 호출 하지 않고 바로 오류페이지로 튕겨버린다.)

즉,

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

  • BindingResult ❌ -> 400 오류가 발생하면서 컨트롤러가 호출되지 않고, 오류 페이지로 이동한다.
  • BindingResult ⭕️ -> 오류정보(FieldError)를 BindingResult에 담아서 컨트롤러를 정상 호출한다.
    • 스프링: (아.. 개발자가 이 필드와 관련해서 고민이 있나보네. 오류를 담아서 보내주자)
    • BindingResult 존재 시, binding 중 발생한 문제 정보를 해당 객체에 담아준다.

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

  1. @ModelAttribute의 객체에 타입 오류 등으로 바인딩이 실패하는 경우 (스프링이 FieldError 생성해서 BindingResult에 넣어준다.)
  2. 개발자가 직접 넣는다.
  3. Validator사용. -> 추후 다룰 예정

💭 2종류의 에러가 있다.
1. ModelAttribute의 바인딩 자체 (1번)
2. 로직상의 에러 (2번)

📕 주의

  • BindingResult는 검증할 대상 바로 다음에 와야한다. 순서가 중요하다. 예를 들어서 @ModelAttribtue Item item 바로 다음에 BindingResult가 와야 한다.
  • BindingResultModel에 자동으로 포함된다.

BindingResult와 Errors

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

BindingResult는 인터페이스이고, Errors 인터페이스를 상속받고 있다. 실제 넘어오는 구현체는 BeanPropertyBindingResult라는 것인데, 둘 다 구현하고 있으므로 Errors를 대신 사용할 수도 있다.

다만 BindingResult가 Errors에 비해 더 많은 기능을 제공하고 있으므로 주로 관례상 BindingResult를 많이 사용한다.
(코드의 addErorr()도 BindingResult가 제공한다.)


FieldError, ObjectError

목표:
1. 사용자 입력값 화면에 남기기
2. FieldError, 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 생성자

두 가지 생성자를 제공한다.

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

사용자가 price(가격) 입력칸에 숫자가 아닌 문자를 입력했다고 생각해보자. 🤔Integer 타입의 변수(price)에 String 값(ex."qqqq")을 저장할 수 있을까? 오류 발생시 사용자 입력값을 다시 돌려주기 위해서, 사용자 입력 값을 보관할 별도의 방법이 필요하다. FieldError는 이 같은 입력 값 저장 기능을 제공한다.

여기서 rejectedValue가 오류 발생 시 사용자 입력 값을 저장하는 필드이다. bindingFailure는 타입 오류 같은 바인딩이 실패했는지 여부를 적어주면 된다. 여기서는 바인딩이 실패한 것은 아니기 때문에 false를 사용한다.

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

th:field="*{price}"

  • 정상 상황: 모델 객체의 값을 사용.
  • 오류 발생: FieldError에서 보관한 값을 사용해서 값 출력

스프링이 스스로 영리하게 값을 바꿔가며 넣어준다! 이 행위를 해준다는 사실 자체를 인지하고 있는것도 중요한것 같다.

스프링의 바인딩 오류 처리

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


오류 코드와 메시지 처리1

목표: 오류 메시지를 체계적으로 다루어보자!

앞서 살펴본 FieldError의 두번째 생성자의 파라미터 중, codesargument를 활용해 오류 코드로 메시지를 제공할 수 있다!

application.properties

spring.messages.basename=messages,errors

src/main/resources/errors.properties

required.item.itemName=상품 이름은 필수입니다.
range.item.price=가격은 {0} ~ {1} 까지 허용합니다.
max.item.quantity=수량은 최대 {0} 까지 허용합니다.
totalPriceMin=가격 * 수량의 합은 {0}원 이상이어야 합니다. 현재 값 = {1}
  • 제약조건.객체명.필드이름 형식으로 code값을 설정했다.

ValidationItemControllerV2 - 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

😠 아.. 이거 너무 복잡한데. 오류 코드 key자체도 번잡하고, Filed, ObjectError 파라미터도 너무 많은거 아냐?

목표:
1. FieldError, ObjectError는 다루기 너무 번거롭다.
2. 오류 코드를 좀 더 자동화 할 수 있지 않을까? 예) item.itemName 처럼?

앞서 컨트롤러에서 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)

이러한 출력결과를 확인할 수 있다.

즉, bindingResult는 target에 대해 알고 있다.

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

📗 rejectValue()

void rejectValue(@Nullable String field, 
				 String errorCode,
			   	 @Nullable Object[] errorArgs, 
                 @Nullable String defaultMessage);
  • field : 오류 필드명
  • errorCode : 오류 코드
    • 이 오류 코드는 메시지에 등록된 코드가 아니다. 뒤에서 설명할 messageResolver를 위한 오류 코드이다.
    • 이후 자세히 언급할 예정. 여기선 아, 에러코드 하나만 넣으면 object명, field명 조합해서 코드를 만들어 주나보다! 하고 생각하면 됨
  • errorArgs : 오류 메시지에서 {0}을 치환하기 위한 값
  • defaultMessage : 오류 메시지를 찾을 수 없을 때 사용하는 기본 메시지
bindingResult.rejectValue("price", "range", new Object[]{1000, 1000000}, null)

앞서 BindingResult어떤 객체를 대상으로 검증하는지 target을 이미 알고 있다고 했다. 따라서 target(item)에 대한 정보는 없어도 된다. 오류 필드명은 동일하게 price를 사용했다.
전반적으로 다소 단축된 모습이다!

축약된 오류 코드

FieldError()를 직접 다룰 때는 오류 코드를 range.item.price와 같이 모두 입력했다. 그런데 rejectValue()를 사용하고부터는 오류 코드를 range로 간단하게 입력했다. 그래도 무언가 규칙이 있는 것 처럼 오류 메시지를 잘 출력한다. 이 부분을 이해하려면 MessageCodesResolver를 이해해야 한다.


오류 코드와 메시지 처리3

목표: 어떤 식으로 오류 코드를 설계할 것이지 알아보자!

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

😰오류 코드가 이렇게까지 복잡할 필요가 있나? 이런 식으로 일일이 모든 변수에 대한 에러 메시지를 설정하려면 그 양이 너무 많은데..

required : 필수 값 입니다.
range : 범위 오류 입니다

🤔이렇게 간단하게 오류 메시지가 나가는건 어떨까?

단순한 오류 메시지는 범용성이 좋으나, 세밀한 작성이 어렵다. 반대로 너무 자세한 오류 메시지는 범용성이 떨어진다. 가장 좋은 방법은 범용성으로 사용하다가, 세밀하게 작성해야 하는 경우에는 세밀한 작성이 가능하도록 메시지에 단계를 두는 방법이다.

예를 들어서,

required 오류 메시지만 사용하는 경우 이 메시지를 선택해서 사용한다.

required: 필수 값 입니다.

그런데 오류 메시지에 required.item.itemName과 같이 객체명과 필드명을 조합한 세밀한 코드가 있으면 이 메시지를 높은 우선순위로 사용하는 것이다.

 #Level1 (더 세밀한 코드가 선택된다.)
 required.item.itemName: 상품 이름은 필수 입니다.
 
 #Level2
 required: 필수 값 입니다.

위와 같은 방식으로 메시지 개발 시, 객체명과 필드명을 조합한 메시지가 있는지 확인하고, 없으면 좀 더 범용적인 메시지를 선택하도록 추가 개발이 필요하다. 그러나 메시지 작성 코드가 아닌 메시지 사용 부분의 코드는 수정이 필요없다. 범용성 있게 잘 개발해두면, 메시지의 추가만으로 매우 편리하게 오류 메시지를 관리할 수 있게된다.

스프링은 MessageCodeResolver라는 것으로 이 기능을 지원한다.


오류 코드와 메시지 처리4

목표: 테스트 코드를 통해 MessageCodesResolver를 알아보자!

bindingResult.rejectValue() 실행 시 내부에서 new FieldError를 생성하여 각 인수들을 전달한다. (objectName은 이미 알고 있고, field 값은 전달받는다.) 이 중 codes(String[])값은 MessageCodesResolver가 생성해서 전달한다.

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

테스트 코드는 모두 통과한다. 따라서, 각 codeResolver가 생성하는 메시지 코드들이 확인 코드 속 String[]들과 같다.

📗MessageCodesResolver

  • 검증 오류 코드메시지 코드들을 생성한다.
  • MessageCodesResolver인터페이스이고 Default MessageCodesResolver는 기본 구현체이다.
  • 주로 다음과 함께 사용.
    • 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()는 내부에서 MessageConverter를 사용한다. 여기에서 메시지 코드들을 생성한다.
  • FieldError, ObjectError의 생성자를 보면, 오류 코드를 하나가 아니라 여러 오류 코드를 가질 수 있다.
    • MessageCodesResolver를 통해서 생성된 순서대로 오류 코드를 보관한다.

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

오류 코드 관리 전략

핵심은 구체적인 것에서! 덜 구체적인 것으로

MessageCodesResolverrequired.item.itemName처럼 구체적인 것을 먼저 만들고, required처럼 덜 구체적인 것을 가장 나중에 만든다. 이 방법으로 메시지와 관련된 공통 전략을 편리하게 도입할 수 있다!

왜 이렇게 복잡하게 사용하는가?

모든 오류 코드에 대해 메시지를 각각 다 정의하고 관리하는 것은 개발자에게 매우 힘든 일이다. 중요도가 작은 메시지는 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에서 메시지를 찾는다.

  • 구체적인 것에서 덜 구체적인 것으로
  • 1번이 없으면 2번, 2번이 없으면 3번을
  • 크게 중요하지 않ㅇ느 오류 메시지는 기존 메시지를 재활용하면 된다!

📗 ValidationUtils

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

기존에 이 코드와 완전히 동일한 코드를 ValidationUtils라는 기능을 이용하여 사용할 수 있다.

ValidationUtils.rejectIfEmptyOrWhitespace(bindingResult, "itemName","required");
  • 다음과 같이 한줄로 생략 가능하다.
  • 제공하는 기능은 Empty, 공백 같은 단순한 기능만 제공한다.

그냥 이런 기능이 있다고 참고만 하면 된다.

📚 (전반적인 과정) 정리

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

오류 코드와 메시지 처리6

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

검증 오류 코드는 2가지로 나눌 수 있다.

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

Integer 타입의 price 필드에 문자 "A"를 입력해보자.
error와 관련된 로그를 확인해보면 BindingResultFieldError가 담겨있고, 메시지 코드에 다음과 같은 값들이 담겨있다

📗 typeMismatch

codes[typeMismatch.item.price,typeMismatch.price,typeMismatch.java.lang.Integer,typeMismatch]
  • typeMismatch.item.price
  • typeMismatch.price
  • typeMismatch.java.lang.Integer
  • typeMismatch

즉, 스프링은 타입 오류 발생 시 typeMismatch라는 오류 코드를 사용한다. 이 오류 코드가 MessageCodesResolver를 통하며 4가지 메시지 코드가 생성된다.

따로 errors.properties typeMismatch 관련 오류 코드를 생성하지 않으면, 스프링이 생성한 기본 메시지가 출력된다.

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 분리1

목표: 복잡한 검증 로직을 별도로 분리하자!

컨트롤러에서 검증 로직이 너무 많은 부분을 차지하고 있다. 이런 경우, 별도의 클래스로 역할을 분리하는 것이 좋다. 유지 보수가 편리해지고, 가독성이 좋아지며, 재사용도 가능하다!

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);
}
  • supprts() {}: 해당 검증기를 지원하는 여부 확인
  • validate(Object target, Errors errors): 검증 대상 객체와 BindingResult
    • BindingResult가 Errors의 자식클래스다.
    • 어떤 클래스든 가능하도록 Object에 클래스를 넣고, 다운캐스팅 해서 사용한다.
      • Item item = (Item) target;

ItemValidator 직접 호출하기

ValidationItemController - addItemV5()

private final ItemValidator itemValidator;

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

}
  • @RequiredArgsConstructor를 통해 ItemValidator를 스프링 빈으로 주입 받아서 직접 호출했다.

클래스를 분리해서 기존의 길었던 검증 로직이 itemValidator.validate(item, bindingResult);이 한 줄로 줄었다!


Validator 분리2

스프링에서 제공하는 Validator 인터페이스는 검증 기능을 체계적으로 도입하도록 도와준다. 해당 인터페이스 없이도 검증 기능이 구현 가능하나, 사용 시 스프링이 추가적인 도움을 준다.

📗 WebDataBinder를 통해서 사용하기

WebDataBinder는 스프링의 파라미터 바인딩의 역할을 해주고 검증 기능도 내부에 포함한다.

깊이있게 알 필요는 없다. 스프링 mvc 내부에서 객체에 파라미터를 바인딩하고, 검증하는데 사용하는 기능이다. 이 기능을 밖으로 꺼내서 검증기를 넣어줘야 WebDataBindier가 이 검증기를 적용해준다. 자세히알필요없다.

ValidationItemControllerV2

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

validator를 직접 호출하는 부분이 사라지고, 대신 검증 대상 앞에 @Validated가 붙었다.

동작 방식

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

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

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

@SpringBootApplication
public class ItemServiceApplication implements WebMvcConfigurer {

	 public static void main(String[] args) {
 		SpringApplication.run(ItemServiceApplication.class, args);
 	}
    
 	@Override
 	public Validator getValidator() {
 		return new ItemValidator();
 	}
    
}

이렇게 글로벌 설정을 추가할 수 있다. (@InitBinder를 제거해도 검증기가 잘 작동한다!)

주의!

글로벌 설정 사용 시 BeanValidator가 자동 등록되지 않는다. 따라서 꼭 주석처리 해야한다. 글로벌 설정을 직접 사용하는 경우는 매우 드물다.

주의!

검증시 @Validated, @Valid둘 다 사용 가능하다.
@Valid: 자바 표준 검증 애노테이션.
@Validated: 스프링 전용 검증 애노테이션

  • javax.validation.@Valid를 사용하려면 의존관계 추가가 필요하다.
    • implementation 'org.springframework.boot:spring-boot-starter-validation

profile
Good Luck!

0개의 댓글