[Spring] 검증(1) - BindingResult, MessageCodesResolver

imcool2551·2022년 3월 3일
0

Spring

목록 보기
10/15
post-thumbnail

본 글은 인프런 김영한님의 스프링 완전 정복 로드맵을 기반으로 정리했습니다.

0. 들어가며


웹 애플리케이션은 필수적으로 사용자가 입력한 값을 검증해야한다. 사용자가 숫자 타입을 문자 타입으로 입력하는 등 값이 올바르지 않은 경우 서버는 요청을 거절하고 입력한 데이터를 유지한 상태로 어떤 오류가 발생했는지 친절하게 알려주어야 한다. 이를 검증(Validation)이라고 한다. 서버 사이드 검증을 수행하지 않으면 데이터베이스에 엉뚱한 값이 넘어갈 수도 있고 애플리케이션에 의도하지 않은 값이 돌아다니다 장애가 생길수 있기 때문에 클라이언트 검증과 별도로 필수적으로 수행해야 한다. 검증은 보통 컨트롤러에서 실행한다.

예제를 통해 알아볼 것인데 검증할 클래스는 Item이다. 검증은 도메인 객체가 아닌 입력값 DTO를 통해 하는 것이 일반적이지만, 예재의 단순화를 위해 도메인 객체를 직접 검증했다. @Data 는 모든 필드의 세터를 만들어서 캡슐화가 깨질 수 있으므로 실제 애플리케이션에서는 DTO 정도에서만 사용하지만 마찬가지로 예제의 단순화를 위해 사용했다.

@Data
public class Item {

    private Long id;
    private String itemName;
    private Integer price;
    private Integer quantity;

    public Item() {
    }

    public Item(String itemName, Integer price, Integer quantity) {
        this.itemName = itemName;
        this.price = price;
        this.quantity = quantity;
    }
}
  • 상품 이름: 빈 문자열(공백 포함)이 아닌 문자열 itemName

  • 상품 가격: 1,000~1,000,000 까지 허용하는 정수타입 price

  • 상품 수량: 0~9999 까지 허용하는 정수타입 quantity

  • (가격 * 수량)의 값이 10,000원 이상이어야 함

1. BindingResult, FieldError, ObjectError


1a. BindingResult

BindingResult는 검증오류를 보관하는 객체다. BindingResult에 검증오류를 보관하는 방법은 세 가지가 있다.

  1. @ModelAttribute 객체에 타입 오류등으로 바인딩이 실패하는 경우

    사용자가 정수형 필드에 문자를 넣는 경우를 생각해보면 된다. 객체에 타입 오류 등으로 바인딩이 실패하면 스프링이 FieldError를 생성해서 BindingResult에 넣어준다. BindingResult가 있으면 쿼리 파라미터를 ModelAttribute 객체로 바인딩하는데에 실패해도 컨트롤러가 호출된다. BindingResultModel에 자동으로 포함되기 때문에 어떤 오류가 발생했는지 사용자에게 친절하게 알려줄 수 있다. BindingResult가 없으면 400 오류가 발생하면서 컨트롤러가 호출되지 않고 오류 페이지로 이동한다. 오류 페이지로 이동하면 사용자가 입력했던 값이 모두 날아간다. 이는 사용자 경험 측면에서 나쁘다.

  2. 개발자가 직접 넣어주는 경우

    개발자는 검증을 수행해서 BindingResult에 직접 오류 객체를 넣어줄 수 있다.

  3. Validator를 사용하는 경우

    검증 로직을 Validator로 분리하면 컨트롤러 코드를 간단하게 유지할 수 있는데, 이는 뒤에서 살펴보겠다.

BindingResult 는 핸들러 매개변수에서 자신이 검증할 객체 바로 다음에 위치시켜야한다.

1b. FieldError

