아래에서 설명할 예시는 김영한님 spring mvc2 강의를 참조했습니다.
적용할 검증 시나리오는 다음과 같습니다.
1. 타입 검증
👉 가격, 수량에 문자가 들어가면 검증 오류 처리
2. 필드 검증
👉 상품명은 "필수"이고 가격은 "1000원 이상, 백만원 이하", 수량은 "최대 9999"
3. 특정 필드의 범위를 넘어서는 검증
👉 가격*수량의 합은 10,000원 이상
스프링프레임워크가 제공하는 인터페이스 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 객체를 생성해서 bindingResult에 담으면 된다.
public FieldError(String objectName, String field, String defaultMessage){}
objectName = @ModelAttribute 이름 (오류가 발생한 객체 이름)
field = 오류가 발생한 필드 이름
defaultMessage = 오류 기본 메시지
특정 필드를 넘어서는 오류가 있으면 ObjectError 객체를 생성해서 bindingResult에 담아두면 된다.
public ObjectError(String objectName, String defaultMessage){}
BindingResult
가 있으면 @ModelAttribute
에 데이터 바인딩시 오류가 발생해도 컨트롤러가 호출된다.
@ModelAttribute
의 객체에 타입 오류 등으로 바인딩이 실패하는 경우, 스프링이 FieldError
생성해서 BindingResult
에 넣어주기 때문이다.
위에서 설명한 생성자를 포함, 두 개의 생성자를 제공한다.
추가되는 생성자는 다음과 같다.
public Fielderror(String objectName, String field, @Nullable Object rejectedvalue, booleaen bindingFailure, @Nullable String[] codes, @Nullable Object[] arguments, @Nullabel String defaultMessage)
위의 생성자를 이용해서, 오류 발생시 사용자가 입력했던 값을 저장할 수 있다.
사용자의 입력 데이터가 컨트롤러 @ModelAttribute에 바인딩되는 시점에 오류가 발생하면 모델 객체에 사용자 입력 값을 유지하기 어렵다.
예를 들어서 가격에 문자가 입력된다면 가격은 Integer
타입이므로 문자를 보관할 수 있는 방법이 없다. 그래서 오류가 발생한 경우 사용자 입력 값을 보관하는 별도의 방법이 필요하고, 이렇게 보관한 사용자 입력 값을 검증 오류 발생시 다시 화면에 출력하면 된다.
여기서 rejectedValue
가 바로 오류 발생시 사용자 입력 값을 저장하는 필드가 된다.
위의 FieldError 생성자 인자를 보면 codes
와 arguments
를 확인할 수 있다. 이것이 바로 오류 메시지를 처리하기 위한 인자이다.
오류 메시지를 커스텀하기 위해 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);
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);
}
해당 코드로 변경해도 멀쩡하게 커스텀한 오류 메시지가 출력됨을 확인할 수 있다.
검증 오류로 메세지 코드들을 생성하는 인터페이스이다. 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");
위에서 볼 수 있듯, 컨트롤러에서 검증 로직이 매우 큰 부분을 차지하게 된다면 별도의 클래스로 역할을 분리하는 것이 보다 더 효율적이다. 이렇게 되면 분리한 검증로직을 재사용할 수도 있게 된다.
@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()
가 사용된다.