개발/Spring MVC

Spring MVC 2편 - Validation

Debin 2022. 1. 22.
반응형
본 게시글은 인프런 김영한 선생님 강의 스프링 MVC 2편을 완강하고 배운 것을 남기고자 적은 포스팅입니다.

 

강의 링크는 아래와 같습니다.

 

https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81-mvc-2/dashboard

 

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

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

www.inflearn.com

 

이제 기초 Validation, 즉 검증에 대해 알아보자.

우선 우리는 지난 시간부터 계속 Item이라는 클래스를 가지고 있었다. 아래와 같다. 

 

public class Item{
private String itemName; //이름
    private Integer price; //가격
    private Integer quantity; //수량 
}

 

이제 HTML 폼을 통해 값이 우리가 원하는 타입으로 들어오는지 검증해보겠다.

맨 처음 검증 코드는 순수 자바 코드에 의한 검증 코드다.

 

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

 

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

 

전체 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 가 없다. 따라서 errors.containsKey()를 호출하는 순간 NullPointerException 이 발생한다. errors?. 은 errors 가 null 일 때 NullPointerException 이 발생하는 대신, null을 반환하는 문법이다. th:if에서 null 은 실패로 처리되므로 오류 메시지가 출력되지 않는다.

 

 

필드 오류 처리는 itemName의 경우를 통해 알아보자.

 

<input type="text" th:classappend="${errors?.containsKey('itemName')} ? 'fielderror' : _"
 class="form-control">

 

classappend를 사용해서 해당 필드에 오류가 있으면 field-error라는 클래스 정보를 더해서 폼의 색깔을 빨간색으로 강조한다. (폼의 색깔은 즉 CSS를 사용해 바꾸는 것이다)

만약 값이 없으면 _ (No-Operation)을 사용해서 아무것도 하지 않는다.

 

이렇게 순수한 자바 코드에 의한 오류 처리를 마쳤다. 몇 가지 문제점이 보인다.

 

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

 

이제 이런 문제를 해결하면서 스프링이 제공하는 검증 방법을 알아보겠다.

 

BindingResult

지금부터 스프링이 제공하는 검증 오류 처리 방법을 알아보자. 핵심은 BindingResult이다. 우선 코드로 확인해보자.

 

@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 다음에 와야 한다. 즉 검증할 대상 뒤에 바로 자리를 잡아야 한다.

 

먼저 필드 오류 코드를 바로 살펴보자.

 

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

 

필드에 오류가 생기면 FieldError 객체를 생성해서 bindingResult에 담는다. 각 파라미터는 첫 번째 자리부터

 

  •  objectName : @ModelAttribute 이름
  • field : 오류가 발생한 필드의 이름
  • 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));
     }
 }

 

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

 

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

 

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>
    
 </form>

 

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

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

 

즉 BindingResult는 스프링이 제공하는 검증 오류를 보관하는 객체다. 검증 오류가 발생하면 여기에 보관하면 된다.

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

반대로 BindingResult가 없으면 400 오류가 발생하면서 컨트롤러가 호출되지 않고, 오류 페이지로 이동한다.

특별하게 BindingResult는 자동으로 Model에 포함된다!!!

 

BindingResult에 검증 오류를 적용하는 3가지 방법이 있다.

 

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

 

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

 

이제 한 가지 문제를 해결해볼 것인데 그것은 오류가 발생하는 경우 고객의 입력이 모두 없어지는 것이다. 이를 고쳐보자.

 

위 컨트롤러의 코드를 아래와 같이 수정했다.

 

@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의 생성자를 확인할 수 있다. 이전에는 파라미터가 objectName, field, defaultMesage 총 3개였다.

이번 코드에는 파라미터가 7개인 생성자를 확인할 수 있다.

 

  • objectName : 오류가 발생한 객체 이름
  • field : 오류 필드
  • rejectedValue : 사용자가 입력한 값(거절된 값)
  • bindingFailure : 타입 오류 같은 바인딩 실패인지, 검증 실패인지 구분 값
  • codes : 메시지 코드
  • arguments : 메시지에서 사용하는 인자
  • defaultMessage : 기본 오류 메시지

 

ObjectError도 유사하게 두 가지 생성자를 제공한다.

 

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

예를 들어서 가격에 숫자가 아닌 문자가 입력된다면 가격은 Integer 타입이므로 문자를 보관할 수 있는 방법이 없다. 그래서 오류가 발생한 경우 사용자 입력 값을 보관하는 별도의 방법이 필요하다. 그리고 이렇게 보관한 사용자 입력 값을 검증 오류 발생 시 화면에 다시 출력하면 된다. FieldError는 오류 발생 시 사용자 입력 값을 저장하는 기능을 제공한다.

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

 

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

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

 

스프링의 바인딩 오류 처리

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

 

 

