본문 바로가기

SPRING

김영한 - 스프링 MVC 2편 - 백엔드 웹 개발 핵심 기술 4일차

BindingResult

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

    //검증 로직
    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() >= 9999) {
        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 다음에 와야한다.

 

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

 

<!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}" th:errorclass="field-error" class="form-control">
            <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>

        <hr class="my-4">

        <div class="row">
            <div class="col">
                <button class="w-100 btn btn-primary btn-lg" type="submit" th:text="#{button.save}">상품 등록</button>
            </div>
            <div class="col">
                <button class="w-100 btn btn-secondary btn-lg"
                        onclick="location.href='items.html'"
                        th:onclick="|location.href='@{/validation/v2/items}'|"
                        type="button" th:text="#{button.cancel}">취소</button>
            </div>
        </div>

    </form>

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

 

 

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

 

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

#fields : #fields로 BindingResult가 제공하는 검증 오류에 접근할 수 있다.

th:errors : 해당 필드에 오류가 있는 경우에 태그를 출력한다. th:if의 편의 버전이다.

th:errorclass : th:field에서 지정한 필드에 오류가 있으면 class 정보를 추가한다.

 

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

 

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

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

 

BindingResult는 Model에 자동으로 포함 된다.

 

FieldError , ObjectError

 

현재 오류가 발생하는 경우 입력했던 내용이 전부 사라지는 문제가 있었다 이를 해결해보았다.

 

//검증 로직
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() >= 9999) {
    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));
    }
}

 

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: 타입 오류 같은 바인딩 실패인지, 검증 실패인지 구분 값 => 바인딩 실패시 true

codes : 메시지 코드

arguments : 메시지에서 사용하는 인자

defaultMessage : 오류 메시지

 

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

 

th:field="*{price}"

타임리프의 th:field는 정상 상황에서는 모델 객체의 값을 사용하지만, 오류가 발생하면 FieldError 에서 보관한 값을 사용해서 값을 출력한다.

 

오류 코드와 메시지 처리

FieldError , ObjectError 의 생성자는 errorCode , arguments를 제공한다.

 

erros 메시지 파일 생성

 

messages.properties 를 사용해도 되지만, 오류 메시지를 구분하기 쉽게 errors.properties라는 별도의 파일로 관리한다.

 

먼저 스프링부트가 해당 메시지 파일을 인식할 수 있게 설정을 추가한다.

 

application.properties

spring.messages.basename=messages,errors

 

errors.properties

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

 

errors에 등록한 메시지를 사용하도록 코드를 변경하였다.

 

//검증 로직
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() >= 9999) {
    bindingResult.addError(new FieldError("", "quantity",item.getQuantity() , false , new String[]{"max.item.quantity"}, new Object[]{9999}, "수량은 최대 9,999까지 허용합니다."));
}

//특정 필드가 아닌 복합 룰 검증
if(item.getPrice() != null && item.getQuantity() != null) {
    int resultPrice = item.getPrice() * item.getQuantity();
    if(resultPrice < 10000) {
        bindingResult.addError(new ObjectError("item",new String[]{"가격 * 수량의 합은 {0}원 이상이어야 합니다. 현재 값 = {1}"},new Object[]{10000,resultPrice} ,null));
    }
}

 

codes : requried.item.itemName 를 사용해서 메시지 코드를 지정한다. 코드는 하나가 아니라 배열로 여러 값을 전달 할 수 있는데 순서대로 매칭해서 처음 매칭되는 메시지가 사용된다.

 

arguments : Object[]{1000, 1000000}를 사용해서 코드의 {0} , {1}로 치환할 값을 전달한다.

 

오류 코드와 메시지 처리2

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

컨트롤러에서 로그를 찍어보면 BindingResult는 이미 본인이 검증해야 할 객체인 target을 알고 있다.

 

BindingResult가 제공하는 rejectValue() , reject() 를 사용하면, FieldError , ObjectError 를 직접 생성하지 않고, 깔금하게 검증 오류를 다룰 수 있다.

 

//검증 로직
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() >= 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);
    }
}

 

errors.properties에 있는 코드를 직접 입력하지 않았는데 오류메시지가 정상 출력한다.

 

