EP3. 검증1 - Validation
Web/MVC2

EP3. 검증1 - Validation

지금까지 만든 웹 어플리케이션은 사용자가 입력하는 모든 상황을 대비할 수 없습니다.

 

예를들어,

사용자가 가격을 입력하는 곳에 알파벳을 입력하거나 그냥 입력 창에 공백을 넣게되면 에러페이지로 연결이 되며 기존에 작성했던 데이터는 다 사라지게 됩니다.

 

만약 우리가 회원가입시 이런 상황을 겪게 된다면 당연히 이 사이트를 더이상 이용하고 싶지 않을 것입니다.

 

 

검증은 클라이언트에서도 가능하고 서버에서도 가능합니다.

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

 

 

 

상품 저장 성공 FLOW

 

사용자가 정상 데이터를 입력하면 상품을 저장하고, 상품 상세 화면으로 redirect합니다.

 

 

 

 

 

상품 저장 검증 실패 FLOW

 

상품 검증에 실패하면

  1. 고객에게 다시 상품 등록 폼을 보여주고
  2. 어떤 값을 잘못 입력했는지 알려주어야 합니다.

 

 

 

 

 

 

BindingResult

스프링이 제공하는 검증 오류를 보관하는 객체입니다.

 

 

BindingResult 사용

 

BindingResult의 위치

 

@ModelAttribute 바로 뒤에 와야 합니다.

 

 

fieldError 사용

 

bindingResult의 addError메소드에 FieldError 객체안에 3개의 string타입 매개변수를 담아 사용합니다.

  1. objectName : 오류가 발생한 객체 이름
  2. field : 오류 필드
  3. defaultMessage : 기본 오류 메시지
if (!StringUtils.hasText(item.getItemName())) {
    bindingResult.addError(new FieldError("item", "itemName", "상품 이름은 필수 입니다."));
}

 

그리고 bindingResult는 model에 저절로 담기기 때문에 따로 담아주지 않아도 됩니다.

// 검증에 실패하면 다시 입력 폼으로
if (bindingResult.hasErrors()) {
    log.info("bindingResult = {}", bindingResult);
    // model.addAttribute("errors", bindingResult); -> 생략. 저절로 담김
    return "validation/v2/addForm";
}

 

 

 

 

타임리프에서 사용법

 

컨트롤러에서 위와같이 작성했으면, 타임리프에서 에러를 어떻게 받는지 알아보겠습니다.

 

 

Global Error

 

타임리프에서 제공하는 #fields 를 이용해 hasGlobalErrors()를 사용할 수 있습니다.

 

보통 global오류는 여러개인 상황이 많기 때문에 타임리프의 each를 이용해 여러개를 반복하여 출력할 수 있습니다.

이때, #fields의 globalErrors()를 이용하여 error메세지를 하나씩 받아올 수 있습니다.

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

 

 

Field Error

 

th:errorclass

th:errorclass 를 이용해 해당 field에서 오류가 있을 경우 'field-error' class를 추가해 줄 수 있습니다.

 

th:errors

th:errors는th:field="*{itemName}"을 이용해 th:errors:"*{itemName}"으로 오류가 있을 경우 bindingResult에 담긴 해당 필드의 오류메세지를 text로 보여줄 수 있습니다.

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

 

 

 

 

 

@ModelAttribute에 '바인딩 시' 타입 오류가 발생하는 경우

  • BindingResult가 없으면 : 400 오류가 발생 (컨트롤러 호출 x)
  • BindingResult가 있으면 : 오류 정보('FieldError')를 'BindingResult'에 담아서 컨트롤러를 정상 호출

 

 

오류발생시 고객이 입력한 정보가 사라짐

fieldError의 다른 생성자를 이용하여 rejectedValue를 설정할 수 있습니다.

 

기존 생성자 매개변수

  1. objectName : 오류가 발생한 객체 이름
  2. field : 오류 필드
  3. defaultMessage : 기본 오류 메시지

다른 생성자 매개변수

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

 

rejectedValue에 사용자가 입력한 값을 넣어주면 bindingResult에 저장해놨다가 타임리프에서 꺼내쓸 수 있습니다.

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

 

타임리프에서 사용

그대로 사용하면 됩니다.

타임리프의 field에러가 똑똑하게 동작합니다.

  • 정상 상황 : 모델 객체의 값 사용
  • 오류 상황 : FieldError에서 보관한 값 사용

 

 