오류 코드와 메시지 처리

이제는 오류 메시지를 체계적으로 다루어보자.

이전에 FieldError, ObjectError의 생성자가 errorCode, arguments를 제공하는 것을 기억할 것이다. 이것은 오류 발생 시 오류 코드로 메시지를 찾기 위해 사용된다.

 

우선 errors.properties라는 별도의 파일을 만들자.

(그전에 먼저 스프링 부트가 해당 메시지 파일을 이해할 수 있게 설정을 추가해야 한다. application.properties에

spring.messages.basename=messages, errors라고 작성하면 된다.)

 

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

#src/main/resources/errors.properties

 

이제 errors에 등록한 메시지를 사용하게 위 메서드의 코드를 변경해보자.

 

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

 

  • codes : required.item.itemName 를 사용해서 메시지 코드를 지정한다. 메시지 코드는 하나가 아니라 배열로 여러 값을 전달할 수 있는데, 순서대로 매칭 해서 처음 매칭 되는 메시지가 사용된다.
  • arguments : Object[]{1000, 1000000}를 사용해서 코드의 {0} , {1}로 치환할 값을 전달한다. 

 

위와 같이 메시지를 적용할 수 있지만 이는 너무 다루기 번거롭다.

다른 방법을 모색해보자. 

컨트롤러에서 BindingResult는 검증해야할 객체 바로 다음에 온다. 따라서 BindingResult는 이미 본인이 검증해야 할 객체에 대해 알고 있다.

 

BindingResult가 제공하는 rejectValue, reject 메서드를 사용하면 FieldError, ObjectError를 직접 생성하지 않고, 깔끔하게 검증 오류를 다룰 수 있다. 위 V3 메서드 코드를 아래와 같이 수정하겠다.

 

@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 메소드의 파라미터를 확인해보자. reject는 field만 빼면 된다.

 

  • field : 오류 필드명
  • errorCode : 오류 코드(이 오류 코드는 메시지에 등록된 코드가 아니다. 뒤에서 설명할 messageResolver를 위한 오류 코드이다.)
  • errorArgs : 오류 메시지에서 {0}을 치환하기 위한 값
  • defaultMessage : 오류 메시지를 찾을 수 없을 때 사용하는 기본 메시지

 

 

우리의 오류 코드가 축약 된 것을 확인할 수 있다.

FieldError()를 직접 다룰 때는 오류 코드를 range.item.price 와 같이 모두 입력했다. 그런데 rejectValue()를 사용하고부터는 오류 코드를 range로 간단하게 입력했다. 그래도 오류 메시지를 잘 찾아서 출력한다. 무언가 규칙이 있는 것처럼 보인다. 이 부분을 이해하려면 MessageCodesResolver를 이해해야 한다. 왜 이런 식으로 오류 코드를 구성하는지 바로 다음에 자세히 알아보자.

 

오류 코드를 만들 때 다음과 같이 자세히 만들 수도 있고,

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

range.item.price : 상품의 가격 범위 오류입니다.

 

또는 다음과 같이 단순하게 만들 수도 있다.

required : 필수 값 입니다.

range : 범위 오류 입니다.

 

단순하게 만들면 범용성이 좋아서 여러 곳에서 사용할 수 있지만, 메시지를 세밀하게 작성하기 어렵다. 반대로 너무 자세하게 만들면 범용성이 떨어진다. 가장 좋은 방법은 범용성으로 사용하다가, 세밀하게 작성해야 하는 경우에는 세밀한 내용이 적용되도록 메시지에 단계를 두는 방법이다. 예를 들어서 required라고 오류 코드를 사용한다고 가정해보자. 다음과 같이 required라는 메시지만 있으면 이 메시지를 선택해서 사용하는 것이다

 

required: 필수 값 입니다.

 

 

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

 

#Level1
required.item.itemName: 상품 이름은 필수 입니다.

#Level2
required: 필수 값 입니다

 

물론 이렇게 객체명과 필드명을 조합한 메시지가 있는지 우선 확인하고, 없으면 좀 더 범용적인 메시지를 선택하도록 추가 개발을 해야겠지만, 범용성 있게 잘 개발해두면, 메시지의 추가 만으로 매우 편리하게 오류 메시지를 관리할 수 있을 것이다.

 

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

 

MessagesCodesResolver는 검증 오류 코드로 메시지 코드들을 생성한다.

MessageCodesResolver 인터페이스이고 DefaultMessageCodesResolver는 기본 구현체이다.

주로 ObjectError , FieldError과 함께 사용한다.

 

아래와 같은 테스트 코드를 한 번 돌려보면 모든 테스트가 통과할 것이다. 그러면 DefaultMessageCodesResolver의 기본 메시지 생성 규칙에 대해 알 수 있다.

 

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

 

