스프링이 제공하는 검증 오류 처리방법을 알아보자.
@PostMapping("/add")
public String addItemV1(@ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model) {
//검증 로직
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("error={}", 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}";
}
BindingAttribute bindingResult 파라미터 위치는 @ModelAttribute Item item 다음에 와야 한다.
만약 바인딩 하는 객체DTO 에 필드에 오류가 있으면 FieldError 객체를 생성해서 BindingResult 에 담아두면 된다.
public FieldError(String ObjectName, String field, String defaultMeassage)
objectName : @ModelAttribute 이름field : 오류가 발생한 필드 이름defaultMessage: 오류 기본 메시지public ObjectError(String objectName, String defaultMessage)
objectName : @ModelAttribute 이름defaultMessage : 오류 기본 메시지
if (bindingResult.hasErrors()) {
// 다시 폼 페이지로 돌아갈 때 BindingResult는 모델에 자동 올라갑니다.
return "item/form";
}
여기서 BindingResult 의 있는 에러들은 모델에 자동으로 올라가서 타임리프에서 사용이 가능하다.
스프링에 제공하는 검증 오류를 보관하는 객체. 검증 오류가 발생하면 여기에 보관가능
BindingResult 가 있으면 @ModelAttribute 에 바인딩 시 오류가 발생해도 컨트롤러가 호출된다!
BindingResult 가 없으면 400오류가 발생하면서 컨트롤러가 호출되지 않는다.
BindingResult 가 있으면 오류 정보(FieldError를 BindingResult에 담아서 컨트롤러를 정상 호출)
이제부터 오류가 발생하는 경우 고객이 입력한 내용이 모두 사라지는데 이 문제를 해결해보자
if (!StringUtils.hasText(item.getItemName())) {
bindingResult.addError(new FieldError("item","itemName","상품 이름은 필수입니다."));
}
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)
여기서 rejectedValue 이 부분이 오류 발생시 사용자 입력 값을 저장하는 필드이다.
간단하게 생각하면 BindResult 에 new FieldError , new ObjectError 를 담는 객체라고 생각하자.
그리고 타임리프에서는 th:field:'*{price} 가 매우 똑똑하게 동작하는데, 정상 상황에서는 모델 객체의 값을 사용하지만, 오류가 발생하면 FieldError 에서 보관한 값을 사용해서 값을 출력한다.
타입오류로 바인딩에 실패하면 바인딩이 실패하면 validation 을 안하지 않나? 라고 생각할 수 있지만, 타입오류로 인해서 실패하더라고 FieldError 를 생성하면서 사용자가 입력한 값을 넣어준다. 그리고 BindingResult 에 담아서 컨트롤러를 호출한다. 따라서 타입 오류 같은 바인딩 실패시에도 사용자의 오류 메시지를 정상 출력할 수 있다.
BindingResult 가 제공하는 rejectValue() 를 사용하면 FieldError,ObjectError 를 직접 생성하지 않고, 깔끔하게 검증 오류를 다룰 수 있다.
@PostMapping("/add")
public String addItemV4(@ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model) {
//검증 로직
if (!StringUtils.hasText(item.getItemName())) {
bindingResult.rejectValue("itemName","required");
}
if (item.getPrice() == null || item.getPrice() < 1000 || item.getPrice() > 1000000) {
bindingResult.rejectValue("price","range", new Object[]{1000,1000000}, null);
}
if (item.getQuantity() == null || item.getQuantity() >= 9999) {
bindingResult.rejectValue("quantity", "max", new Object[]{9999}, 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);
}
}
//검증에 실패하면 다시 입력 폼으로
if (bindingResult.hasErrors()) {
log.info("error={}", 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}";
}
여기서 필드 오류를 담당하는 rejectValue 에 대해 알아보자.
void rejectValue(@Nullable String field, String errorCode,
@Nullable Object[] errorArgs, @Nullable String defaultMessage);
field : 오류 필드명errorCode : 오류 코드(이 오류 코드는 메시지에 등록된 코드가 아니다. 뒤에서 설명할 messageResolver를 위한 오류 코드이다.)errorArgs : 오류 메시지에서 {0} 을 치환하기 위한 값defaultMessage : 오류 메시지를 찾을 수 없을 때 사용하는 기본 메시지앞에서 BindingResult 는 어떤 객체를 대상으로 검증하는지 target 을 이미 알고 있다고했다.(그래서 BindingResult는 @ModelAttrtibute뒤에 와야하는것) 따라서 target(item) 에 대한 정보는 없어도 된다. 오류 필드명은 동일하게 price를 사용했다.
축약된 오류 코드
FieldError() 를 직접 다룰 때는 오류 코드를 range.item.price 와 같이 모두 입력했다. 그런데 rejectValue() 를 사용하고 부터는 오류 코드를 range 로 간단하게 입력했다. 그래도 오류 메시지를 잘 찾아서 출
력한다. 무언가 규칙이 있는 것 처럼 보인다. 이 부분을 이해하려면 MessageCodesResolver 를 이해해야 한다. 왜 이런식으로 오류 코드를 구성하는지 바로 다음에 자세히 알아보자.
rejectValue 는 MessageCodesResolver 를 사용해서 오류코드를 순서대로 생성한다. 기본 메세지 생성 규칙에 대해서 알아보자
<객체 오류인 경우>
객체 오류의 경우 다음 순서로 2가지 생성
1.: code + "." + object name 2.: code
예) 오류 코드: required, object name: item
1.: required.item
2.: required
<필드 오류인 경우>
필드 오류의 경우 다음 순서로 4가지 메시지 코드 생성
1.: code + "." + object name + "." + field
2.: code + "." + field
3.: code + "." + field type
4.: code
예) 오류 코드: typeMismatch, object name "user", field "age", field type: int
1. "typeMismatch.user.age"
2. "typeMismatch.age"
3. "typeMismatch.int"
4. "typeMismatch"
동작 방식
rejectValue() , reject() 는 내부에서 MessageCodesResolver 를 사용한다.
여기에서 메시지 코드들을 생성한다.
FieldError , ObjectError 의 생성자를 보면, 오류 코드를 하나가 아니라 여러 오류 코드를 가질 수 있다. MessageCodesResolver 를 통해서 생성된 순서대로 오류 코드를 보관한다.
이 부분을 BindingResult 의 로그를 통해서 확인해보자.
codes [range.item.price, range.price, range.java.lang.Integer, range]
FieldError
rejectValue("itemName", "required")
다음 4가지 오류 코드를 자동으로 생성
required.item.itemName required.itemName required.java.lang.String requiredObjectError
reject("totalPriceMin")
다음 2가지 오류 코드를 자동으로 생성
totalPriceMin.item totalPriceMin오류 메시지 출력
타임리프 화면을 렌더링 할 때 th:errors 가 실행된다. 만약 이때 오류가 있다면 생성된 오류 메시지 코드를 순서대 로 돌아가면서 메시지를 찾는다. 그리고 없으면 디폴트 메시지를 출력한다.

