스프링 MVC-스프링 Validation

검증

강의에 따르면 실제 개발 환경에서 성공 로직을 작성하는 것은 30%, 나머지 70%는 실패에 대비하는 검증 및 테스트 코드를 작성하는데 소비된다고 한다.
HTTP 요청은 얼마든지 클라이언트에서 조작할 수 있으므로 검증 과정은 매우 중요하다. 가장 기본적인 검증 방식에서부터 스프링이 지원하는 편리한 검증 기능까지 하나씩 알아보도록 하자.

검증 로직 개발

검증 실패

만약 범위를 넘어서거나, 공백값이 들어오는 등의 이유로 검증이 실패했다면 검증 오류 결과를 model에 담아 form에 반환하여 어디가 잘못되었는지의 메세지와 함께 폼을 다시 보여줘야 한다.
먼저 컨트롤러에 해당 검증 로직을 추가하는 방식으로 검증을 진행해보자.

    @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()) {
            log.info("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}";
    }

검증 로직에 맞게 구현한 상품 등록 코드이다. 검증 조건에 걸리면 map에 에러를 담는 식으로 구현하였다.
필드에서 에러가 났다면 필드명을 key로 하여 map에 담았고, 필드가 아니라면 globalError라는 키로 담았다. 해당 데이터는 폼에서 클라이언트에 메세지를 띄우는데 사용할 수 있다.

해당 방식의 문제점은 뷰 템플릿에서 중복이 많고, 타입 오류가 나 바인딩 오류가 나면 컨트롤러에 진입하기도 전에 예외가 반환되므로 처리가 되지 않는다.
또한 고객이 입력한 문자가 오류가 발생한 후 사라지므로, 고객이 어떤 문자를 입력하여 오류가 났는지 판단하기 어렵다. 따라서 고객이 입력한 값도 어딘가에 담아 보관해야 한다.

BindingResult

스프링에서는 map 대신 오류들을 저장하면서, 고객이 입력한 필드의 값도 보관이 가능한 클래스인 BindingResult를 지원한다. 해당 클래스를 사용하여 코드를 리팩토링 해보자.

    @PostMapping("/add")
    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의 addError 메서드와 FieldError, ObjectError 등의 객체들을 사용하여 오류를 저장하는 것을 볼 수 있다.
참고로 BindingReslut의 매개변수 위치는 검증해야 하는 객체 뒤에 와야한다. 위의 코드에서는 @ModelAttribute가 붙은 Item 뒤에 온 것을 볼 수 있다.

FieldError의 생성자는 다음과 같다.

public FieldError(String objectName, String field, String defaultMessage) {}  
  • objectName : @ModelAttribute 이름
  • field : 오류가 발생한 필드 이름
  • defaultMessage : 오류 기본 메시지

보통 필드에 오류가 있을 경우 사용하게 된다.
필드가 아닌 글로벌 오류가 있을 경우에는 ObjectError를 사용하게 되는데, 생성자는 다음과 같다.

public ObjectError(String objectName, String defaultMessage) {}

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

이렇게 BindingResult에 넣은 오류들은 타임리프에서 편리하게 꺼내서 사용할 수 있다.

  • #fields : #fields로 BindingResult가 제공하는 검증 오류에 접근할 수 있다.
  • th:errors : 해당 필드에 오류가 있는 경우에 태그를 출력한다. th:if의 편의 버전이다.
  • th:errorclass : th:field에서 지정한 필드에 오류가 있으면 class 정보를 추가한다.

따라서 타임리프에서 다음과 같이 작성하여 오류 메세지를 출력할 수 있다.

  
//글로벌 오류 처리
 <div th:if="${#fields.hasGlobalErrors()}">
    <p class="field-error" th:each="err : ${#fields.globalErrors()}" th:text="${err}">전체 오류 메시지</p>
 </div>
  
//필드 오류 처리
  <input type="text" id="itemName" th:field="*{itemName}"
 th:errorclass="field-error" class="form-control" placeholder="이름을 입력하세요">
 <div class="field-error" th:errors="*{itemName}">
상품명 오류
</div>

BindingResult의 장점은 이 뿐만이 아니다. 기존에는 바인딩 오류가 나면 컨트롤러에 진입하기 전에 예외를 반환하여 제대로 된 예외처리가 불가능했지만, 이제 BindingResult를 사용하면 오류 정보를 BindingResult에 담아 컨트롤러를 정상 호출한다.
이제 숫자를 입력하는 칸에 문자를 담아 바인딩 오류를 낸 후 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”]’
라는 장문의 오류 메세지가 콘솔에 출력되는 것을 알 수 있다. 또한 웹 페이지도 기존처럼 예외 페이지로 바로 넘어가지 않고 정상 작동하였다.
이렇게 컨트롤러도 정상 작동하고, 필요한 오류 정보도 출력해주므로 우리는 바인딩 에러에도 적절한 예외처리가 가능하다. 오류 메세지를 보고 errors.properties에
typeMismatch.java.lang.Integer=숫자를 입력해주세요.
라고 추가하는 것으로 바인딩 오류 시의 고객에게의 메세지 출력이 가능하다.

그런데 아직 고객이 입력한 값이 사라지는 문제는 해결하지 못하였다. 이제 FieldError의 다른 생성자를 사용하여 문제를 해결해보자.

FieldError

FieldError의 생성자는 사실 위에서 본 것 외에도 한 가지가 더 있다.

public FieldError(String objectName, String field, @Nullable Object rejectedValue boolean bindingFailure, @Nullable String[] codes, @Nullable Object[] arguments, @Nullable String defaultMessage)

기존의 파라미터 외에도 rejectedValue 등 여러 파라미터가 추가된 것을 볼 수 있다. 각각의 파라미터의 역할은 다음과 같다.

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

숫자를 입력하는 칸에 문자가 입력된다면 Integer 타입이 아닌 다른 타입이 들어온 것이므로 값의 저장이 어려울 것이다.
따라서 고객이 잘못 입력한 값을 저장할 별도의 방법이 필요한데, FieldError가 그 역할을 해준다. 정확히는 FieldError의 rejectedValue가 잘못 입력한 값을 저장하는 필드이다. 따라서 값을 보존하는 오류를 생성하고 싶다면 아래와 같이 작성하면 된다.
new FieldError(“item”, “price”, item.getPrice(), false, null, null, “가격은 1,000 ~ 1,000,000 까지 허용합니다.”)
item의 값을 받아 잘못 입력한 값을 다시 필드에 넣어주고, 바인딩 에러가 아니므로 bindingFailure는 false로 하였다.
만약 타입 오류로 바인딩 에러가 났다면 스프링에서 FieldError를 생성하면서 bindingFailure는 true로, rejectedValue에도 적절한 값을 넣어서 오류를 생성해 줄 것이다.

오류 메세지 처리

오류 메세지는 기본적으로 같은 것이 여러 곳에서 쓰이는 경우가 많다. 우리는 이런 것을 편리하게 처리할 수 있는 스프링의 기능을 이미 배웠는데, 바로 메세지 기능이다.
메세지 기능을 통해 오류 메세지를 편리하게 처리하도록 리팩토링 해보자.
먼저 application.properties에서 basename을 추가해주는 과정이 필요하다. 메세지를 messages.properties에 추가해도 되지만 오류 메세지는 errors.properties에 따로 보관하는 것이 관리가 편하기 때문이다.
spring.messages.basename=messages,errors
라고 파일에 추가해주면 된다.
그리고 errors.properties 파일을 새로 만든 후

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