스프링의 바인딩 오류 처리를 이용하여 타임오류시에도 사용자가 입력한 값도 그대로 출력할 수 있습니다.

또한, 오류코드도 체계적으로 처리하면 타임오류시에도 다음과 같은 불친절한 오류도 처리할 수 있습니다.

 

 

 

errors.properties

message 파트에서 여러곳에서 사용되는 문구들은 messages.properties에서 모아놓고 불러와 사용했습니다.

 

error도 마찬가지로 errors.properties에서 모아놓고 불러올 수 있습니다.

 

application.properties

기존에 messages만 있던 basename에서 errors를 추가해주면 message를 두 properties에서 다 불러 사용할 수 있습니다.

spring.messages.basename=messages,errors

 

errors.properties

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

 

사용

bindingResult는 @Modelattribute 바로 옆에 있기 때문에 사실 target객체가 어느것인지 미리 알 수 있습니다.

 

그래서 아래와 같이 FieldError에 target의 정보를 같이 넣어주어 사용하는 것보다

bindingResult.addError(new FieldError("item", "itemName", item.getItemName(), false, new String[]{"required.item.itemName"}, null, null));

 

아래와 같이 간단하게 사용할 수 있습니다.

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

 

어떻게 "required" 만으로 "required.item.itemName"를 가져올 수 있을까요?

 

bindingResult.rejectValue("itemName", "required"); 로 작성을 하게되면 스프링은

new String[]{"required.item.itemName", "required"} 이런식으로 String 객체를 만들어 errorCode에 넣어줍니다.

 

만약  "required.item.itemName"가 존재하면 이 메세지를 가져오고 "required.item.itemName"가 없이 "required"만 있으면 "required"를 가져옵니다.

 

 

더 자세히 알아보겠습니다.

 

 

MessageCodesResolver

스프링에서 내부적으로 MessageCodesResolver를 통해 String배열을 생성합니다.

 

MessageCodesResolver에 errorCode는 "required", objectName은 "item"을 주게되면

아래와 같이 String[]{"required.item", "required"} 인 String배열을 반환합니다.

public class MessageCodesResolverTest {

    MessageCodesResolver codesResolver = new DefaultMessageCodesResolver();

    @Test
    public void messagesCodesResolverObject() throws Exception {
        String[] messageCodes = codesResolver.resolveMessageCodes("required", "item");
        assertThat(messageCodes).containsExactly("required.item", "required");
    }
}

 

더 많은 파라미터를 넣어주게 되면 더 자세한 String을 생성하여, 배열에 담긴 순서대로 errors.properties에서 하나라도 있으면 그 error message를 선택하게 됩니다.

    @Test
    public void messageCodesResolverField() {
        String[] messageCodes = codesResolver.resolveMessageCodes("required", "item", "itemName", String.class);
        assertThat(messageCodes).containsExactly(
                "required.item.itemName",
                "required.itemName",
                "required.java.lang.String",
                "required"
        );
    }

 

 

아래와 같이 레벨별로 얼마나 자세하게 오류메세지를 표시할지 적어 놓으면 스프링이 만드는 String 배열에 따라 각 레벨의 메세지가 자동으로 들어갑니다.

 

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

 

 

 

 

 

이제 진짜로 type이 다를때 발생하던 긴 오류를 내가 원하는 메세지로 바꿔봅시다.

 

 

사실 스프링에서는 typeMismatch에 대한 에러도 String 배열을 생성하고 있었습니다.

 

하지만 errors.properties에 해당 String을 발견하지 못했기 때문에 defaultMessage를 내보내고 있던 것입니다.

 

이를 해결해주는 방법은 스프링이 만든 String에 맞는 메세지를 errors.properties에 정의해주면 됩니다.

 

errors.properties

...생략

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

 

 

이제는 가격에 다른 타입을 입력시 우리가 정의해주었던 메세지가 출력됩니다.

 

 

하지만 여기서도 한가지 걸리는 점은 타입에러로 이미 걸러졌지만 다른 에러를 또 출력해 에러 메세지가 2개가 보입니다.

그래서 보통 컨트롤러의 맨 앞에 hasError로 바로 끝내버리는 코드를 넣어줍니다.

public String addItemV4(@ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model) {

    log.info("objectName={}", bindingResult.getObjectName());
    log.info("target={}", bindingResult.getTarget());

    // 타입검증을 위해 앞에서 한번 거름 (안거르면 아래에서 발견한 에러가 하나 더 담김)
    if (bindingResult.hasErrors()) {
        return "validation/v2/addForm";
    }

    //검증 로직
    if (!StringUtils.hasText(item.getItemName())) {
        bindingResult.rejectValue("itemName", "required");
    }
    //        ValidationUtils.rejectIfEmptyOrWhitespace(bindingResult, "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() >= 9999) {
        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("bindingResult = {}", bindingResult);
        // model.addAttribute("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

지금까지 작성한 컨트롤러는 컨트롤러가 너무 많은 일을 합니다.

그래서 검증하는 로직은 Validator 클래스로 분류 한 후 빈으로 등록해 사용하는 방법으로 바꿔보겠습니다.

 

스프링에서 제공하는 Validator라는 인터페이스를 구현했습니다.

 

ItemValidator

package hello.itemservice.web.validation;

import hello.itemservice.domain.item.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<?> clazz) {
        return Item.class.isAssignableFrom(clazz);
        // Item == clazz
        // Item == subItem
    }

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

        //검증 로직
        if (!StringUtils.hasText(item.getItemName())) {
            errors.rejectValue("itemName", "required");
        }
        //        ValidationUtils.rejectIfEmptyOrWhitespace(bindingResult, "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() >= 9999) {
            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 에서는 isAssignableFrom() 를 사용했는데 isAssignableFrom()는 해당객체와 그 자식 객체까지 타입이 같은지 검사할 수 있습니다.

 

validate에서

  • Object target (객체를 받아 원하는 타입의 객체로 캐스팅 하여 사용)
  • Errors errors (bindingResult의 부모객체로 rejectValue가 구현되어있으므로 그대로 받아 사용)

를 매개변수로 받습니다.

 

그리고 컨트롤러에서 사용했던 검증로직을 그대로 사용합니다.

 

 

 

컨트롤러에서 이 Validator를 어떻게 부를까요?

 

  1. ItemValidator를 @Component 를 주어 컴포넌트스캔의 대상으로 빈으로 관리하게 합니다.
  2. 컨트롤러에서 ItemValidator를 주입받아 사용합니다.

 

 

 

 

addItemV5에서 itemValidator의 validate를 부르는 코드도 없앨 수 있습니다.

사실 ItemValidator에서 Validator 인터페이스를 구현하는 이유도 아래와 같이 사용하기 위함입니다.

 

@InitBinder를 이용하여 컨트롤러가 시작되면 항상 binding검사를 하도록 init메서드를 작성합니다.

public class ValidationItemControllerV2 {

    private final ItemRepository itemRepository;
    private final ItemValidator itemValidator;

    @InitBinder
    public void init(WebDataBinder dataBinder) {
        dataBinder.addValidators(itemValidator);
    }
    
    ..생략

 

사용할때에는 사용할 target 객체의 앞에 @Validated 어노테이션을 하나 더 붙여줍니다.

그러면 이 메서드가 불릴때 자동으로 target 객체의 validate검사를 자동으로 실행합니다.

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

        // 검증에 실패하면 다시 입력 폼으로
        if (bindingResult.hasErrors()) {
            return "validation/v2/addForm";
        }

        //성공 로직
        Item savedItem = itemRepository.save(item);
        redirectAttributes.addAttribute("itemId", savedItem.getId());
        redirectAttributes.addAttribute("status", true);
        return "redirect:/validation/v2/items/{itemId}";
    }

 

 

Q. 그런데 이 target이 자신의 validator (ItemValidator)를 어떻게 찾을 수 있을까요?

A. ItemValidator에서 작성한 supports가 해당 객체가 맞는지 판단하여 validate를 실행합니다.

@Component
public class ItemValidator implements Validator {

    @Override
    public boolean supports(Class<?> clazz) {
        return Item.class.isAssignableFrom(clazz);
        // Item == clazz
        // Item == subItem
    }
    ...생략

 

 

'Web > MVC2' 카테고리의 다른 글

EP4. 검증2 - Bean Validation  (0) 2021.10.14
EP2. 메세지와 국제화  (0) 2021.08.20
EP1. 타임리프 Thymeleaf  (0) 2021.07.26