Backend/Spring

BindingResult(FieldError, ObjectError), MessageCodesResolver를 통한 오류 코드 생성, Bean Validation

olsohee 2024. 1. 11. 12:25

BindingResult

BindingResult는 스프링이 제공하는 검증 오류를 보관하는 객체이다. 검증 오류가 발생하면 BindingResult 객체에 담아두면 된다.

@PostMapping("/add")
public String addItem(@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() >= 10000) {
        bindingResult.addError(new FieldError("item", "quantity", "수량은 최대 9,999 까지 허용합니다."));
    }

    // 특정 필드 예외가 아닌 전체 예외
    if (item.getPrice() != null && item.getQuantity() != null) {
        if (item.getPrice() * item.getQuantity() < 10000) {
            bindingResult.addError(new ObjectError("item", "가격 * 수량의 합은 10,000원 이상이어야 합니다. 현재 값 = " + resultPrice));
        } 
    }

    ...
}
  • BindingResult는 검증할 대상 바로 뒤에 와야 한다. 
  • BindingResult는 Model에 자동으로 포함되기 때문에 별도로 Model에 담아주는 코드를 작성하지 않아도 된다.
  • BindingResult가 있으면 @ModelAttribute 객체에 데이터 바인딩시 오류가 발생해도 컨트롤러가 정상 호출된다. 
    • BindingResult가 없으면 ➡️ 400 오류가 발생해서 컨트롤러가 호출되지 않는다.
    • BindingResult가 있으면 ➡️ 오류 정보(FieldError)가 BindingResult에 보관되고 컨트롤러가 정상 호출된다.
  • 필드에 오류가 있으면 FieldError를 생성해서 BindingResult에 담아두면 된다.
  • 특정 필드를 넘어서는 오류가 있으면 ObjectError 객체를 생성해서 BindingResult에 담아두면 된다. 
  • 개발자가 별도로 검증을 수행하여 발생하는 검증 오류 외에도, 타입 오류로 인해 바인딩에 실패하면 스프링은 자동으로 FieldError를 생성하여 BindingResult에 담아둔다. 그리고 컨트롤러를 정상 호출한다.

FieldError, ObjectError

필드에 오류가 있으면 FieldError를 생성해서 BindingResult에 담아두면 된다.

public class FieldError extends ObjectError {

	private final String field;

	@Nullable
	private final Object rejectedValue;

	private final boolean bindingFailure;

	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: 타입 오류 같은 바인딩 실패인지, 검증 실패인지 구분하는 값
  • codes: 메시지 코드
  • arguments: 메시지에서 사용하는 인자
  • defaultMessage: 기본 오류 메시지

특정 필드를 넘어서는 오류가 있으면 ObjectError 객체를 생성해서 BindingResult에 담아두면 된다. 

public class ObjectError extends DefaultMessageSourceResolvable {

	private final String objectName;

	@Nullable
	private transient Object source;

	public ObjectError(String objectName, @Nullable String defaultMessage) {}
    
	public ObjectError(String objectName, @Nullable String[] codes, 
    				@Nullable Object[] arguments, @Nullable String defaultMessage) {}
    
