요구사항
현재 : 지금 잘못된 입력이 들어오면, 400에러 : Bad Request 가 발생한다.
개선 : 잘못된 값이 들어오면, 잘못입력되었습니다 등의 어떤 오류가 발생하였는지 사용자에게 알려주어야 한다.
상품 저장 성공 Case

상품 저장 실패 - 검증 실패 Case

요구사항 정리
ValidationItemControllerV1.class
@PostMapping("/add") //실제 저장 로직 실행
public String addItem(@ModelAttribute Item item, RedirectAttributes redirectAttributes, Model model) {
// 검증 오류 결과를 보관
Map<String, String> errors = new HashMap<>();
//검증 로직
if (isNull(item)) {
errors.put("itemName", "상품 이름은 필수 입니다");
}
if (item.getPrice() == null || item.getPrice() < 1000 || item.getPrice() > 1000000) {
errors.put("price", "가격은 1,000 ~ 1,000,000 까지 허용합니다");
}
if (item.getQuantity() == null || item.getQuantity() > 9999) {
errors.put("quantity", "수량은 최대 9,999까지 허용합니다");
}
if (item.getPrice() != null && item.getQuantity() != null) {
int resultPrice = item.getPrice() * item.getQuantity();
if (resultPrice < 10000) {
errors.put("globalError", "가격 * 수량의 합은 10,000 원 이상이여야 합니다. 현재 값 : " + resultPrice);
}
}
//검증에 싪패하면 다시 입력 폼으로
if (hasError(errors)) {
log.info("errors = {}", errors);
model.addAttribute("errors", errors);
return "validation/v1/addForm";
}
//검증에 성공한 이후 저장 로직
Item savedItem = itemRepository.save(item);
redirectAttributes.addAttribute("itemId", savedItem.getId());
redirectAttributes.addAttribute("status", true);
return "redirect:/validation/v1/items/{itemId}";
}
private static boolean isNull(Item item) {
return !StringUtils.hasText(item.getItemName());
}
private static boolean hasError(Map<String, String> errors) {
return !errors.isEmpty();
}
th:class="${errors?.containsKey('itemName')} ? 'form-control field-error' : 'form-control'"
만약, 상품 목록에서 상품 등록 버튼을 눌러 http://localhost:8080/validation/v1/items/add 요청을 보냈다면,
errors Hashmap 객체를 만들지 않는다 (Get - /add) 만약 이때 ? 를 붙이지 않는다면, NullPointException이 발생한다. 따라서 clasappend 를 사용하여 nulll point excepiton 을 방지한다.
Tip💡 페이지에서 replace : 단축키 command + R
Tip💡 디렉토리, 패키지 선택하고, 선택한 하위 모든 페이지에서 replace : 단축키 command + Shift + R
V1 → V2 생성.
ValidationItemControllerV2
@PostMapping("/add") //실제 저장 로직 실행
public String addItem(@ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model) {
...
}
→ @ModelAttribute 뒤에 BindingResult 를 추가한다.
→ 오류가 생기면 BindingResult 에 담는다. 이것이 V1 의 errors와 같은 역할을 한다.
//검증 로직
if (isNull(item)) {
bindingResult.addError(new FieldError("item", "itemName", "상품 이름은 필수 입니다"));
}
오류의 종류 :
FieldError는 2가지 생성자를 제공하고, 그중 첫번째는 3가지 파라미터가 있다.
ObjectError는 2가지 파라미터가 있다.
"가격 * 수량의 합은 10,000 원 이상이여야 합니다. 현재 값 : " + resultPrice이후, 검증에 실패 시 입력 폼으로 return 해야 한다.
//검증에 싪패하면 다시 입력 폼으로
if (bindingResult.hasErrors()) {
log.info("errors = {}", bindingResult);
return "validation/v2/addForm";
}
BindingResult 가 있으면 @ModelAttribute 에 데이터 바인딩 시 오류가 발생해도 컨트롤러가 호출된다!만약, @ModelAttribure에 바인딩 시 타입 오류가 발생하면?

