Spring-Validation

희운·2025년 5월 20일

SpringBoot

목록 보기
4/10

BindingResult

스프링이 제공하는 검증 오류 처리방법을 알아보자.



	@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 에 담아두면 된다.

FieldError 생성자 요약

public FieldError(String ObjectName, String field, String defaultMeassage)
  • objectName : @ModelAttribute 이름
  • field : 오류가 발생한 필드 이름
  • defaultMessage: 오류 기본 메시지

ObjectError 생성자 요약

public ObjectError(String objectName, String defaultMessage)
  • objectName : @ModelAttribute 이름
  • defaultMessage : 오류 기본 메시지

여기서 중요한것이 BindingResult 의 에러들은 뷰 템플릿에서 바로 사용이 가능한 것이다.(BindingResult 는 Model 에 자동으로 포함되기 때문에)


 if (bindingResult.hasErrors()) {
        // 다시 폼 페이지로 돌아갈 때 BindingResult는 모델에 자동 올라갑니다.
        return "item/form";
    }
    

여기서 BindingResult 의 있는 에러들은 모델에 자동으로 올라가서 타임리프에서 사용이 가능하다.

BindingResult2

  • 스프링에 제공하는 검증 오류를 보관하는 객체. 검증 오류가 발생하면 여기에 보관가능

  • BindingResult 가 있으면 @ModelAttribute 에 바인딩 시 오류가 발생해도 컨트롤러가 호출된다!

  • BindingResult 가 없으면 400오류가 발생하면서 컨트롤러가 호출되지 않는다.

  • BindingResult 가 있으면 오류 정보(FieldErrorBindingResult에 담아서 컨트롤러를 정상 호출)


이제부터 오류가 발생하는 경우 고객이 입력한 내용이 모두 사라지는데 이 문제를 해결해보자


if (!StringUtils.hasText(item.getItemName())) {
            bindingResult.addError(new FieldError("item","itemName","상품 이름은 필수입니다."));
 }
  • FieldError 는 두가지 생성자를 가지고있다.

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(), reject()

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 를 이해해야 한다. 왜 이런식으로 오류 코드를 구성하는지 바로 다음에 자세히 알아보자.

  • 간단하게 생각해보면 rejectValueMessageCodesResolver 를 사용해서 오류코드를 순서대로 생성한다.

기본 메세지 생성 규칙에 대해서 알아보자

<객체 오류인 경우>

객체 오류의 경우 다음 순서로 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
  • required

ObjectError
reject("totalPriceMin")

다음 2가지 오류 코드를 자동으로 생성

  • totalPriceMin.item
  • totalPriceMin

오류 메시지 출력
타임리프 화면을 렌더링 할 때 th:errors 가 실행된다. 만약 이때 오류가 있다면 생성된 오류 메시지 코드를 순서대 로 돌아가면서 메시지를 찾는다. 그리고 없으면 디폴트 메시지를 출력한다.

그럼 API 응답에서는 순서대로 돌아가면서 메시지를 찾을 수 있나?

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;
            }
        }
    }
}


오류 코드 관리 전략

MessageCodesResolverrequired.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"를 입력해보자.
로그를 확인해보면 BindingResultFieldError 가 담겨있고, 다음과 같은 메시지 코드들이 생성된 것을 확인 할 수 있다.
codes[typeMismatch.item.price,typeMismatch.price,typeMismatch.java.lang.Integer,ty peMismatch]

다음과 같이 4가지 메시지 코드가 입력되어 있다.

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"
profile
기록하는 공간

0개의 댓글