[스프링 웹 MVC 2편] 006. Validation (2)
BindingResult
가 있으면 @ModelAttribute
에 데이터 바인딩 시 오류가 발생해도 컨트롤러가 호출됨.
BindingResult
에 검증 오류를 적용하는 3가지 방법
@ModelAttribute
의 객체에 타입 오류 등으로 바인딩이 실패하는 경우 스프링이FieldError
를 생성하여BindingResult
에 넣어준다- 개발자가 직접 넣어준다
@Validated
사용
FieldError
, ObjectError
의 확장
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
: 타입 오류와 같은 바인딩 실패인지 단순 검증 실패인지 구분- 바인딩 실패시 : True, 아니면 False
codes
: 메세지 코드arguments
: 메세지에서 사용하는 인자 : {0}, {1} 등등defaultMessage
: 기본 오류 메세지
new FieldError("item", "price", item.getPrice(), false, null, null, "가격 1,000 ~ 1,000,000까지 허용됨");
타임리프는 th:field="*{price}"
와 같이 th:field
는 id
와 name
을 생성함에 더불어
정상 상황에서는 모델 객체의 값을 받아와 사용하지만, 오류가 발생하는 경우 FieldError
에서 보관한 값을 사용해서 값을 출력한다.
오류코드와 메세지 처리
errors
메세지 파일 생성을 통해 오류메세지를 messages
처럼 관리할 수 있다.
errors.properties
파일을 생성하기 전에 스프링 부트에서는 해당 메세지의 사용을 설정해야 한다.
application.properties
에 spring.messages.basename=messages,errors
errors.properties
에 다음과 같이 코드를 작성한다.
required.item.itemName=상품 이름은 필수입니다
// Validation Logic
if(!StringUtils.hasText(item.getItemName())){
bindingResult.addError
(new FieldError("item", "itemName", item.getItemName(), false, new String[]{"required.item.itemName"}, 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}, "가격은 ~ 입니다"));
}
// Validation Logic
if(!StringUtils.hasText(item.getItemName())){
bindingResult.rejectValue("itemName", "required", "기본 : 상품 이름은 필수입니다");
// 첫글자만 따서 넣어주면 됨 (규칙?)
// required.item.itemName
더 편리하게 사용할 수 있는 방법이 없나?
FieldError
와 ObjectError
에는 써야 할 parameter
들이 너무 많다.
BindingResult
가 제공하는 rejectValue()
와 reject()
를 사용하면 FieldError
, ObjectError
를 사용하지 않고도 깔끔하게 검증 오류를 다룰 수 있다.
if(!StringUtils.hasText(item.getItemName())){
bindingResult.rejectValue("itemName", "required", "기본 : 상품 이름은 필수입니다");
// 첫글자만 따서 넣어주면 됨 (규칙?)
// required.item.itemName}
ValidationUtils.rejectIfEmpty(bindingResult, "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);
}
// ModelAttribute는 값을 자동으로 넣어줌 -> th:object 그대로 남아있음.
// 메모리상에 남아있는 객체 자료 재활용함. 위에 new Item()으로 선언한 이유임
// Combinational Validation
if(item.getPrice() != null && item.getQuantity() != null){
int resultPrice = item.getPrice() * item.getQuantity();
if(resultPrice < 10000){
bindingResult.reject("totalPriceMin", new Object[]{10000, resultPrice}, null);
}
}
errors.properties
에 코드를 입력하지 않았음에도 불구하고 오류메세지가 정상 출력되는 이유?
rejectValue()
의 기본 형식은 다음과 같다
void rejectValue(@Nullable String field, String errorCode, @Nullable Object[] errorArgs, @Nullable String defaultMessage);
field
: 오류 필드 명errorCode
: 오류코드 (messageResolver를 위한)errorArgs
: 오류 메세지에서 {0}, {1}, {2} 등을 치환하기 위한 값defaultMessage
: 오류메세지 찾을 수 없는 경우 사용할 기본 메세지
BindingResult는 어떤 객체를 검증대상으로 갖는지 target을 이미 알고 있어서 fieldError
에서 맨 처음 parameter를 target으로 한것과는 다르게 따로 기술해줄 필요가 없다!
축약된 오류 코드에 대해
FieldError
를 직접 다룰 때는 오류 코드를 range.item.price
와 같이 모두 입력했는데rejectValue()
를 사용하고 부터는 오류 코드를 range
만 입력했다.
MessageCodesResolver
를 이해해야 그 이유를 확인할 수 있다.
range.item.price=가격은 {0} ~ {1} 까지 허용합니다.
void reject(String errorCode, @Nullable Object[] errorArgs, @Nullable String defaultMessage)
는 앞에서 설명한 글로벌 오류 발생시 사용하는 것과 같다.
메세지 생성 방식에 대해(FieldError, ObjectError, RejectValue, Reject)
자세한 메세지 생성방식
required.item.itemName
: 상품 이름은 필수입니다.range.item.price
: 상품의 가격 범위 오류 입니다.
자세하지 않은 메세지 생성방식(러프한)
required
: 필수 값입니다.range
: 범위 오류 입니다.
위와 아래가 모두 기술되어있으면, 상세한 메세지를 먼저 사용하고 러프한 메세지를 후순위로 사용한다.
객체 + 필드명을 조합한 메세지 확인 후 범용적인 메세지를 이용한다.
DefaultMessageCodesResolver
의 기본 메세지 명명 규칙은 다음과 같다
객체, 즉 글로벌 오류의 경우 다음과 같은 순서로 기본메세지 확인
1.: code + "." + object name
2.: code
필드 오류의 경우 다음과 같은 순서로 기본 메세지 확인
1.: code + "." + object name + "." + field # 예시) required.item.itemName
2.: code + "." + field # 예시) "required.itemName"
3.: code + "." + field type # 예시) "required.java.lang.String"
4.: code # 예시) "required"
자바 기본 오류의 경우
1. "typeMismatch.user.age"
2. "typeMismatch.age"
3. "typeMismatch.int"
4. "typeMismatch"
ValidationUtils
ValidationUtils를 사용하면 hasText()
, rejectIfEmptyOrWhiteSpace
와 같은 단순한 기능 제공
결론
- rejectValue() 호출
- MessageCodesResolver를 사용해서 검증 오류 코드로 메세지 코드 생성
- new FieldError()를 생성해서 메세지 코드들을 보관
- th:errors에서 메세지 코드를 통해 순서대로 메세지 찾고 노출
검증에서 걸릴 수 있는 경우의 수는 두가지
- 객체 필드에 해당 값이 바인딩이 정상적으로 되는 경우
- 이 때는 필드에 조건을 비교하여 검사하는 검증 진행
- 아예 바인딩이 정상적으로 안되는 경우(객체 타입 정보에 입력값이 맞지 않는 경우)
- 주로 TypeMismatch가 이에 해당함.
Validator
복잡한 검증 로직의 별도 분리
다음과 같이 Validator
를 상속한 ItemValidator
클래스를 만들어보자
package hello.itemservice.web.validation;
import hello.itemservice.domain.item.Item;
import lombok.extern.slf4j.Slf4j;
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
@Slf4j
public class ItemValidator implements Validator {
@Override
public boolean supports(Class<?> clazz) {
return Item.class.isAssignableFrom(clazz);
// 파라미터로 넘어오는 클래스가 Item에 지원이 되냐
// item == clazz
// item == subItem(자식 클래스도 통과하게 끔 할 수 있다) isAssignableFrom
}
@Override
public void validate(Object target, Errors errors) {
// Object, Errors 가 넘어온다
// Object는 target (item을 넘긴다 캐스팅 필요)
Item item = (Item) target;
// Errors 는 errors의 부모
// Binding Result는 Target을 가지고 있다.
// rejectValue, reject를 사용하면 field, object error를 사용하지 않고도 검증 구현 가능.
// 더 디테일한 내용? errors.properties에 있는 코드 직접 사용 X -> HOW? // 결국 필드 에러 대신해서 생성해주게 됨.
// Validation Logic if(!StringUtils.hasText(item.getItemName())){
errors.rejectValue("itemName", "required", "기본 : 상품 이름은 필수입니다");
// 첫글자만 따서 넣어주면 됨 (규칙?)
// required.item.itemName }
ValidationUtils.rejectIfEmpty(errors, "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);
}
// ModelAttribute는 값을 자동으로 넣어줌 -> th:object 그대로 남아있음.
// 메모리상에 남아있는 객체 자료 재활용함. 위에 new Item()으로 선언한 이유임
// Combinational Validation
if(item.getPrice() != null && item.getQuantity() != null){
int resultPrice = item.getPrice() * item.getQuantity();
if(resultPrice < 10000){
errors.reject("totalPriceMin", new Object[]{10000, resultPrice}, null);
}
}
}
}
직접 호출시 컨트롤러
컨트롤러 코드 위에 @Autowired
를 통한
private final ItemValidator itemValidator;
추가와 함께
@PostMapping("/add")
public String addItemV5(@ModelAttribute Item item,
BindingResult bindingResult, // ModelAttribute 바로 뒤에 와야 함! (순서 중요)
RedirectAttributes redirectAttributes,
Model model) {
log.info("objectName = {}", bindingResult.getObjectName());
log.info("target = {}", bindingResult.getTarget());
// Binding Result는 Target을 가지고 있다.
// rejectValue, reject를 사용하면 field, object error를 사용하지 않고도 검증 구현 가능.
// 더 디테일한 내용? errors.properties에 있는 코드 직접 사용 X -> HOW? // 결국 필드 에러 대신해서 생성해주게 됨.
// // Validation Logic
// if(!StringUtils.hasText(item.getItemName())){
// bindingResult.rejectValue("itemName", "required", "기본 : 상품 이름은 필수입니다");
// // 첫글자만 따서 넣어주면 됨 (규칙?)
// // required.item.itemName
// }
//
// ValidationUtils.rejectIfEmpty(bindingResult, "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);
// }
//
// // ModelAttribute는 값을 자동으로 넣어줌 -> th:object 그대로 남아있음.
// // 메모리상에 남아있는 객체 자료 재활용함. 위에 new Item()으로 선언한 이유임
//
// // Combinational Validation
// if(item.getPrice() != null && item.getQuantity() != null){
// int resultPrice = item.getPrice() * item.getQuantity();
// if(resultPrice < 10000){
// bindingResult.reject("totalPriceMin", new Object[]{10000, resultPrice}, null);
// }
//
// }
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}";
}
주석처리된 길고 긴 코드들이 전부 사라지는 마법을 경험하게 된다.
@InitBinder // 모든 경우에 검증
public void init(WebDataBinder dataBinder){
dataBinder.addValidators(itemValidator);
}
추가로 상단에 다음과 같이 @InitBinder
가 적용된 WebDataBinder
메서드를 작동하면
해당 클래스 내에 모든 컨트롤러 호출시 검증 로직을 적용할 수 있다.
@PostMapping("/add")
public String addItemV6(@Validated @ModelAttribute Item item,
BindingResult bindingResult, // ModelAttribute 바로 뒤에 와야 함! (순서 중요)
RedirectAttributes redirectAttributes,
Model model) {
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}";
}
컨트롤러 메서드 구현은 위와 같이 간단해진다.itemValidator.validate()
코드 조차도 필요가 없어진다!
P.S) 글로벌 적용을 위해서는 ItemServiceApplication
에 WebMvcConfigurer
를 상속하여getValidator()
메서드를 오버라이드 하고 ItemValidator
를 반환하면 모든 컨트롤러에서 글로벌하게 적용된다.
글로벌 설정을 하면 이후 스프링 통합에서 사용하는 BeanValidator
가 작동하지 않음에 유의하자