Spring Validation

MINJU·2022년 8월 8일
0

스프링

목록 보기
13/15
post-custom-banner

아래에서 설명할 예시는 김영한님 spring mvc2 강의를 참조했습니다.

적용할 검증 시나리오는 다음과 같습니다.


1. 타입 검증
👉 가격, 수량에 문자가 들어가면 검증 오류 처리

2. 필드 검증
👉 상품명은 "필수"이고 가격은 "1000원 이상, 백만원 이하", 수량은 "최대 9999"

3. 특정 필드의 범위를 넘어서는 검증
👉 가격*수량의 합은 10,000원 이상




BindingResult

스프링프레임워크가 제공하는 인터페이스 BindingResult.
검증 오류를 보관하는 객체이다. 이 인터페이스는 Errors 인터페이스를 상속받고 있다.
실제 넘어오는 구현체는 BeanPropertyBindingResult이다.

Errors 인터페이스는 단순한 오류 저장조회 기능을 제공하는데 BindingResult는 여기에 더해서 추가적인 기능을 제공한다. 아래서 설명할 addError()BindingResult가 제공한다.

BindingResult는 검증할 대상 바로 다음에 와야한다.

    @PostMapping("/add")
    public String addItemV1(@ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes) {
        
        // 검증 로직
        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}";
    }

FieldError 생성자

필드에 오류가 있으면 FieldError 객체를 생성해서 bindingResult에 담으면 된다.

public FieldError(String objectName, String field, String defaultMessage){}
  • objectName = @ModelAttribute 이름 (오류가 발생한 객체 이름)

  • field = 오류가 발생한 필드 이름

  • defaultMessage = 오류 기본 메시지


ObjectError 생성자

특정 필드를 넘어서는 오류가 있으면 ObjectError 객체를 생성해서 bindingResult에 담아두면 된다.

public ObjectError(String objectName, String defaultMessage){}
  • objectName = @ModelAttribute 이름
  • defaultMessage = 오류 기본 메시지



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

@ModelAttribute의 객체에 타입 오류 등으로 바인딩이 실패하는 경우, 스프링이 FieldError 생성해서 BindingResult에 넣어주기 때문이다.



FieldError 생성자

위에서 설명한 생성자를 포함, 두 개의 생성자를 제공한다.
추가되는 생성자는 다음과 같다.

public Fielderror(String objectName, String field, @Nullable Object rejectedvalue, booleaen bindingFailure, @Nullable String[] codes, @Nullable Object[] arguments, @Nullabel String defaultMessage)

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

위의 생성자를 이용해서, 오류 발생시 사용자가 입력했던 값을 저장할 수 있다.

사용자의 입력 데이터가 컨트롤러 @ModelAttribute에 바인딩되는 시점에 오류가 발생하면 모델 객체에 사용자 입력 값을 유지하기 어렵다.
예를 들어서 가격에 문자가 입력된다면 가격은 Integer 타입이므로 문자를 보관할 수 있는 방법이 없다. 그래서 오류가 발생한 경우 사용자 입력 값을 보관하는 별도의 방법이 필요하고, 이렇게 보관한 사용자 입력 값을 검증 오류 발생시 다시 화면에 출력하면 된다.
여기서 rejectedValue가 바로 오류 발생시 사용자 입력 값을 저장하는 필드가 된다.



오류 코드와 메시지 처리

위의 FieldError 생성자 인자를 보면 codesarguments를 확인할 수 있다. 이것이 바로 오류 메시지를 처리하기 위한 인자이다.

오류 메시지를 커스텀하기 위해 errors 메시지 파일을 생성할 수 있다.

위치를 지정하기 위해 messages.properties 파일에 아래 코드를 작성해야한다. 이렇게 되면 스프링 부트가 messages.properties, errors.properties 두 파일을 모두 인식하게 된다. (생략하면 messages.properties를 기본으로 인식)

spring.messages.basename=messages, errors



errors.properties에 다음과 같이 메시지를 커스텀할 수 있다.

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