BindingResult에 보관되는 오류 객체다. FieldError 는 이름 그대로, 필드에 오류가 있는 경우 발생하는 에러다. 필드의 타입이 맞지 않을 때 스프링이 생성할 수도 있고, 개발자가 검증을 수행해서 필드에 오류가 있다면 직접 생성해서 BindingResultaddError() 메서드를 통해 넣을수 있다. 생성자는 2개가 있다.

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: 기본 오류 메시지
@PostMapping("/items/add")
public String addItem(@ModelAttribute Item item,
                     BindingResult bindingResult,
                     RedirectAttributes redirectAttributes) {

	... 다른 필드들 검증

  if (item.getPrice() == null || item.getPrice() < 1000 || item.getPrice() > 1_000_000) {
      bindingResult.addError(new FieldError("item", "price", item.getPrice(), false,
          new String[] {"range.item.price"}, new Object[]{1000, 1_000_000}, 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}";

}

Item 도메인 객체를 직접 검증해서, 오류를 BindingResult 에 추가한 다음 View를 반환하고, 오류가 없다면 Repsitory를 통해 저장한 다음, Redirection 시켰다(PRG 패턴).

FieldError 를 생성한 뒤 BindingResult 에 추가하는 부분을 집중적으로 보면 된다. FieldError 생성자의 매개변수를 하나씩 살펴보자.

objectNamefield 를 통해 item객체의 price필드에 오류가 있음을 나타냈다. 사용자가 입력한 값을 보존하기 위해 rejectedValue에 값을 넣었다. 바인딩은 됐지만 논리적인 오류가 있는 경우이므로 bindingFailure는 false다.

codesarguments는 메시지 코드와 관련있다. 메시지 코드는 별도의 파일을 통해 애플리케이션에서 사용하는 메시지들을 관리하는 방법이다. 예제를 위해 resources 폴더에 error.properties 파일을 아래와 같이 작성했다. 참고로 스프링은 메시지 관리 기능을 MessageSource 를 통해 제공하기 때문에 구현체인 ResourceBundleMessageSource 를 빈으로 등록해야하지만, 스프링 부트는 자동으로 등록해준다. 그리고 application.properties 파일에 spring.messages.basename=messages,errors 처럼 메시지 소스를 여러개 설정하면 된다. 기본값은 spring.messages.basename=messages 이기 때문에 별도의 설정을 하지 않으면 errors.properties 파일은 인식되지 않는다

range.item.price=가격은 {0} ~ {1} 까지 허용합니다.

메시지 코드를 통해, 코드와 메시지를 분리해서 관리하면 메시지만 일괄적으로 관리할 수 있기 때문에 유지보수성이 좋아진다. 추가로, 코드를 다시 컴파일 하지 않고도 애플리케이션에서 사용하는 메시지를 바꿀 수 있게 된다.

메시지 코드를 작성했다면, codesarguments 매개변수를 통해 "가격은 1000 ~ 1000000 까지 허용합니다." 라는 오류 메시지를 만들어낼 수 있다. codes 매개변수는 배열이기 때문에 여러개의 메시지 코드를 받을 수 있다. 순서대로 매칭해서 처음 매칭되는 메시지가 사용된다. defaultMessage는 기본 오류 메시지로 아무런 메시지 코드를 찾지 못한경우에 사용할 오류 메시지를 넣어준다.

그러나 FieldError 를 직접 생성하는 것은 너무 불편하다. 생성자의 매개변수가 너무 많다. BindingResult 는 자신이 검증해야할 객체 바로 다음에 오기 때문에 자신이 검증할 객체에대해 이미 알고있다. 그러므로, BindingResultFieldError 를 직접 생성하지 않고 필드 오류를 처리할 수 있도록 단순화 해주는 rejectValue() 메서드를 제공한다.

void rejectValue(@Nullable String field, String errorCode, String defaultMessage);

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

매개 변수는 다음와 같다.

  • field: 오류 필드명
  • errorCode: 오류 코드 (메시지 코드가 아니다. 뒤에서 설명할 MessageResolver를 위한 오류 코드다)
  • errorArgs: 오류 메시지의 {0}을 치환하기 위한 값
  • defaultMessage: 오류 메시지를 찾을수 없을 때 사용하는 기본 메시지
@PostMapping("/items/add")
public String addItem(@ModelAttribute Item item,
                     BindingResult bindingResult,
                     RedirectAttributes redirectAttributes) {

	... 다른 필드들 검증

  if (item.getPrice() == null || item.getPrice() < 1000 || item.getPrice() > 1_000_000) {
    bindingResult.rejectValue("price", "range", new Object[]{1000, 1_000_000}, 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}";

}

errorCode 를 보면 range.item.price 처럼 모두 입력하지 않고 range 만 입력해줬음에도 range.item.price 에 설정한 오류 메시지가 출력된다. 이 부분을 이해하려면 MessagesCodesResolver 를 살펴봐야한다. 이는 아래에서 다루도록 하겠다.

rejectValue() 가 오류 객체를 직접 생성하는 것보다는 편리하지만 실제로 필드의 경우 애노테이션을 통해 검증하는 BeanValidation 방식이 주로 사용된다. 메시지 코드를 통해 오류 메시지를 관리하는 방법의 장점도 BeanValidation 을 통해 극대화 된다. BeanValidation 은 다음 글에서 다룬다.

1c. ObjectError

FieldError가 특정 필드에 오류가 있는 경우를 의미한다면, ObjectError는 이름 그대로 객체 자체의 오류가 있는 경우를 의미한다. 예재의 경우, (가격 * 수량) 의 값이 10,000원 이상이어야 하는 조건이 충족되지 않으면 ObjectError 를 통해 검증 실패를 알릴 수 있다. FieldError 와 마찬가지로 2개의 생성자를 가진다.

public ObjectError(String objectName, String defaultMessage)

public ObjectError(String objectName, @Nullable String[] codes, @Nullable Object[] arguments, @Nullable String defaultMessage)

오류 객체를 직접 생성하기 보다는 BindingResult 가 제공하는 reject() 메서드를 통해 처리하는 것이 편하다. 매개변수는 rejectValue() 와 유사하다.

void reject(String errorCode, String defaultMessage);

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

@PostMapping("/items/add")
public String addItem(@ModelAttribute Item item,
                     BindingResult bindingResult,
                     RedirectAttributes redirectAttributes) {

	... 필드 검증

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

}

error.properties 파일에 totalPriceMin 을 추가해주면 "전체 가격은 10000원 이상이어야 합니다. 현재 값 = 9000" 와 같은 오류 메시지를 만들 수 있다.

range.item.price=가격은 {0} ~ {1} 까지 허용합니다.
totalPriceMin=전체 가격은 {0}원 이상이어야 합니다. 현재 값 = {1}

필드 오류의 경우 애노테이션을 이용한 BeanValidaiton 을 사용하는 것이 편리하지만, 객체 자체 오류의 경우 애노테이션으로 검증하기가 쉽지 않다. 애노테이션이 제공되긴 하지만 객체의 검증은 복합적인 이유로 발생하며 조건도 까다로울 수 있기 때문에 이 예제처럼 코드를 통해 직접 처리하는 편이 낫다.

2. MessageCodesResolver


메시지 코드를 만들 때 required 처럼 단순하게 만들수도 있고 required.item.price 처럼 자세히 만들수도 있다. 단순하게 만들면 범용성이 좋아서 여러곳에서 사용할 수 있지만 세밀한 오류 메시지 작성이 불가능하다. 반대로, 자세하게 만들면 세밀한 메시지 작성이 가능하지만 범용성이 떨어진다. 가장 좋은 방법은, 범용성으로 사용하다가, 세밀하게 작성해야 하는 경우에 세밀한 내용이 적용되도록 오류 코드를 단계적으로 사용하는 것이다.

스프링은 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");
    }

}

MessageCodesResolver 는 인터페이스고 DefaultMessageCodesResolver 는 기본 구현체다. 위처럼 직접 메시지 코드들을 생성할 일은 거의 없다. BindingResultFieldErrorObjectError 를 추가하면 MessageCodesResolver 가 메시지 코드들을 생성해준다.

첫 번째 테스트는 ObjectError 를 위한 메시지 코드들을 생성한다. 객체 오류의 경우 다음 순서로 2가지 메시지 코드가 생성된다.

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

테스트를 보면 required.item, required 순서로 메시지 코드가 생성된 것을 볼 수 있다. 먼저 생성된 메시지 코드가 우선순위를 가진다.

두 번째 테스트는 FieldError 를 위한 메시지 코드들을 생성한다. 필드 오류의 경우 다음 순서로 4가지 메시지 코드가 생성된다.

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

테스트를 보면 required.item.itemName, required.itemName, required.java.lang.String, required 순서로 메시지 코드가 생성된 것을 볼 수 있다. 마찬가지로, 먼저 생성된 메시지 코드가 우선순위를 가진다.

MessageCodesResolver 는 구체적인 것에서 덜 구체적인 것의 순서로 메시지 코드들을 생성한다. 만약 개발자가 발생할 수 있는 모든 오류에 대해 메시지를 작성해야 한다면 매우 고통스러울 것이다. MessageCodesResolver 는 크게 중요하지 않은 메시지는 범용성 있는 required 와 같은 메시지로 끝내고, 중요한 메시지는 꼭 필요할 때 구체적으로 만들 수 있도록 메시지 코드들을 단계적으로 만들어준다. 이렇게 생성된 메시지 코드들은 MessageSource 를 통해 순서대로 찾아진다. 만약 아무 메시지 코드도 못 찾으면 코드에 작성한 디폴트 메시지를 사용하게 된다.

메시지 코드 생성 전략이 BeanValidation 과 만나면 그 편리성과 실용성이 극대화된다.

3. Validator


지금까지 살펴본 코드는 컨트롤러에서 검증 로직을 작성했다. 이런 경우 검증하는 역할을 분리해서 별도의 클래스로 분리하는 것이 유지보수하기에 좋을 수 있다. 앞서, BindingResult 에 오류를 넣는 세 가지라고 했다.

  • 타입 오류와 같은 바인딩 실패로 인해 스프링이 넣어줌

  • 개발자가 검증 로직을 돌려서 직접 넣기

  • Validator 를 통해서 넣기

마지막 방법을 살펴보자.

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

스프링은 검증에 특화된 Validator 검증기 인터페이스를 제공한다. supports() 는 해당 검증기가 검증하려는 객체를 지원하는지 알아보기 위해 사용된다. validate() 는 실제 검증할 객체와 BindingResult 를 넘겨서 검증을 수행하고 오류가 있다면 담는다. 참고로, ErrorsBindingResult 의 상위 인터페이스다.

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

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

        if (item.getPrice() == null || item.getPrice() < 1000 || item.getPrice() > 1_000_000) {
            errors.rejectValue("price", "range", new Object[]{1000, 1_000_000}, 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);
            }
        }

    }
}