MessageSource 를 직접 사용하면 가능하다. 왜냐하면 메세지를 순서대로 찾아서 반환하는 기능을 하는 객체는 MessageSource 가 하는것이기 때문
API 로 예외처리를 하고 싶은경우 아래 코드를 참고해서 사용하자.
@RestControllerAdvice
public class ApiExceptionHandler {
private final MessageSource messageSource;
public ApiExceptionHandler(MessageSource messageSource) {
this.messageSource = messageSource;
}
@ExceptionHandler(BindException.class)
public ResponseEntity<ApiError> handleBind(BindException ex, Locale locale) {
List<ApiError.Field> fieldErrors = ex.getBindingResult()
.getFieldErrors().stream()
.map(fe -> {
// fe는 MessageSourceResolvable 인터페이스 구현체이므로
String resolved = messageSource.getMessage(fe, locale);
return new ApiError.Field(
fe.getField(),
fe.getCodes(), // 원한다면 코드 배열도 같이 보낼 수 있고
resolved // 실제 치환된 메시지
);
})
.toList();
ApiError body = new ApiError("VALIDATION_FAILED", fieldErrors);
return ResponseEntity.badRequest().body(body);
}
// 간단한 DTO
public static class ApiError {
public String error;
public List<Field> fields;
public ApiError(String error, List<Field> fields) {
this.error = error;
this.fields = fields;
}
public static class Field {
public String field;
public String[] codes;
public String message;
public Field(String field, String[] codes, String message) {
this.field = field;
this.codes = codes;
this.message = message;
}
}
}
}
MessageCodesResolver 는 required.item.itemName 처럼 구체적인 것을 먼저 만들어주고, required 처럼 덜 구체적인 것을 가장 나중에 만든다.
이렇게 하면 앞서 말한 것 처럼 메시지와 관련된 공통 전략을 편리하게 도입할 수 있다.
왜 이렇게 복잡하게 사용하는가?
모든 오류 코드에 대해서 메시지를 각각 다 정의하면 개발자 입장에서 관리하기 너무 힘들다.
크게 중요하지 않은 메시지는 범용성 있는 requried 같은 메시지로 끝내고, 정말 중요한 메시지는 꼭 필요할 때 구체 적으로 적어서 사용하는 방식이 더 효과적이다.
<errors.properties>
#required.item.itemName=상품 이름은 필수입니다.
#range.item.price=가격은 {0} ~ {1} 까지 허용합니다.
#max.item.quantity=수량은 최대 {0} 까지 허용합니다.
#totalPriceMin=가격 * 수량의 합은 {0}원 이상이어야 합니다. 현재 값 = {1}
#==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} 까지의 문자를 허용합니다.
itemName 의 경우 required 검증 오류 메시지가 발생하면 다음 코드 순서대로 메시지가 생성된다.
1. required.item.itemName
2. required.itemName
3. required.java.lang.String
4. required
이 순서를 누가 만들어 준다? MessageCodesResolver 가 이렇게 구체적인것부터 덜 구체적인 것을 가장 나중에 만들어준다!!
정리
1. rejectValue() 호출
2. MessageCodesResolver 를 사용해서 검증 오류 코드로 메시지 코드들을 생성
3. new FieldError() 를 생성하면서 메시지 코드들을 보관
4. th:erros 에서 메시지 코드들로 메시지를 순서대로 메시지에서 찾고, 노출(타임리프에서 MessageSource 사용!)
검증 오류 코드는 다음과 같이 2가지로 나눌 수 있다.
rejectValue() 를 직접 호출price 필드에 문자 "A"를 입력해보자.
로그를 확인해보면 BindingResult 에 FieldError 가 담겨있고, 다음과 같은 메시지 코드들이 생성된 것을 확인 할 수 있다.
codes[typeMismatch.item.price,typeMismatch.price,typeMismatch.java.lang.Integer,ty peMismatch]
typeMismatch.item.price
typeMismatch.price
typeMismatch.java.lang.Integer
typeMismatch
스프링은 타입 오류가 발생하면 typeMismatch 라는 오류 코드를 사용한다. 이 오류 코드가 MessageCodesResolver 를 통하면서 4가지 메시지 코드가 생성된 것이다.
errors.properties 에 메시지 코드가 없으면 스프링이 생성한 기본 메세지 출력
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: "A"