해당 메시지를 사용하는 코드는 다음과 같다.

// 검증 로직
        if(!StringUtils.hasText(item.getItemName()))
            bindingResult.addError(new FieldError("item", "itemName", item.getItemName(), false,
                    new String[]{"required.item.itemName"}, null,  null));



컨트롤러에서 BindingResult는 검증해야할 객체인 target 바로 다음에 온다.
따라서 BindingResult는 이미 본인이 검증해야할 객체인 target을 알고 있다.

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


void rejectValue(@Nullable String field, String errorCode, @Nullable Object[] errorArgs, @Nullable String defaultMessage);
  • field = 오류 필드명
  • errorCode = 오류 코드 (MessageResolver를 위한 오류 코드)
  • errorArgs = 오류메시지에서 {0}을 치환하기 위한 값
  • defaultMessage = 오류 메시지를 찾을 수 없을 때 사용하는 기본 메시지

bindingResult.rejectValue("price", "range", new Object[]{1000, 1000000}, null)

BindingResult는 어떤 객체를 대상으로 검증하는지 target을 이미 알고 있다. 따라서 target(item)에 대한 정보는 없어도 된다.



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

reject()는 객체에 대한 에러코드 및 메시지, 메시지 인자를 전달하고 rejectValue()는 필드에 대한 에러 정보를 추가한다.



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

해당 코드로 변경해도 멀쩡하게 커스텀한 오류 메시지가 출력됨을 확인할 수 있다.



MessageCodesResolver

검증 오류로 메세지 코드들을 생성하는 인터페이스이다. DefaultMessageCodesResolver가 기본 구현체이다.

객체 오류의 경우
1. code + "." + object name
2. code
순서로 기본 메시지 key값을 생성하고


필드 오류의 경우
1. code + "." + object name + "." + field
2. code + "." + field
3. code + "." + field type
4. code
순서로 기본 메시지 key 값을 생성한다.


여기서 핵심은 구체적인 것에서 덜 구체적인 것으로 우선순위가 정해진다는 것이다. 따라서 크게 중요하지 않은 메시지는 범용성 있는 requried같은 메시지로 끝내고, 중요한 메시지는 꼭 필요할 때 구체적으로 적어서 사용하는 방식이 더 효과적이다.

이에 더해서 ValidationUtils 클래스를 사용하여 더 간단하게도 표현이 가능하다. 이 클래스는 'Empty' 와 같은 단순한 기능만 제공한다.

아래의 코드가

if (!StringUtils.hasText(item.getItemName())) {
 bindingResult.rejectValue("itemName", "required", "기본: 상품 이름은필수입니다.");
}

아래와 같이 단순하게 변경 가능해진다.

ValidationUtils.rejectIfEmptyOrWhitespace(bindingResult, "itemName","required");



Validator 분리

위에서 볼 수 있듯, 컨트롤러에서 검증 로직이 매우 큰 부분을 차지하게 된다면 별도의 클래스로 역할을 분리하는 것이 보다 더 효율적이다. 이렇게 되면 분리한 검증로직을 재사용할 수도 있게 된다.

@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) { // 검증대상객체와 BindingResult
        Item item = (Item) target;
        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()>10000){
            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
    이라는 의미를 담고 있다.


호출하는 코드는 다음과 같다.

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



위의 코드를 WebDataBinder를 통해서 사용하도록 변경할 수도 있다.

컨트롤러 내부에

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

해당 코드를 작성하면 된다. 이렇게 WebDataBinder에 검증기를 추가하면 해당 컨트롤러에서는 검증기를 자동으로 적용할 수 있다.

이렇게 되면 코드가

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

이와 같이 단순하게 변경됨을 확인할 수 있다.

@Validated는 검증기를 실행하라는 애노테이션으로서, WebDataBinder에 등록된 검증기를 찾아서 실행하게 되는데 여러 검증기 중에서 실행되어야할 검증기를 찾아내는 과정에서 supports()가 사용된다.

post-custom-banner

0개의 댓글