검증기를 만들었으니, 실제 검증기를 사용하는 코드를 살펴보자.

@PostMapping("/add")
public String addItem(@ModelAttribute Item item,
                      BindingResult bindingResult,
                      RedirectAttributes redirectAttributes) {

  if (itemValidator.supports(item.getClass())) {
      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}";
}

검증하는 책임을 검증기로 분리시킴으로써 코드가 한결 읽기 쉬워졌다. 검증기는 컴포넌트 스캔을 통해 빈으로 등록했으므로 사용하는 클래스에서 주입받을 수 있다.

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

더 나아가, Validator 인터페이스를 통해 만든 검증기는 스프링의 추가적인 도움을 받을 수 있다. WebDataBinder 는 스프링의 파라미터 바인딩 역할을 해주고 검증 기능도 내부에 포함한다. 컨트롤러에서 위의 코드로 WebDataBinder 에 검증기를 추가하면 해당 컨트롤러에서 검증기가 자동으로 적용된다. WebDataBinder 는 사용자 요청마다 새로 생성되며 추가된 검증기는 해당 컨트롤러에서만 사용된다. 글로벌 설정을 할 수도 있지만 그러면 BeanValidation 이 자동 등록되지 않기 때문에 글로벌 설정하는 경우는 드물다.

바뀐 컨트롤러 코드는 다음과 같다.

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

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