void rejectValue(@Nullable String field, String errorCode,
@Nullable Object[] errorArgs, @Nullable String defaultMessage);

field : 오류 필드명

errorCode: 오류 코드

errorArgs : 오류 메시지에서 {0}을 치환하기 위한 값

defaultMessage: 오류 메시지를 찾을 수 없을 때 사용하는 기본 메시지

 

축약된 오류 코드

 

FieldError()를 직접 다룰 때는 오류 코드를 range.item.price와 같이 모두 입력했다.

rejectValue()를 사용하고 부터는 오류 코드를 range로 간단하게 입력했다.

이 부분을 이해하기 위해서는 MessageCodesResolver를 이해해야 한다.

 

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

 

오류 메시지에 객체명과 필드명을 조합한 세밀한 메시지코드가 있으면 더 높은 우선순위로 사용한다.

 

MessageCodesResolver

 

package hello.itemservice.validation;

import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.Test;
import org.springframework.validation.DefaultMessageCodesResolver;
import org.springframework.validation.MessageCodesResolver;
import org.springframework.validation.ObjectError;

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

public class MessageCodesResolverTest {

    MessageCodesResolver codesResolver = new DefaultMessageCodesResolver();

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

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

        assertThat(messageCodes).containsExactly(
              "required.item.itemName",
              "required.itemName",
              "required.java.lang.String",
              "required"
        );
    }


}

 

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

 

동작방식

 

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

FieldError , ObjectError의 생성자를 보면, 오류 코드를 하나가 아니라 여러 오류 코드를 가질 수 있다.

MessageCodesResolver를 통해서 생성된 순서대로 오류 코드를 보관한다.

 

로그를 찍으면 다음과 같이 출력 된다.

 

오류 코드와 메시지 처리3

 

MessageCodesResolver는 required.item.itemName처럼 구체적인 것을 먼저 만들어주고, 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} 까지 허용합니다.

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

 

MessageSource는 구체적인 것에서 덜 구체적인 순서대로 찾는다.

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

 

ValidationUtils

 

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

ValidationUtils 를 사용하면 위와 같은 로직을 한줄로 정리 할 수있다.

하지만 ValidationUtils는 Empty , 공백같은 단순한 기능만 제공한다.

 

오류 코드와 메시지 처리4

 

현재 가격 필드에 숫자가아닌 문자를 입력하면 스프링이 생성한 기본 메시지가 출력된다.

 

로그를 확인해보면 4가지 메시지 코드가 입력되어있다.

 

typeMismatch.item.price

typeMismatch.price

typeMismatch.java.lang.Integer

typeMismatch

 

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

아직 errors.properties에 내용을 추가한다.

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

출력 결과 에러 메시지가 정상적으로 출력되는 것을 볼 수 있다.

 

 

Valiadator 분리1

현재 컨트롤러에 검증 로직이 차지하는 부분이 매우 크기 때문에 별도의 클래스로 역할을 분리하는것이 좋다.

 

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

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

 

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

 

supports() {} : 해당 검증기를 지원하는 여부 확인

validate(Object target, Errors errors): 검증 대상 객체와 BindingResult

 

private final ItemValidator itemValidator;
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}";
}

 

ItemValidator를 스프링 빈으로 주입 받아서 직접 호출 했다.

검증과 관련된 부분이 깔끔하게 분리 되었다.

 

Valiadator 분리2

Validator 인터페이스를 사용해서 검증기를 만들면 스프링의 추가적인 도움을 받을 수 있다.

 

WebDataBinder를 통해서 사용하기

 

@InitBinder
public void init(WebDataBinder 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에 등록한 검증기를 찾아서 실행한다. 

여러 검증기를 등록한다면 그 중에 어떤 검증기가 실행되어야 할지 구분이 필요한데 그때 support()가 사용된다.

여기서는 supports(Item.class) 가 호출되고 , 결과가 true 이므로 ItemValidator의 validate()가 호출된다.

 

※검증시 @Validated , @Valid 둘다 사용 가능하다.

@Valid를 사용하려면 javax.validation.@Valid를 사용하려면 build.gradle 의존관계 추가가 필요하다.

@Validated는 스프링 전용 검증 애노테이션이고, @Valid는 자바 표준 검증 애노테이션이다.

 

출처

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