ValidationItemControllerV2.class - addItemV2
@PostMapping("/add") //실제 저장 로직 실행
public String addItemV2(@ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model) {
//검증 로직
if (isNull(item)) {
bindingResult.addError(new FieldError("item", "itemName", item.getItemName(),false,null,null, "상품 이름은 필수 입니다"));
}
if (item.getPrice() == null || item.getPrice() < 1000 || item.getPrice() > 1000000) {
bindingResult.addError(new FieldError("item", "price", item.getPrice(), false, null, null, "가격은 1,000 ~ 1,000,000 까지 허용합니다"));
}
if (item.getQuantity() == null || item.getQuantity() > 9999) {
bindingResult.addError(new FieldError("item", "quantity", item.getQuantity(), false, null, null, "수량은 최대 9,999까지 허용합니다"));
}
if (item.getPrice() != null && item.getQuantity() != null) {
int resultPrice = item.getPrice() * item.getQuantity();
if (resultPrice < 10000) {
bindingResult.addError(new ObjectError("item", null, null, "가격 * 수량의 합은 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
//위의 BindingResult1 에서 사용한 생성자
public FieldError(String objectName,
String field,
String defaultMessage);
//현재 BindingResult2 에서 사용한 생성자
public FieldError(String objectName,
String field,
@Nullable Object rejectedValue,
boolean bindingFailure,
@Nullable String[] codes,
@Nullable Object[] arguments,
@Nullable String defaultMessage)bindingResult.addError(new FieldError("item", "itemName", item.getItemName(),false,null,null, "상품 이름은 필수 입니다"));th:field = “*{price}” 는 정상 상황일 때, 모델 객체의 값을 사용하고, 오류가 발생 시 FieldError에서 보관한 값을 사용해서 값을 출력한다.
→ Failed to convert property value of type java.lang.String to required type java.lang.Integer for property price; nested exception is java.lang.NumberFormatException: For input string: "qqq”
: 스프링이 기본으로 제공해주는 오류 메시지이다.
하지만, 불친절하고 개발자스럽다,,, 일반 사용자에게 친절하지 않다. → 오류 메시지를 관리하는 메커니즘을 공부해보자!
required.item.itemName=상품 이름은 필수입니다.
range.item.price=가격은 {0} ~ {1} 까지 허용합니다.
max.item.quantity=수량은 최대 {0} 까지 허용합니다.
totalPriceMin=가격 * 수량의 합은 {0}원 이상이어야 합니다. 현재 값 = {1}→ error_en.properties 파일을 생성하여 오류메시지도 국제화 처리가 가능하다.
이제 errors 에 등록한 메시지를 사용해보자.
ValidationItemControllerV2.class - addItemV3() 추가
@PostMapping("/add") //실제 저장 로직 실행
public String addItemV3(@ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model) {
//검증 로직
if (isNull(item)) {
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);
return "validation/v2/addForm";
}
//검증에 성공한 이후 저장 로직
Item savedItem = itemRepository.save(item);
redirectAttributes.addAttribute("itemId", savedItem.getId());
redirectAttributes.addAttribute("status", true);
return "redirect:/validation/v2/items/{itemId}";
}
null → String[]{”…”, ”…”} : errors.properties 에서 오류메시지를 찾는다, 없으면 defaultMessage 출력됨.null → Object[]{ … , … } : 오류메시지에 넘길 인자를 넣어줄 수 있다.FieldError, ObjectError 의 인자를 하나하나 넣어야한다 → 다루기가 어렵고, 번거롭다.
오류 코드도 좀 더 자동화 할 수 있을까??
→ Controller에서 BindingResult는 검증해야 할 객체 바로 다음에 온다. 따라서 BindingResult는 이미 본인이 검증해야 할 객체를 알고 있다.
BindingResult 는 rejectValue(), reject() 를 제공한다. 이를 사용하면 , FieldError 와 ObjectError 를 직접 생성하지 않고, 깔끔하게 오류를 다룰 수 있다.
addItemV3 (Before)
bindingResult.addError(new FieldError("item", "itemName", item.getItemName(), false, new String[]{"required.item.itemName"}, null, null));
bindingResult.addError(new FieldError("item", "price", item.getPrice(), false, new String[]{"range.item.price"}, new Object[]{1000, 1000000}, null));
bindingResult.addError(new FieldError("item", "quantity", item.getQuantity(), false, new String[]{"max.item.quantity"}, new Object[]{9999}, null));
bindingResult.addError(new ObjectError("item", new String[]{"totalPriceMin"}, new Object[]{10000, resultPrice}, null));
addItemV4 (After)
bindingResult.rejectValue("itemName", "required");
bindingResult.rejectValue("price", "range", new Object[]{1000, 1000000}, null);
bindingResult.rejectValue("quantity", "max", new Object[]{9999}, null);
bindingResult.reject("totalPriceMin", new Object[]{10000, resultPrice}, null);
rejectValue() 는 3가지 방법을 지원한다.
void rejectValue*(*@Nullable String field, String errorCode*)*;void rejectValue*(*@Nullable String field, String errorCode, String defaultMessage*)*;void rejectValue*(*@Nullable String field, String errorCode, @Nullable Object*[]* errorArgs, @Nullable String defaultMessage*)*;reject() 도 3가지 방법을 지원한다.
void reject*(*String errorCode*)*;void reject*(*String errorCode, String defaultMessage*)*;void reject*(*String errorCode, @Nullable Object*[]* errorArgs, @Nullable String defaultMessage*)*;분명 errors.properties에 errorCode 를
required.item.itemName=상품 이름은 필수입니다.
라고 하였다.
하지만, rejectValue의 errorCode 로 넘겨준 값은 “required”, “range”, “max”, “totalPriceMin” 이다.
어떻게 required → required.item.itemName 으로 찾을까??
MessageCodesResolver 를 통해 찾는다!
기존 만들었던 오류 메시지는 상세히, 자세히 만들었다.
required.item.itemName=상품 이름은 필수입니다.
range.item.price=가격은 {0} ~ {1} 까지 허용합니다.
max.item.quantity=수량은 최대 {0} 까지 허용합니다.
totalPriceMin=가격 * 수량의 합은 {0}원 이상이어야 합니다. 현재 값 = {1}
하지만, 범용성을 갖춰 단순하게 만들 수도 있다.
required=필수 값 입니다.
range=범위는 {0} ~ {1} 까지 허용합니다.
max=최대 {0} 까지 허용합니다.
하지만, 메시지를 세밀하게 작성하기 어렵다.
만약, 위의 두개가 동시에 작성되어 있다면, bindingResult.rejectValue("itemName", "required"); 은 어떤것을 고르게 될까?
→ 객체명과 필드명을 조합한 세밀한 메시지 코드가 있다면, 해당 메시지 (=세밀한 메시지) 를 높은 우선순위로 사용한다.
Test
@Test
void messageCodesResolverObject() {
String[] messageCodes = codesResolver.resolveMessageCodes("required", "item");
for (String messageCode : messageCodes) {
System.out.println("messageCode = " + messageCode);
}
}
실행 결과
messageCode = required.item
messageCode = required
→ String[] 을 만들어주는 것을 알 수 있다. 그리고 우선 순위는 디테일 한 것이 먼저이다.
Test
@Test
void messageCodesResolverField() {
String[] messageCodes = codesResolver.resolveMessageCodes("required", "item", "itemName", String.class);
for (String messageCode : messageCodes) {
System.out.println("messageCode = " + messageCode);
}
}
실행 결과
messageCode = required.item.itemName
messageCode = required.itemName
messageCode = required.java.lang.String
messageCode = required
errors.properties 추가
Level1 ~ 4까지 추가.
#Level1
required.item.itemName
...
#Level2
...
#Level3
required.java.lang.String
...
#Level4
required
간단하게 사용할 수 있음. 기능은 Empty 또는 공백 인 경우만 검증할 수 있다.
//Before
if (isNull(item)) {
bindingResult.rejectValue("itemName", "required");
}
//After
ValidationUtils.rejectIfEmptyOrWhitespace(bindingResult, "itemName", "required");
검증 오류 코드는 2가지로 나눌 수 있다.

가격에 Integer type 이 아닌, 다른 자료형이 입력되었을 때, 에러 로그를 출력한 결과이다.
codes → String[]
default message : default message [Failed to convert property value of type 'java.lang.String' to required type 'java.lang.Integer' for property 'price'; nested exception is java.lang.NumberFormatException: For input string: "price!!!”
앞에서의 errors.properties에 메시지를 추가하여 우선 순위에 따른 에러 메시지를 출력했다.
그것과 같은 방법으로, 위 4개의 코드이름과 같은 이름으로 내가 원하는 메시지를 추가하면 내가 설정한 메시지를 사용할 수 있다.
결과적으로 소스코드를 하나도 건들지 않고, 원하는 메시지를 단계별로 설정할 수 있다!
현재까지는 검증 로직이 Controller 에 붙어 있고, 이것의 비중이 매우 크다.
즉, Controller는 핵심 로직이 아닌 다른 기능을 많이 하는 중이다.
이것을 별도의 검증 역할 클래스로 분리하자!
ItemValidator.class ← implement Validator.interface
@Slf4j
@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;
//검증 로직
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() > 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);
}
}
}
}
ValidationItemControllerV2.class 추가 및 수정
private final ItemValidator itemValidator; //추가
@PostMapping("/add") //수정
public String addItemV5(@ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model) {
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}";
}
Tip💡
@RequiredArgsConstructor ↔ 생성자 + @Autowired
: 생성자가 하나일 때는 Autowired 생략 가능.
왜 이렇게 쓸까?? → 싱글톤 방식으로 사용하지 않으면, 객체를 계속 생성해서 사용해야 한다.
private final ItemValidator itemValidator;
...
itemValidator.validate(item, bindingResult); //싱글톤 방식
->
new NoSpringBeanItemValidator().validate(item, bindingResult);
///
public class NoSpringBeanItemValidator{
public void validate(Object target, Errors errors) {
Item item = (Item) target;
//동일한 검증 로직
...
}
}
}
가능은 하다만,,, 요청에 대한 객체가 반복되어 생성된다.
다음 단계에서 supports() , validate(Object target, Errors errors) 가 무엇인지 다음 단계에서 알아보자.
WebDataBinder : 스프링 MVC 내부에서 스프링의 파라미터 바인딩 역할을 해주고, 검증 기능도 내부에 포함한다.
이것을 사용하기 위해 애노테이션 @InitBinder 를 붙여준다.
그러면, 애노테이션 @InitBinder 붙은 매서드는 언제 호출이 되는가?
: 이 컨트롤러가 호출 될 때 마다 (해당 컨트롤러로 요청이 올 때 마다) 항상 불려져서 WebDataBinder 가 새로 만들어지고, 검증기가 내부에 들어있다.
이러한 이유로, 당연히 컨트롤러 내부에서만 사용 가능하다!
addItemV6
@PostMapping("/add") //실제 저장 로직 실행
public String addItemV6(@Validated @ModelAttribute Item item, BindingResult bindingResult, 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}";
}
→ 검증 하고자 하는 ModelAttribute 앞에 @Validated 애노테이션을 붙여준다.
@Validated
@Override
public boolean supports(Class<?> clazz) {
return Item.class.isAssignableFrom(clazz);
}참고 : @Validated는 스프링 전용 검증 애노테이션이고, @Valid 는 자바 표준 검증 애노테이션이다. 둘 다 사용 가능하긴 하다. 하지만, @Valid 를 사용하기 위해서는 build.gradle dependency 추가가 필요함.