본문 바로가기

Spring/MVC

[Spring MVC] 요청 검증 - 코드 & 메시지

이전 장(링크) 에서는 기본적인 방법으로 파라미터의 유효성을 검사하는 방법에 대해서 알아보았다.
이번 장에서는 스프링의 메시지 기능을 사용하여 체계적인 오류 코드와 메시지 처리 방법에 대해서 알아본다.
모든 코드는 깃허브(링크) 에 올려두었다.


메시지 사용

message.properties 파일과 errors.properties 파일을 추가하고 스프링 부트가 해당 메시지 파일을 인식할 수 있게 application.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}

FieldErrorObjectError의 생성자는 파라미터로 errorCodearguments를 전달받는다.
오류가 발생하는 경우 코드로 메시지를 찾기 위해 사용되며 메시지에 사용되는 매개변수를 지정할 수 있다.

public String addItem(@ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model) {
    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()) {
        log.info("errors={} ", bindingResult);
    }
}
  • codesrequired.item.itemName와 같이 메시지 코드의 키값을 지정한다.
    만약 메시지 코드가 하나가 아니라 배열로 전달된다면 순서대로 매칭하여 처음에 매칭되는 메시지가 사용된다.
  • argumentsObject[{...}]와 같이 메시지에 사용될 변수를 전달할 수 있다.

FieldError, ObjectError 생략

매번 검증하는 코드에 FieldError, ObjectError 객체를 생성해야 하는데 이 부분을 수정하고 오류 코드도 더 간단하게 지정 가능하도록 수정해본다.
우리는 컨트롤러에서 BindingResult는 검증해야 할 객체인 target바로 다음에 오도록 순서를 지정하기 때문에 BindingResult 객체는 자신이 검증해야 하는 객체인 target을 알고 있다.

이미 BindingResult가 검증해야 하는 객체를 알고 있기 때문에 직접 FieldError, ObjectError를 생성하지 않고 rejectValue, reject 메서드를 사용하여 검증 오류를 다룰 수 있다.

public String addItem(@ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model) {
    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, 10000000}, 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("errors={} ", bindingResult);
    }
}

rejectValue

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

BindingResult는 매개변수의 위치를 기반으로 어떤 객체를 검증하는지 알고 있기 떄문에 target에 대한 정보없이 검증이 가능하다.

FieldError()를 직접 다룰 때는 오류 코드를 range.item.price와 같이 모두 입력했지만 rejectValue를 사용하면 오류코드를 사용하는 경우에는 간단하게 range로 입력하였다.
하지만 스프링에서는 우리가 원하는 메시지를 찾아서 출력하였다. 이렇게 작동하는지 이해하기 위해서는 MessageCodesResolver를 이해해야 한다.


메시지 범용 활용

오류 메시지의 경우 특정 필드에 종속되도록 자세하게 만드는 경우와 그렇지 않게 범용적으로 만드는 경우가 있다.

특정 필드에 종속되는 경우

  • required.item.itemName: 상품 이름은 필수 입니다.
  • range.item.price: 상품의 가격 범위 오류 입니다.

범용적으로 만드는 경우

  • required: 필수 값 입니다.
  • range: 범위 오류 입니다.

범용적으로 만드는 경우 재사용성이 높아지지만 세밀한 메시지를 작성하기에는 무리가 있다.
하지만 특정 필드에 종속적으로 만드는 경우에는 특정 필드와 같은 속성을 가지고 있는 곳에만 사용할 수 있다는 단점이 있다.
좋은 방법으로는 범용성으로 사용하다가, 세밀하게 작성해야 하는 경우에는 세밀한 내용이 적용되도록 메시지에 단계를 두는 방법이 있다.

예를 들어 required라고하는 오류 코드가 있다.

required: 필수 값 입니다.

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

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

세밀한 메시지가 있는 경우 우선 확인하고, 없으면 범용적인 메시지를 선택하도록 개발하면 된다.
범용성 있게 잘 개발해두면, 간단한 메시지 추가로 편리하게 오류 메시지를 관리할 수 있게 된다.

스프링은 MessageCodesResolver를 통해서 이러한 기능을 지원한다.


MessageCodesResolver

MessageCodesResolver는 검증 오류 코드로 메시지 코드들을 생성한다.
MessageCodesResolver는 인터페이스이며 DefaultMessageCodesResolver가 기본 구현체이다. 주로 ObjectErrorFieldError와 함께 사용된다.