검증기를 호출하는 로직 조차도 코드에서 제거되었다. @Validated 는 검증기를 실행하라는 애노테이션이다. 이 애노테이션이 붙으면 WebDataBinder 에 등록한 검증기를 찾아서 실행한다. 검증기가 여럿이라면, 어떤 검증기가 실행되어야 하는지 구분이 필요하다. 이 때, supports() 가 사용된다. 위의 경우엔, supports(Item.class) 의 결과로 ItemValidatorvalidate() 가 호출될 것이다. @ValidatedBeanValidation 에서도 사용되므로 다음 글에서 자세히 다루도록 하겠다.

4. 정리


이 글에서 BeanValidation 이 자주 언급되었다. 실제로, 필드 단위 검증은 편리하고 실용적이고 명시적인 애노테이션 기반의 BeanValidation 을 주로 사용하게 된다. 객체 단위의 검증은 애노테이션으로 처리하기엔 복잡한 경우가 많아서 이 글에서 소개한 BindingResult.reject() 를 통해 처리하는 것이 낫다.

MessageCodesResolver 가 단계적으로 생성하는 메시지 코드와 Validator 와 함께 사용된 @Validated 애노테이션 또한 BeanValidation 과 함께 사용하여 검증을 보다 편리하게 처리할 수 있다. 다음글에서 BeanValidation 을 다루도록 하겠다.

profile
아임쿨

0개의 댓글