@PostMapping("/add")
public String addItem(@ModelAttribute Item item, RedirectAttributes redirectAttributes, Model model) {
//검증 오류 결과 보관
Map<String, String> errors = new HashMap<>();
// 검증 로직
if (!StringUtils.hasText(item.getItemName())) {
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 (!errors.isEmpty()) {
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}";
}
✔️ 위 방법의 문제점
@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}";
}
❗ 주의 : BidingResult 파라미터 위치는 반드시 @ModelAttribute 다음에 와야한다.
public FieldError(String objectName, String field, String defaultMessage) {}
public ObjectError(String objectName, String defaultMessage) {}
✔️ Thymeleaf 템플릿은 BindingResult를 활용해서 편리하게 검증 오류를 표현하도록 기능을 제공한다.
<!-- 글로벌 오류 처리 -->
<div th:if="${#fields.hasGlobalErrors()}">
<p class="field-error" th:each="err : ${#fields.globalErrors()}" th:text="${err}">전체 오류 메시지</p>
</div>
<!-- 필드 오류 처리 -->
<input type="text" id="itemName" th:field="*{itemName}"
th:errorclass="field-error" class="form-control" placeholder="이름을 입력하세요">
<div class="field-error" th:errors="*{itemName}">
상품명 오류
</div>
Validator
사용org.springframework.validation.Errors
org.springframework.validation.BindingResult
BindingResult
는 인터페이스이고 Errors
인터페이스를 상속한다.BeanPropertyBindingResult
인데 BindingResult 대신에 Errors를 사용해도 된다.public FieldError(String objectName, String field, @Nullable Object rejectedValue, boolean bindingFailure,
@Nullable String[] codes, @Nullable Object[] arguments, @Nullable String defaultMessage)
objectName
: 오류가 발생한 객체 이름field
: 오류 필드rejectedValue
: 사용자가 입력한 값(거절된 값)bindingFailure
: 타입 오류 같은 바인딩 실패인지, 검증 실패인지 구분 값codes
: 메시지 코드arguments
: 메시지에서 사용하는 인자defaultMessage
: 기본 오류 메시지📝 FieldError 는 오류 발생 시 rejectedValue
필드에 사용자의 입력 값을 보관하는 기능을 제공한다.
📝 Thymeleaf에서 th:field
는 오류가 없는 경우는 model 객체의 값을 사용하고, 오류가 발생하면 FieldError 에서 보관한 값을 사용하여 값을 출력한다.
💡 ObjectError도 FieldError와 유사하게 추가 생성자를 제공한다.
✔️ FieldError의 codes
, arguments
필드는 오류 발생 시 코드로 메시지를 찾기 위해 사용된다.
✔️ application.yaml
와 errors.yaml
파일에 아래 내용을 추가하여 에러 메시지를 설정 파일에서 가져와 공통적으로 사용한다.
#application.yaml
spring:
messages:
basename: messages,errors
#errors.yaml
required:
item:
itemName: 상품 이름은 필수입니다.
range:
item:
price: 가격은 {0} ~ {1} 까지 허용합니다.
max:
item:
quantity: 수량은 최대 {0} 까지 허용합니다.
totalPriceMin: 가격 * 수량의 합은 {0}원 이상이어야 합니다. 현재 값 = {1}
✔️ 위 설정 파일 사용 예제 코드
//range.item.price=가격은 {0} ~ {1} 까지 허용합니다.
bindingResult.addError(new FieldError("item", "price", item.getPrice(),
false, new String[]{"range.item.price"}, new Object[]{1000, 1000000}, null));
✔️ 다루기 번거로운 FieldError 와 ObjectError를 사용하지 않고 BindingResult에서 제공하는 rejectValue()
, reject()
를 사용하면 더 깔끔하게 검증 오류를 처리할 수 있다.
if (item.getPrice() == null || item.getPrice() < 1000 || item.getPrice() > 1000000) {
bindingResult.rejectValue("price", "range", new Object[]{1000, 1000000}, null);
}
✔️ rejectValue()
void rejectValue(@Nullable String field, String errorCode,
@Nullable Object[] errorArgs, @Nullable String defaultMessage);
✔️ reject()
void reject(String errorCode, @Nullable Object[] errorArgs, @Nullable String defaultMessage);
range.item.price
와 같이 모두 입력해서 사용했는데 rejectValue()는 앞에 range
만 사용해도 오류 코드를 잘 찾아서 출력한다.✔️ 스프링은 아래와 같이 코드가 있을 때 코드의 세밀함으로 우선순위를 정해서 더 세밀한 코드의 메시지를 우선적으로 먼저 사용한다.
#Level1
required.item.itemName: 상품 이름은 필수 입니다.
#Level2
required: 필수 값 입니다.
MessageCodesResolver
는 인터페이스 이고, DefaultMessageCodesResolver
가 기본 구현체이다.ObjectError
, FieldError
와 같이 사용한다.객체 오류
code + "." + object name
code
required.item
required
필드 오류
code + "." + object name + "." + field
code + "." + field
code + "." + field type
code
"typeMismatch.user.age"
"typeMismatch.age"
"typeMismatch.int"
"typeMismatch"
DefaultMessageCodesResolver
를 사용하여 메시지 코드들을 생성한다.✔️ 위에서 학습한 내용들을 가지고 실제 오류 코드 설정을 작성한다.
#==ObjectError==
#Level1
totalPriceMin.item: 상품의 가격 * 수량의 합은 {0}원 이상이어야 합니다. 현재 값 = {1}
#Level2 - 생략
totalPriceMin: 전체 가격은 {0}원 이상이어야 합니다. 현재 값 = {1}
#==FieldError==
#Level1
required.item.itemName: 상품 이름은 필수입니다.
range.item.price: 가격은 {0} ~ {1} 까지 허용합니다.
max.item.quantity: 수량은 최대 {0} 까지 허용합니다.
#Level2 - 생략
#Level3
required.java.lang.String: 필수 문자입니다.
required.java.lang.Integer: 필수 숫자입니다.
min.java.lang.String: {0} 이상의 문자를 입력해주세요.
min.java.lang.Integer: {0} 이상의 숫자를 입력해주세요.
range.java.lang.String: {0} ~ {1} 까지의 문자를 입력해주세요.
range.java.lang.Integer: {0} ~ {1} 까지의 숫자를 입력해주세요.
max.java.lang.String: {0} 까지의 문자를 허용합니다.
max.java.lang.Integer: {0} 까지의 숫자를 허용합니다.
#Level4
required: 필수 값 입니다.
min: {0} 이상이어야 합니다.
range: {0} ~ {1} 범위를 허용합니다.
max: {0} 까지 허용합니다.
if (!StringUtils.hasText(item.getItemName())) {
bindingResult.rejectValue("itemName", "required", "기본: 상품 이름은 필수입니다.");
}
ValidationUtils.rejectIfEmptyOrWhitespace(bindingResult, "itemName", "required");
✔️ 위에서는 직접 개발자가 설정하여 작성한 검증 오류 코드(rejectValue() 직접 호출)였다면, 이번에는 스프링이 직접 만드는 오류 메시지를 처리해본다.
✔️ 스프링이 직접 검증 오류에 추가한 경우는 주로 타입 정보가 맞지 않을 때가 대표적이다.
만약 필드에 타입을 잘못 입력하면 스프링에서는 아래와 같은 메시지 코드를 생성한다.
typeMismatch.item.price
typeMismatch.price
typeMismatch.java.lang.Integer
typeMismatch
위 메시지 코드에 대해 설정해두지 않았다면 기본 메시지가 출력된다.
Failed to convert property value of type java.lang.String to required type java.lang.Integer for property price; For input string: "a"
아래와 같이 error.properties에 추가하면 된다.
typeMismatch.java.lang.Integer: 숫자를 입력해주세요.
typeMismatch: 타입 오류입니다.
✔️ 복잡한 검증 로직을 별도로 분리해본다.
supports() {}
: 해당 검증기를 지원하는 여부 확인validate(Object target, Errors errors)
: 검증 대상 객체와 BindingResultpublic interface Validator {
boolean supports(Class<?> clazz);
void validate(Object target, Errors errors);
}
@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() > 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 구현체를 직접 불러서 사용하지 않고 스프링 기능을 통해 자동으로 Validator를 사용하도록 설정해본다.
@InitBinder
public void init(WebDataBinder webDataBinder) {
log.info("init binder {}", webDataBinder);
webDataBinder.addValidators(itemValidator);
}
@Validated
어노테이션을 사용하면 WebDataBinder에 등록한 Validator를 찾아서 실행한다.@PostMapping("/add")
public String addItemV6(@Validated @ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes) {
if (bindingResult.hasErrors()) {
log.info("errors={}", bindingResult);
return "validation/v2/addForm";
}
...
}
✔️ 글로벌 설정 - 모든 컨트롤러에 다 적용
@SpringBootApplication
public class ItemServiceApplication implements WebMvcConfigurer {
public static void main(String[] args) {
SpringApplication.run(ItemServiceApplication.class, args);
}
@Override
public Validator getValidator() {
return new ItemValidator();
}
}
💡 참고
@Validated
, @Valid
둘 다 사용 가능하다.implementation 'org.springframework.boot:spring-boot-starter-validation'