객체 오류의 경우 다음의 순서로 2가지가 생성된다.

  1. code + "." + object name
  2. code

예를 들면 오류 코드가 required, object name : item이라면 

  1. required.item
  2. required

가 생성된다.

 

 

필드 오류는 다음 순서로 4가지 메시지 코드가 생성된다.

  1. code + "." + object name + "." + field
  2. code + "." + field
  3. code + "." + field type
  4. code

예를 들면 오류 코드: typeMismatch, object name "user", field "age", field type: int

  1. typeMismatch.user.age
  2. typeMismatch.age
  3. typeMismatch.int
  4. typeMismatch

이렇게 생성된다.

 

rejectValue, reject는 내부에서 MessageCodesResolver를 사용한다. 여기에서 메시지 코드들을 생성한다.

FieldError , ObjectError 의 생성자를 보면, 오류 코드를 하나가 아니라 여러 오류 코드를 가질 수 있다. MessageCodesResolver를 통해서 생성된 순서대로 오류 코드를 보관한다

 

FieldError rejectValue("itemName", "required")

다음 4가지 오류 코드를 자동으로 생성

 

  • required.item.itemName
  • required.itemName
  • required.java.lang.String
  • required
  •  

ObjectError reject("totalPriceMin")

다음 2가지 오류 코드를 자동으로 생성

 

  • totalPriceMin.item
  • totalPriceMin

 

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

 

 

MessageCodesResolver는 required.item.itemName처럼 구체적인 것을 먼저 만들어주고, required처럼 덜 구체적인 것을 가장 나중에 만든다. 이렇게 하면 앞서 말한 것처럼 메시지와 관련된 공통 전략을 편리하게 도입할 수 있다.

 

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

모든 오류 코드에 대해서 메시지를 각각 다 정의하면 개발자 입장에서 관리하기 너무 힘들다. 크게 중요하지 않은 메시지는 범용성 있는 requried 같은 메시지로 끝내고, 정말 중요한 메시지는 꼭 필요할 때 구체적으로 적어서 사용하는 방식이 더 효과적이다.

 

이제 예시를 살펴보자. errors.properties에 단계 별로 메시지들을 작성했다.

 

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

구체적인 것에서 덜 구체적인 순서대로 찾는다. 메시지에 1번이 없으면 2번을 찾고, 2번이 없으면 3번을 찾는다.

이렇게 되면 만약에 크게 중요하지 않은 오류 메시지는 기존에 정의된 것을 그냥 재활용하면 된다.

 

ValidationUtils를 이용하면 코드를 더 간편하게 만들 수도 있다. 이건 차후에 기회가 되면 다뤄보겠다.

 

 

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

 

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

price 필드에 문자 "A"를 입력해보자. 로그를 확인해보면 BindingResult에 FieldError 가 담겨있고, 다음과 같은 메시지 코드들이 생성된 것을 확인할 수 있다. codes [typeMismatch.item.price, typeMismatch.price, typeMismatch.java.lang.Integer, typ eMismatch]

 

다음과 같이 4가지 메시지 코드가 입력되어 있다.

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

 

스프링은 타입 오류가 발생하면 typeMismatch 라는 오류 코드를 사용한다.

이 오류 코드가 MessageCodesResolver 를 통하면서 4가지 메시지 코드가 생성된 것이다.

 

아직은 errors.properties에 메시지 코드가 없기 때문에 스프링이 생성한 기본 메시지가 출력된다.

그러나 errors.properties에 다음 내용을 추가하자.

 

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

 

결과적으로 소스코드를 하나도 건들지 않고, 원하는 메시지를 단계별로 설정할 수 있다.

 

이제 우리의 코드를 더욱 이쁘게 만들기 위해 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);
             }
         }
     }
}

 

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

 

  • supports : 해당 검증기를 지원하는 여부 확인
  • validate(Object target, Errors errors) 검증 대상 객체와 BindingResult

 

포스트 매핑됐던 메서드는 아래와 같이 깔끔하게 바뀐다.

 

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

 

추가적인 내용을 더 살펴보자.

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

위에 컨트롤러에 아래 코드를 추가하자.

 

@InitBinder
public void init(WebDataBinder dataBinder) {
 log.info("init binder {}", dataBinder);
 dataBinder.addValidators(itemValidator);
}

 

이렇게 WebDataBinder 에 검증 기를 추가하면 해당 컨트롤러에서는 검증 기를 자동으로 적용할 수 있다.

@InitBinder 해당 컨트롤러에만 영향을 준다. 글로벌 설정은 별도로 해야 한다.

 

@Validated를 적용해본 메서드 코드다.

 

@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 이므로 ItemValidator의 validate()가 호출된다.

 

이상으로 포스팅을 마치겠습니다.

반응형

댓글