	...
}

FieldError, ObjectError의 오류 메시지

필드 오류나 글로벌 오류가 발생하면 FieldError나 ObjectError 객체를 생성해서 BindingResult에 담아두게 된다. 그리고 FieldError, ObjectError 객체를 생성할 때 생성자로 codes, arguments를 설정할 수 있다. 이것은 오류 발생시 오류 코드로 메시지를 찾기 위해 사용된다. 

예제

다음과 같이 FiedlError 객체를 생성할 때 생성자로 codes, arguments를 설정할 수 있다.

new FieldError("item", "price", item.getPrice(), false, new String[]{"range.item.price"}, new Object[]{1000, 1000000}
  • codes(range.item.price): 메시지 코드를 지정한다. 이때 메시지 코드는 하나가 아니라 배열로 여러 값을 전달할 수 있는데, 순서대로 매칭해서 처음 매칭되는 메시지가 사용된다. 
  • arguments(1000, 1000000): 메시지 코드에 해당하는 오류 메시지에 치환될 값을 전달한다.

그러면 메시지 파일에서 codes로 지정한 오류 코드를 찾고, arguments를 통해 전달한 값들로 치환해서 에러 메시지가 생성된다.

// errors.properties
required.item.itemName=상품 이름은 필수입니다.
range.item.price=가격은 {0} ~ {1} 까지 허용합니다.
max.item.quantity=수량은 최대 {0} 까지 허용합니다.
totalPriceMin=가격 * 수량의 합은 {0}원 이상이어야 합니다. 현재 값 = {1}

 

그러나 이 방법은 FieldError와 ObjectError 객체를 생성할 때마다 codes와 arguments를 지정해야 하는 번거로움이 있다. 따라서 BindingResult가 제공하는 rejectValue(), reject()를 사용하면 FieldError, ObjectError를 직접 생성하지 않아도 되고 오류 메시지도 자동으로 생성해준다.

rejectValue(), reject()

BindingResult는 검증 대상의 바로 뒤에 위치해야 한다. 즉 BindingResult는 어떤 객체를 대상으로 검증할지 알고 있다. 따라서 rejectValue(), reject()를 사용하면 굳이 오류가 발생한 객체 이름(objectName)을 적어주지 않아도 된다. 

void rejectValue(@Nullable String field, String errorCode,
                 @Nullable Object[] errorArgs, @Nullable String defaultMessage);
  • field: 오류 필드명
  • errorCode: 오류 코드
  • errorArgs: 오류 메시지에서 {0}을 치환하기 위한 값
  • defaultMessage: 기본 오류 메시지(오류 메시지를 찾을 수 없을 때 사용하는 기본 메시지)
void reject(String errorCode, @Nullable Object[] errorArgs, @Nullable String defaultMessage);

rejectValue(), reject()의 오류 메시지

 예제

@PostMapping("/add")
public String addItem(@ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes) {

    // 필드 예외
    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() >= 10000) {
        bindingResult.rejectValue("quantity", "max", new Object[]{9999}, null);
    }

    // 특정 필드 예외가 아닌 전체 예외
    if (item.getPrice() != null && item.getQuantity() != null) {
        if (item.getPrice() * item.getQuantity() < 10000) {
             bindingResult.reject("totalPriceMin", new Object[]{10000, resultPrice}, null);
        } 
    }

    ...
}

 

FieldError()를 직접 다룰 때는 오류 코드를 "range.item.price"와 같이 모두 입력했다. 그런데 rejectValue()를 사용하고 부터는 오류 코드를 range로 간단하게 입력했다. 그래도 오류 메시지를 errors.properties에서 잘 찾아서 출력한다. 이는 스프링의 MessageCodesResolver 덕분이다. 

MessageCodesResolver

MessageCodesResolver는 스프링이 제공하는 인터페이스이고, DefaultMessageCodesResolver가 기본 구현체이다. DefaultMessageCodesResolver의 기본 메시지 생성 규칙은 다음과 같다.

  • 객체 오류의 경우 다음 순서로 오류 메시지를 생성한다.
    1. code + "." + object name
    2. code
  • 필드 오류의 경우 다음 순서로 오류 메시지를 생성한다.
    1. code + "." + object name + "." + field
    2. code + "." + field
    3. code + "." + field type
    4.  code 
  •  ex, 객체 오류(오류 코드: required, object name: item)
    1. required.item
    2. required
  • ex, 필드 오류(오류 코드: typeMismatch, object name: user, field: age, field type: int)
    1. typeMismatch.user.age
    2. typeMismatch.age
    3. typeMismatch.int
    4. typeMismatch
 

오류 코드를 생성하는 방법은 2가지이다.

  1. 앞서 rejectValue(), reject()를 직접 사용하는 예제와 같이 개발자가 rejectValue(), reject()를 호출해서 직접 오류 코드를 설정하는 방법
    • rejectValue(), reject()는 내부에서 MessageCodesResolver를 사용해서 오류 메시지를 생성한다. 
    • FieldError, ObjectError의 생성자를 보면 여러 개의 오류 코드를 가질 수 있었는데, MessageCodesResolver를 통해서 생성된 오류 코드를 순서대로 보관하는 것이다.
    • 즉 rejectValue(), reject()를 사용하면 FieldError, ObjectError를 직접 생성하지 않아도 되고,  MessageCodesResolver가 rejectValue(), reject() 호출시 파라미터 값을 기반으로 오류 코드를 생성해서 FieldError, ObjectError에 담아준다.
  2. 스프링이 대신 오류 코드를 생성하는 방법
    • 예를 들어 타입 오류가 발생하면 스프링은 typeMismatch라는 오류 코드를 사용해서 MessageCodesResolver를 통해 오류 메시지 코드를 생성한다. 
    • ex, 오류 코드: typeMismatch, object name: user, field: age, field type: int
      1. typeMismatch.user.age
      2. typeMismatch.age
      3. typeMismatch.int
      4. typeMismatch

BeanValidation

특정 필드에 대한 검증 로직은 대부분 빈 값인지 아닌지, 특정 크기를 넘는지 아닌지와 같이 일반적인 로직이다. 이런 검증 로직을 모든 프로젝트에 적용할 수 있게 공통화하고 표준화한 것이 BeanValidation이다. BeanValidation은 특정 구현체가 아니라 기술 표준이다. 쉽게 얘기해서 검증 어노테이션과 여러 인터페이스의 모음이다. BeanValidation을 구현한 기술 중에는 일반적으로 하이버네이트 Validator를 사용한다(이름이 하이버네이트일 뿐, ORM과 관련이 없다).

 

BeanValidation을 사용하려면 다음과 같이 의존관계를 추가해야 한다.

implementation 'org.springframework.boot:spring-boot-starter-validation'

 

추가된 라이브러리는 다음과 같다.

