검증 - Validation(1)

2022. 3. 31. 21:01Spring 기초

BindingResult

스프링이 제공하는 검증 오류를 보관하는 객체이다. 검증 오류가 발생하면 해당 필드가 여기에 저장된다.
BindingResult 가 있으면 @ModelAttribute 에 데이터 바인딩 시 오류가 발생할 경우 컨트롤러가 호출된다!

@ModelAttribute에 바인딩 시 타입 오류가 발생하면?

BindingResult 가 없으면 400 오류가 발생하면서 컨트롤러가 호출되지 않고, 오류 페이지로 이동한다.
BindingResult 가 있으면 오류 정보( FieldError )를 BindingResult 에 담아서 컨트롤러를 정상 호출한다(컨트롤러에서 재주껏 처리하도록)

BindingResult에 검증 오류를 적용하는 3가지 방법

  1. @ModelAttribute 의 객체에 타입 오류 등으로 바인딩이 실패하는 경우 스프링이 FieldError 생성해서 BindingResult에 넣어준다.
  2. 개발자가 직접 넣어준다.
  3. Validator 사용 (다음 포스팅인 https://mr-popo.tistory.com/39 참고)

 

주의할 점

BindingResult 는 검증할 대상 바로 다음에 와야한다. 순서가 중요하다. 예를 들어서 @ModelAttribute 
Item item, 바로 다음에 BindingResult 가 와야 한다.
BindingResult 는 Model에 자동으로 포함된다.

BindingResult 는 인터페이스이고, Errors 인터페이스를 상속받고 있다.
실제 넘어오는 구현체는 BeanPropertyBindingResult 라는 것인데, 둘다 구현하고 있으므로
BindingResult 대신에 Errors 를 사용해도 된다. 

Errors 인터페이스는 단순한 오류 저장과 조회 기능을 제공한다. BindingResult 는 여기에 더해서 추가적인 기능들을 제공한다. addError() 메서드도 BindingResult 가 제공하므로 여기서는 BindingResult 를 사용하자. 주로 관례상 BindingResult 를 많이 사용한다.


 오류를 야기한 사용자 입력 메시지를 화면에 남겨보자.

FieldError의 생성자는 두가지가 제공된다. 
1.
public FieldError(String objectName, String field, String defaultMessage);

2.
public FieldError(String objectName, String field, @Nullable Object 
rejectedValue, boolean bindingFailure, @Nullable String[] codes, @Nullable
Object[] arguments, @Nullable String defaultMessage)

두번째 생성자의 rejectedValue를 사용하는 것이다.
rejectedValue에 사용자가 입력한 값을 넣어주면 오류 메시지는 화면에 출력하면서, 사용자가 입력한 메시지가 그대로 남아있게된다.

new FieldError("item", "price", item.getPrice(), false, null, null, "가격은 1,000 ~ 
1,000,000 까지 허용합니다.")

사용자의 입력 데이터가 컨트롤러의 @ModelAttribute 에 바인딩되는 시점에 오류가 발생하면 모델 객체에 사용자 입력 값을 유지하기 어렵다. 예를 들어서 가격에 숫자가 아닌 문자가 입력된다면 가격은 Integer 타입이므로 문자를 보관할 수 있는 방법이 없다. 그래서 오류가 발생한 경우 사용자 입력 값을 보관하는 별도의 방법이 필요하다. 그리고 이렇게 보관한 사용자 입력 값을 검증 오류 발생시 화면에 다시 출력하면 된다.
FieldError 는 오류 발생시 사용자 입력 값을 저장하는 기능을 제공한다. 여기서 rejectedValue 가 바로 오류 발생시 사용자 입력 값을 저장하는 필드다. bindingFailure 는 타입 오류 같은 바인딩이 실패했는지 여부를 적어주면 된다. 여기서는 바인딩이 실패한 것은 아니기 때문에 false 를 사용하였다.

바인딩 오류의 경우에도..
타입 오류로 바인딩에 실패하면 스프링은 FieldError 를 생성하면서 사용자가 입력한 값을 넣어둔다. 
그리고 해당 오류를 BindingResult 에 담아서 컨트롤러를 호출한다. 따라서 타입 오류 같은 바인딩
실패시에도 사용자의 오류 메시지를 정상 출력할 수 있다.

 

타임리프에서는..

th:field="*{price}"

타임리프의 th:field 태그는 매우 스마트하다. 정상 상황에는 모델 객체의 값을 사용하지만, 오류가 발생하면 FieldError 에서 보관한 값을 사용해서 값을 출력한다. 매우 편리하다!!!

 

new FieldError("item", "price", item.getPrice(), false, null, null, "가격은 1,000 ~ 
1,000,000 까지 허용합니다.")

헌데 지금처럼 error 메시지를 하드코딩하는 방법은 간지가 안난다. 재사용도 불가능하다. 이를 메시지 파일로 따로 분리하여 유지 보수를 용이하게 해보자!

  
errors.properties를 생성한 뒤 

spring.messages.basename=messages,errors


설정을 추가했다. 원래 messages는 디폴트 값이라, messages.properties는 특별한 설정 없이 스프링이 지원해주었다. errors를 추가하면서 둘 다 적어주었다.

 

errors.properties 작성

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

 

참고로 errors_en.properties 파일을 생성하면 오류 메시지도 국제화 처리를 할 수 있다. 

bindingResult.addErro(new FieldError("item", "price", item.getPrice(), false, null, null, "가격은 1,000 ~ 
1,000,000 까지 허용합니다."))

에서

bindingResult.addError(new FieldError("item", "price", item.getPrice(),
false, new String[]{"range.item.price"}, new Object[]{1000, 1000000}, null));
 }

메시지 파일을 따로 작성한 뒤, 위의 코드에서 아래의 코드로 변경하였다.

Object[]{1000, 1000000} 를 사용해서 코드의 {0} , {1} 로 치환할 값을 전달한다.
new String[]{"range.item.price"}는 ErrorCode 값을 전달해준 것이다. errors.properties 파일에서 range.item.price와 key 값이 일치하는 메시지가 화면에 출력된다.
메시지 코드는 하나가 아니라 배열로 여러 값을 전달할 수 있는데, 순서대로 매칭해서 처음 매칭되는 메시지가 사용된다. 

이에 대해서는 뒤에서 자세히 설명한다.

여기서 한단계 더 발전하여, BindingResult 가 제공하는 rejectValue() , reject() 를 사용하면 FieldError , ObjectError 를
직접 생성하지 않고, 깔끔하게 검증 오류를 다룰 수 있다. 

bindingResult.rejectValue("price", "range", new Object[]{1000,
1000000}, null);

FieldError , ObjectError 를 생성하지 않고 오류를 다루는 코드다. 이전 코드는 생성자의 파라미터가 너무 많아서 그런가 확실히 깔끔해졌다.

다만 메시지의 code에 "range"만 있는데 어떻게 errors.properties의 값을 가져와서 출력할 수 있는 것일까??

여기서 사용한 rejectValue() 메서드를 자세히 살펴보면 void rejectValue(@Nullable String field, String errorCode,
@Nullable Object[] errorArgs, @Nullable String defaultMessage); 다음과 같은 파라미터를 받을 수 있다.

field : 오류 필드명
errorCode : 오류 코드(이 오류 코드는 메시지에 등록된 코드가 아니다. 뒤에서 설명할
messageResolver를 위한 오류 코드이다.)
errorArgs : 오류 메시지에서 {0} 을 치환하기 위한 값
defaultMessage : 오류 메시지를 찾을 수 없을 때 사용하는 기본 메시지

BindingResult는 검증할 대상 바로 뒤에 오기때문에, 어떤 객체를 대상으로 검증하는지 target정보를 이미 알고있다. 따라서 Item(target)에 대한 정보는 없어도 된다. 

다음은 errorCode에 관한 내용이다.

해당 field(price)에 대한 오류 메시지는 
errors.properties에 다음과 같다.

range.item.price=가격은 {0} ~ {1} 까지 허용합니다.

 

오류 코드를 만들 때 다음과 같이 자세히 만들 수도 있고,

required.item.itemName : 상품 이름은 필수 입니다.
range.item.price : 상품의 가격 범위 오류 입니다.
또는 다음과 같이 단순하게 만들 수도 있다.
required : 필수 값 입니다.
range : 범위 오류 입니다



단순하게 만들면 범용성이 좋아서 여러곳에서 사용할 수 있지만, 메시지를 세밀하게 작성하기 어렵다. 
반대로 너무 자세하게 만들면 범용성이 떨어진다. 가장 좋은 방법은 범용성으로 사용하다가, 세밀하게
작성해야 하는 경우에는 세밀한 내용이 적용되도록 메시지에 단계를 두는 방법이다.
예를 들어서 required 라고 오류 코드를 사용한다고 가정해보자.
다음과 같이 required 라는 메시지만 있으면 이 메시지를 선택해서 사용하는 것이다.
required: 필수 값 입니다.

그런데 오류 메시지에 required.item.itemName 와 같이 객체명과 필드명을 조합한 세밀한 메시지
코드가 있으면 이 메시지를 높은 우선순위로 사용하는 것이다.
#Level1
required.item.itemName: 상품 이름은 필수 입니다.
#Level2
required: 필수 값 입니다.

물론 이렇게 객체명과 필드명을 조합한 메시지가 있는지 우선 확인하고, 없으면 좀 더 범용적인 메시지를
선택하도록 추가 개발을 해야겠지만, 범용성 있게 잘 개발해두면, 메시지의 추가 만으로 매우 편리하게 오류
메시지를 관리할 수 있을 것이다.
스프링은 MessageCodesResolver 라는 것으로 이러한 기능을 지원한다.

MessageCodesResolver는 인터페이스로, DefaultMessageCodesResolver라는 기본 구현체가 존재한다. DefaultMessageCodesResolver의 기본 메시지 생성 규칙은 다음과 같은 순서를 지킨다.

객체 오류


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


핵심은 구체적인 것에서! 덜 구체적인 것으로!


MessageCodesResolver 는 required.item.itemName 처럼 구체적인 것을 먼저 만들어주고, required 처럼 덜 구체적인 것을 가장 나중에 만든다. 이렇게 하면 앞서 말한 것 처럼 메시지와 관련된 공통 전략을 편리하게 도입할 수 있다.

왜 이렇게 복잡하게 사용하는가?

모든 오류 코드에 대해서 메시지를 각각 다 정의하면 개발자 입장에서 관리하기 너무 힘들다.
크게 중요하지 않은 메시지는 범용성 있는 requried 같은 메시지로 끝내고, 정말 중요한 메시지는 꼭 필요할 때 구체적으로 적어서 사용하는 방식이 더 효과적이다.

'Spring 기초' 카테고리의 다른 글

검증 - Bean Validation  (0) 2022.04.02
검증 - Validation (2)  (0) 2022.03.31
REST API와 Ajax(2)  (0) 2022.03.27
REST API와 Ajax(1)  (0) 2022.03.27
RedirectAttributes  (0) 2022.03.26