라고 입력해주었다. 이제 이 메세지들을 사용하도록 코드들을 변경하자. 방법은 간단하다. 위의 FieldError의 생성자 중 codesarguments를 활용하면 된다.

    @PostMapping("/add")
    public String addItemV3(@ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model) {
        log.info("objectName={}",bindingResult.getObjectName());
        log.info("target={}", bindingResult.getTarget());
        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("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()) {
            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, ObjectError를 생성하는 것은 번거롭다. BindingResult는 검증해야 하는 객체 뒤에 오므로 검증해야 하는 객체를 이미 알고 있다고 할 수 있다.
그렇다면 BindingResult에서 오류를 처리해줄 수 있지 않을까? 물론 가능하다. BindingResult의 reject(), rejectValue() 메서드를 사용하여 코드를 더 간결하게 해보자.

    @PostMapping("/add")
    public String addItemV4(@ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model) {
        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()>=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()) {
            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()를 사용하면 되는데 메서드의 기능은 다음과 같다.

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

reject()는 필드 파라미터가 없을 뿐 모두 동일하다. FieldError를 생성해주는 역할을 대신 해주는 메서드라고 할 수 있다. 그런데 FieldError를 사용할 땐 메세지 코드를 range.item.price와 같이 모두 입력하였지만 rejectValue()에서는 range로 간단하게 입력했음에도 같은 결과가 나왔다.
이를 이해하기 위해서는 먼저 스프링의 MessageCodesResolver를 이해해야 한다.

MessageCodesResolver

오류 메세지를 얼마나 세세하게 작성해야 하는지도 문제이다. 단순하게 ‘필수 값입니다’ 정도로 작성하면 어디에든 범용적으로 사용할 수 있지만 더 세밀한 정보를 전달하기엔 부족하다.
‘상품 이름은 필수 값입니다’라고 작성하면 추가 정보가 들어가지만 상품 이름 필드에밖에 사용할 수 없다.
이러한 문제를 해결해주는 것이 스프링의 MessageCodesResolver이다. 이 기능의 구동 방식은 간단하다. 더 세밀한 메세지가 더 우선순위가 높다. 예를들어 다음과 같이 메세지를 작성하였다.

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

오류 코드를 required로 입력하면 더 높은 우선순위인 level1의 메세지가 사용되는 것이다. 더 구체적인 작동방직은 아래의 테스트 코드를 확인해보자.

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

위에서 배웠던 reject(), rejectValue()는 내부적으로 FieldError, ObjectError를 만들고, FieldError, ObjectError의 생성자를 확인해보면 알 수 있지만 이 두 객체는 오류 코드를 한 가지가 아닌 여러 개를 가질 수 있다.
객체 생성 시에 MessageCodesResolver를 사용하여 오류 코드를 생성하는데 일정한 규칙에 따라 생성된다. 해당 규칙은 다음과 같다.

객체 오류
객체 오류의 경우 다음 순서로 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
  5. “typeMismatch.user.age”
  6. “typeMismatch.age”
  7. “typeMismatch.int”
  8. “typeMismatch”

따라서 위의 테스트코드는 {required.item.itemName, required.itemName, required.java.lang.String, required} 4가지의 오류 코드를 MessageCodesResolver가 자동으로 생성하여 객체에 넣어주고, 화면을 타임리프가 렌더링할 때 오류코드가 맞는게 있으면 출력, 아니라면 넘어가고 하나도 맞는게 없다면 기본 메세지를 출력하는 식으로 작동하게 된다.
위의 규칙을 인지하고 오류 코드를 정교하게 작성한다면 범용적인 부분은 범용적인 메세지가, 추가 정보가 필요한 부분은 세밀한 메세지가 나오도록 코드를 수정하지 않고도 구현할 수 있다.

검증 파트 분리

이제 검증이 완벽하게 가능해졌지만 컨트롤러에서 검증이 차지하는 파트가 너무 커졌다. 검증 파트의 스크립트를 따로 분리하여 관리가 쉽도록 리팩토링하자.
먼저 스프링은 검증을 체계적으로 관리하기 위해 다음과 같은 인터페이스를 제공한다.

public interface Validator {
    boolean supports(Class<?> clazz);
    void validate(Object target, Errors errors);
}

해당 인터페이스를 사용하여 ItemValidator 클래스를 새로 만들어보자.

package hello.itemservice.web.validation;

import hello.itemservice.domain.item.Item;
import jdk.jfr.Frequency;
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<?> Class) {
        return Item.class.isAssignableFrom(Class);
    }

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

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

기존 로직을 옮겨서 인터페이스가 제공하는 validate() 메서드에 담았고, supports() 메서드에 Item 클래스 정보를 담아 해당 객체를 검증할 수 있도록 구현한 코드이다.
이렇게 분리한 검증 파트는 두 가지 방법으로 사용이 가능하다.

검증 파트 직접 호출

    @PostMapping("/add")
    public String addItemV5(@ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model) {
        itemValidator.validate(item, bindingResult);

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

가장 쉬운 방법은 직접 호출하는 것이다. 같은 로직을 분리한 것 뿐이니 물론 기존과 같이 잘 작동한다. 하지만 스프링에서 제공하는 인터페이스를 활용했는데 기존과 똑같이 작동하는 것은 좀 부족하다.
검증에서도 스프링의 도움을 받을 수는 없을까?

WebDataBinder 활용

컨트롤러에 다음과 같은 코드를 추가하자.

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

WebDataBinder에 검증기를 추가하면 해당 컨트롤러에서 검증기를 자동으로 적용할 수 있다. @InitBinder는 해당 컨트롤러에서만 적용된다는 뜻으로 글로벌 설정은 별도로 해야 한다.

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

이제 validator를 직접 호출하지 않고 검증대상 앞에 @Validated 어노테이션을 붙이면 검증 로직이 그대로 적용된다.
해당 어노테이션이 붙으면 WebDataBinder에서 여러 검증기들 중 supports() 메서드를 통과하는 검증기를 찾아 검증 로직을 작동한다.

정리

검증이 매우 중요한 부분인 만큼 양도 방대했다. 하지만 문제는 이 검증 파트는 실제로는 거의 안쓰인다는 것이다. 어떤 식으로 작동하는지 알아보기 위한 파트이고 실제로 많이 쓰이는 것은 다음 포스팅에서 다룰 Bean Validation이다.
이만큼 분량이 또 있다니 정말 두려운 일이다.