  • jakarta.validation-api: Bean Validation 인터페이스
  • hibernate-validator: 구현체

위와 같이 의존관계를 추가하면 스프링 부트가 Bean Validation을 인지하고 스프링에 통합한다. 그리고 LocalValidatorFactoryBean을 글로벌 Validator로 등록한다. 이 Validator는 @NotNull 같은 검증 어노테이션을 보고 검증을 수행한다. 개발자는 @Valid, @Validated만 적용하면 된다. 그러면 스프링이 자동으로 등록한 Validator가 검증을 수행하고, 검증 오류가 발생하면 FieldError 또는 ObjectError 객체를 생성해서 BindingResult에 담아준다.

 

참고로 @Valid와 @Validated 둘 다 사용 가능한데, @Valid는 자바 표준 검증 어노테이션이고 @Validated는 스프링 전용 검증 어노테이션이다. @Validated는 내부에 groups라는 기능을 추가로 갖는다.

BeanValidation의 오류 메시지

BeanValidation은 발생하는 검증 오류와 그에 따른 오류 코드를 앞서 설명한 MessageCodesResolver를 기반으로 생성한다. 즉 BeanValidation 어노테이션에 따른 생성되는 오류 코드는 다음과 같다.

  • @NotBlank
    1. NotBlank.item.itemName
    2. NotBlank.itemName
    3. NotBlank.java.lang.String
    4. NotBlank
  • @Range
    1. Range.item.price
    2. Range.price
    3. Range.java.lang.Integer
    4. Range

즉 BeanValidation은 검증 오류가 발생하면 다음 순서로 오류 메시지를 찾는다.

  1. MessageCodesResolver를 통해 생성된 오류 코드 순서대로 messageSource에서 메시지 찾기
  2. messageSource에 오류 코드에 대한 메시지가 없으면 어노테이션의 message 속성 사용 (ex, @NotBlank(message = "공백 X"))
  3. 라이브러리가 제공하는 기본 값 사용 (ex, 공백일 수 없습니다.)

BeanValidation의 글로벌 오류

BeanValidation에서 특정 필드 오류가 아닌 객체에 대한 글로벌 오류는 어떻게 처리할까? @ScriptAssert()를 사용하는 방법이 있지만, 사용이 복잡하며 검증 기능이 특정 객체의 범위를 넘어서는 경우에는 사용이 어렵다. 따라서 글로벌 오류의 경우에는 해당 부분만 자바 코드로 작성하는 것이 권장된다.

@PostMapping("/add")
public String addItem(@Validated @ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes) {

    // 특정 필드 예외가 아닌 전체 예외
    if (item.getPrice() != null && item.getQuantity() != null) {
        if (item.getPrice() * item.getQuantity() < 10000) {
             bindingResult.reject("totalPriceMin", new Object[]{10000, resultPrice}, null);
        } 
    }

    ...
}

@RequestBody(HttpMessageConverter)에 BeanValidation 적용

BeanValidation은 앞서 살펴본 것과 같이 @ModelAttribute에도 적용할 수 있지만, @RequestBody에도 적용할 수 있다(@ModelAttributeHTTP 요청 파라미터(URL 쿼리 스트링, POST Form)를 다룰 때, @RequestBodyHTTP Body의 데이터를 객체로 변환할 때 사).

 

컨트롤러에서 파라미터로 @RequestBody를 받으면, RequestMappingHandlerAdapter는 HttpMessageConverter를 사용해서 HTTP 요청 메시지 데이터를 읽어서 파라미터로 넘겨줄 객체(@RequestBody)를 생성한다. 

 

그런데 BeanValidation으로 인한 검증에 실패하면, HttpMessageConverter는 HTTP 요청 메시지 바디의 JSON 데이터를 @RequestBody 어노테이션이 붙은 객체(ex, dto)로 변환하는데 실패한다. 따라서 컨트롤러가 호출되지 않고 예외(MethodArgumentNotValidException)가 발생한다. 

 

@ModelAttribute와 비교하면 다음과 같다.

  • @ModelAttribute는 필드 단위로 정교하게 바인딩이 적용된다. 특정 필드가 바인딩되지 않아도 나머지 필드는 정상 바인딩되고, Validator를 사용한 검증도 적용할 수 있다.
  • @RequestBodyHttpMessageConverter 단계에서 JSON 데이터를 객체로 변경하지 못하면 컨트롤러가 호출되지 않고 예외가 발생한다. 따라서 Validator도 적용할 수 없다.

Reference

  • 인프런, 스프링 MVC 1편, 김영한