DefaultMessageCodesResolver의 기본 메시지 생성 규칙

객체 오류
객체 오류의 경우 아래와 같은 순서로 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.item.Name
  • required.java.lang.String
  • required

ObjectError reject("totalPriceMin")
아래와 같은 2가지 오류 코드를 자동으로 생성한다.

  • totalPriceMin.item
  • totalPriceMin

스프링의 오류 메시지 처리

검증 오류 코드는 개발자가 직접 rejectValue()를 호출하는 경우와 스프링이 검증 오류에 추가(주로 타입 캐스팅 오류)한 경우가 있다.
숫자가 입력되어야 하는 필드에 문자열을 입력하면 BindingResult에는 개발자가 직접 입력하지 않아도 FieldError가 담겨있는 것을 확인할 수 있다.
BindingResult를 사용하는 경우 타입 캐스팅 시 오류가 발생하여도 컨트롤러 코드에 진입한다.

codes[typeMismatch.item.price,typeMismatch.price,typeMismatch.java.lang.Integer,typeMismatch]

스프링은 타입 오류가 발생하면 typeMismatch라는 오류 코드를 사용하며 해당 오류 코드가 MessageCodesResolver를 통해서 아래와 같이 네 개의 메시지 코드를 생성한다.

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

이렇게 스프링에서 자동으로 메시지 코드를 생성하기 때문에 우리는 error.properties와 같은 설정파일만 수정하면 코드의 수정없이 원하는 메시지를 단계별로 설정할 수 있다.
(Spring Cloud를 사용하면 애플리케이션 재실행없이 설정파일을 적용할 수 있다. 즉, 애플리케이션의 재실행없이 화면에 표시되는 문구를 바꿀수 있게 된다.)


Validator(검증기) 분리

지금까지 우리가 작성한 코드는 검증을 위한 코드가 컨트롤러 코드의 대부분을 차지하면서 가독성을 떨어뜨렸다.
이는 AOP적인 관점에서 보았을 때 좋지 못한 방식이므로 검증을 위한 기능을 별도의 클래스로 역할을 분리해본다.

  1. Item 엔티티를 검증하는 ItemValidator 클래스를 생성한다.

ItemValidator

@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() > 1000000) {
            errors.rejectValue("price", "range", new Object[]{1000, 10000000}, 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);
            }
        }
    }
}

우리가 작성한 ItemValidator 클래스는 스프링의 Validator 인터페이스를 구현하고 있기 때문에 스프링에서 체계적인 검증이 가능해진다.

support() 메서드는 isAssignableFrom() 메서드를 사용하여 호환되는 클래스인지 확인한다.

  • isAssignableFrom: 특정 Class가 어떠한 클래스/인터페이스를 상속/구현했는지 확인한다.
  • instanceof: 특정 Object가 어떠한 클래스/인터페이스를 상속/구현했는지 확인한다.

validate() 메서드는 실제로 우리가 필요로하는 검증 로직을 구현하고 있다.

  1. WebDataBinder 연동

스프링의 @InitBinder 애노테이션을 사용하여 WebDataBinder를 통해 해당 컨트롤러에 Validator(검증기)를 자동으로 적용할 수 있다.
@InitBinder를 통한 적용 방법은 해당 컨트롤러에만 적용된다.

@InitBinder
public void init(WebDataBinder dataBinder) {
    dataBinder.addValidators(itemValidator);
}
  1. @Validated 애노테이션 추가
@PostMapping("/add")
public String addItemV6(
    @Validated @ModelAttribute Item item, 
    BindingResult bindingResult, 
    RedirectAttributes redirectAttributes, 
    Model model) {
    // ...
}

@Validated 애노테이션을 우리가 검증하려는 대상에 붙여주면 WebDataBinder에 등록된 검증기를 찾아서 실행한다.
여러 검증기가 등록되어 있다면 우리가 1단계에서 작성한 support() 메서드를 통해서 어떠한 검증기가 실행되야 하는지 확인한다.
(마치 DispatcherServlet의 프론트 컨트롤러 패턴과 유사하게 동작)

우리는 대상을 검증하기 위해 대상에게 @Validated를 붙여주었다.
@Validated이외에 @Valid라는 애노테이션도 사용이 가능하며 동일하게 동작한다.
@Validated의 경우 스프링 전용 검증 애노테이션이고 @Valid는 자바 표준 검증 애노테이션이다.


참고한 강의:

참고한 문서: