[Spring] 필드/글로벌 검증하기 (BindingResult, MessageResolver, @Validated)

728x90

 

검증 직접 처리

...
Map<String, String> errors = new HashMap<>();

// 필드 오류
if (item.getQuantity() == null || item.getQuantity() >= 10000) {
  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);
  }
}
...

이렇게 필드 오류와 글로벌 오류가 발생하면 errors 에 담아, 

errors 에 값이 있으면 다시 입력 폼이 있는 뷰 템플릿으로 보낸다.

 

...
<!-- 필드 오류 -->
<input type="text" th:classappend="${errors?.containsKey('quantity')} ? 'field-error' : _"
       class="form-control" placeholder="수량을 입력하세요">
<div class="field-error" th:if="${errors?.containsKey('quantity')}" 
     th:text="${errors['quantity']}">수량 필드</div>

<!-- 글로벌 오류 -->
<div th:if="${errors?.containsKey('globalError')}">
  <p class="field-error" th:text="${errors['globalError']}">글로벌 오류 메시지</p>
</div>
...

뷰 템플릿에서 errors 를 찾아서 값을 불러온다.

 

문제점

- 뷰 템플릿에서의 중복 처리가 많다.

- 타입 오류 처리 불가

- 타입 오류 시 고객이 입력한 값도 별도로 관리되어야 함

 

BindingResult

객체의 값이 잘못되면 BindingResult 에 담는다.

이때 BindingResult 는 @ModelAttribute 파라미터 다음에 위치해야 한다.

// 필드 오류
if (item.getQuantity() == null || item.getQuantity() >= 10000) {
  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));
  }
}

 

필드 오류의 경우

new FieldError(`objectName`, `field`, `defaultMessage`) 를 생성하고,

글로벌 오류의 경우

new ObjectError(`objectName`,  `defaultMessage`) 를 생성하여 `bindingResult` 에 담는다.

그리고 타입 오류가 발생해도 오류 정보를 `bindingResult` 에 담아 컨트롤러를 정상 호출한다.

 

`objectName` : @ModelAttribute 이름

`field` : 오류가 발생한 필드 이름

`defaultMessage` : 오류 기본 메시지

 

이 때 `bindingResult` 는 model 에 자동으로 포함된다.

 

...
<!-- 필드 오류 -->
<input type="text" th:errorclass="field-error" 
       class="form-control" placeholder="수량을 입력하세요">
<div class="field-error" th:errors="*{itemName}">수량 필드 오류</div>

<!-- 글로벌 오류 -->
<div th:if="${#fields.hasGlobalErrors()}">
  <p class="field-error" th:each="err : ${#fields.glabalErrors()}" 
     th:text="${err}">글로벌 오류</p>
</div>
...

bindingResult 를 사용했기 때문에

`#fields`, `th:errors`, `th:errorclass` 를 사용하여 간편하게 출력할 수 있다.

 

BindingResult 와 Errors

BindingResult 는 인터페이스이고, Errors 인터페이스를 상속받고 있다.

구현체 BeanPropertyBindingResult 는 둘 다 구현하고 있으므로, 모두 사용할 수 있다.

하지만 Errors 는 단순 오류 저장/조회 기능만 제공하므로,

추가적인 기능들을 제공하는 BindingResult 를 많이 사용한다.

 

문제점

- 오류 발생시 입력한 값이 유지되지 않는다.

 

FieldError, ObjectError

FieldError 는 두 가지 생성자를 제공한다.

new FieldError(`objectName`, `field`, `defualtMessage`) (위에서 사용)

new FieldError(`objectName`, `field`, `rejectedValue`, `bindingFailure`, `codes`, `arguments`, `defualtMessage`)

(ObjectError 도 유사하게 두 가지 생성자를 제공한다.)

 

`objectName` : @ModelAttribute 이름

`field` : 오류가 발생한 필드 이름

`rejectedValue` : 사용자가 입력한 값(거절된 값)

`bindingFailure` : 바인딩 실패인지 검증 실패인지

`codes` : 메시지 코드

`arguments` : 메시지 코드의 매개변수

`defaultMessage` : 오류 기본 메시지

 

> 참고 <

바인딩 실패 : 타입이 맞지 않는 경우

검증 실패 : 비즈니스 범위에 맞지 않는 경우


동작 흐름

타입 오류로 인한 바인딩이 실패하면,

1. bindingResult 가 FiedError 를 생성하여 `rejectedValue` 에 해당 값을 담아둔다.

2. 컨트롤러가 호출되어 @ModelAttribute 의 객체에 null 이 입력된다.

3. 타임리프의 th:field 는 오류가 발생하면 FieldError 에서 보관한 `rejectedValue` 를 사용하여 출력한다.

 

max.item.quantity=수량은 최대 {0} 까지 허용합니다.
totalPriceMin=가격 * 수량의 합은 {0}원 이상이어야 합니다. 현재 값 = {1}
// 필드 오류
if (item.getQuantity() == null || item.getQuantity() >= 10000) {
  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));
  }
}

메시지/국제화를 통한 MessageSource 를 찾아 메시지를 조회하여 오류 메시지를 체계적으로 사용할 수 있다.

 

문제점

- FieldError, ObjectError 너무 복잡

 

rejectValue( ), reject( )

BindingResult 는 검증할 객체 target 바로 다음에 오기 때문에, 검증할 객체의 정보를 이미 알고 있다.

따라서 FieldError, ObjectError 를 직접 생성하지 않고,

BindingResult 의 `rejectValue()`, `reject()` 를 사용하면 깔끔하게 사용할 수 있다.

 

필드 오류

bindingResult.rejectValue(`field`, `errorCode`, `errorArgs`, `defaultMessage`)

글로벌 오류

bindingResult.reject(`errorCode`, `errorArgs`, `defaultMessage`)

 

`field` : 오류가 발생한 필드 이름

`errorCode` : 오류 코드 (MessageCodeResolver 를 위한 오류 코드)

`errorArgs` : 메시지의 매개변수

`defaultMessage` : 오류 기본 메시지

 

max.item.quantity=수량은 최대 {0} 까지 허용합니다.
totalPriceMin=가격 * 수량의 합은 {0}원 이상이어야 합니다. 현재 값 = {1}
// 필드 오류
if (item.getQuantity() == null || item.getQuantity() >= 10000) {
  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);
  }
}

bindingResult 는 검증 대상 객체를 이미 알고 있기 때문에,

target 에 대한 정보 없이 `reject()` 와 `rejectValue()` 로 간단하게 사용할 수 있다.

 

이때, MessageCodeResolver 가 field 와 errorCode 만으로 다양한 오류 코드를 만들어 넣어준다.

 

MessageCodeResolver 란 ?

MessageCodeResolver 가 인터페이스이고, 기본 구현체는 DefaultMessageCodesResolver 이다.

 

BindingResult 의 rejectValue( ), reject( ) 는 내부에서 MessageCodeResolver 를 사용하여 오류 코드들을 생성한다.

 

FieldError( ), ObjectError( ) 를 직접 생성하여 파라미터로 넣어줬던 String[ ] 의 메시지 코드들을

MessageCodeResolver 가 이미 알고 있는 검증할 객체와 파라미터를 통해 자동으로 만들어 주는 것이다.

 

예를 들어 FieldError `rejectValue("itemName", "required")` 라면,

`itemName`, `required` 와 검증할 객체 `item`, itemName 의 `type` 을 사용하여

1. `required.item.itemName`

2. `required.itemName`

3. `required.java.lang.String`

4. `required` 

 

예를 들어 ObjectError `reject("totalPriceMin")` 이라면,

`totalPriceMin` 과 검증할 객체 `item` 을 사용하여

1. `totalPriceMin.item`

2. `totalPriceMin`

 

우선순위를 만들어 생성한다.

 

Validator 와 @Validated

검증 로직을 Validator 인터페이스를 구현하여 별도의 클래스에 분리한다.

Validator 인터페이스에는 `supports()` 와 `validate()` 메서드가 있다.

 

`supports()` : 해당 검증기를 지원하는 여부 확인 (해당 클래스 + 자식 클래스)

`validate(Object target, Errors errors)` : 검증 대상 객체와 BindingResult

(아까 BindingResult 는 Errors 를 상속받았다고 했다.)

 

컨트롤러에서 스프링 빈으로 주입받아 직접 호출할 수 있다.

 

자동 호출 - WebDataBinder, @InitBinder, @Vaildated

WebDataBinder 를 사용하여 검증기를 미리 등록하면 해당 컨트롤러에서 자동으로 검증기를 실행할 수 있다.

@InitBinder
public void init(WebDataBinder dataBinder) {
  dataBinder.addValidators(itemValidator);
}

 

@Validated

그리고 검증할 객체인 @ModelAttribute 애노테이션 앞에 @Vaildated 애노테이션을 추가하면, 

WebDataBinder 에 등록한 검증기를 찾아서 실행한다.

이때 검증기의 `supports()` 를 사용하여 검증할 객체에 맞는 검증기를 찾고, `validate()` 가 호출되어 검증한다.

 

 

> 참고 <

- @Validated vs @Valid

둘 다 사용 가능하지만, @Valid 는 bulid.gradle 에 의존관계를 추가해야 한다.

@Validated 는 스프링 전용 검증 애노테이션이고, @Valid 는 자바 표준 검증 애노테이션이다.

@Validated 는 내부에 groups 기능을 포함하고 있다. (하지만 잘 사용하지 않음)

 

> 참고 <

- @MoedlAttribute vs HttpMessageConver 를 사용하는 것들 (@RequestBody, HttpEntity 등)

HttpMessageConvertor 는 전체 객체 단위로 적용되기 때문에, 특정 필드 하나가 바인딩 되지 않으면 데이터를 객체로 변경하지 못한다. 따라서 이후 단계가 실행되지 않아 BindingResult 에 담기지도 않고, Validator 도 적용할 수 없다.

하지만 @ModelAttribute 는 필드 단위로 정교하게 바인딩이 적용되어, 특정 필드가 바인딩 되지 않아도 나머지 필드는 정상 바인딩이 되며 BindingResult 에도 담기고, Validator 도 적용할 수 있다. 

 

 

 

 

 

 

 

 

 

 

 

출처 | 스프링 MVC 2(김영한) - 인프런

728x90