Spring

Spring 검증 - BindingResult (FieldError, ObjectError, rejectValue, reject)

녁이 2023. 12. 18. 20:01
728x90
반응형

검증 ( Client, Server 검증 )

  • 클라이언트 검증은 조작할 수 있으므로 보안에 취약
  • 서버만으로 검증하면, 즉각적인 고객 사용성이 부족
  • 둘을 섞어 사용하되, 최종적으로 서버 검증은 필수
  • API 방식 → API 스펙을 잘 정의해서 검증 오류를 API 응답 결과에 잘 남겨주어야 함

 

 

 

Code들을 Version을 올려가면서 더 단순하고 깔끔하게 변경해보겠다.

ControllerV1 (제일 무식하게)

//검증 오류 결과를 보관
 Map<String, String> errors = new HashMap<>();

errors 라는 map을 만들고 검증 로직을 컨트롤러에 작성하여 이를 담아준다.

 

 <form action="item.html" th:action th:object="${item}" method="post">
 <div th:if="${errors?.containsKey('globalError')}">
 <p class="field-error" th:text="${errors['globalError']}">전체 오류 메시지</p>
 </div>
 
 <div>
 <label for="itemName" th:text="#{label.item.itemName}">상품명</label>
 <input type="text" id="itemName" th:field="*{itemName}"
 th:class="${errors?.containsKey('itemName')} ? 'form-control 
field-error' : 'form-control'" class="form-control" placeholder="이름을 입력하세요">
 <div class="field-error" th:if="${errors?.containsKey('itemName')}"
th:text="${errors['itemName']}">
 상품명 오류
 </div>

addForm.html 파일의 일부분이다.

위와 같이 타임리프를 통해 errors 를 통해 받은 에러를 찍어내려 한다.

th:object 로 ${item} 을 받았기 때문에 itemName 등에 접근할 수 있다.

(field-error 클래스는 빨간색 css 를 해당 클래스에 적용한 것이다.)

 

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

위를 보면 ?. 라는 문법을 볼 수 있다.

 

Safe Navigation Operator

위에서 넘어온 errors가 만일 null 이라면 어떻게 될까?

등록폼에 처음 진입한 시점에는 데이터가 null이다. → errors.containsKey() 를 호출 순간 예외 발생

errors?. 은 null일 때, 예외를 발생시키는 대신, null을 반환해준다.

 

Version1 의 문제점

  • 뷰 템플릿에서 중복 처리가 많다.
  • 타입 오류 처리가 안됨. → Item 의 price 부분에 숫자가 들어와야 하는데, 사용자가 "qqq" 와 같이 문자열을 입력하면 오류가 발생
  • 사용자가 실수로 다른 타입으로 입력했을 때, 입력했던 값이 저장되지 않고 사라지게 된다. → 저장되어야 함.

ControllerV2 (BindingResult 사용 → FieldError, ObjectError)

BindingResult 를 사용하면 V1에서처럼 error를 담기 위해 model을 사용하지 않아도 된다. 이는 자동으로 포함된다.

만일, BindingResult를 사용한다면, BindingResult 파라미터의 위치는 @ModelAttribute Item item 뒤에 와야 한다.

( 검증할 객체 파라미터의 뒤에 와야 한다는 말 )

 

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

 if (!StringUtils.hasText(item.getItemName())) {
 bindingResult.addError(new FieldError("item", "itemName", "상품 이름은
필수입니다."));
 }

 

bindingResult.addError() 를 통해 에러를 뷰 템플릿에 넘겨줄 수 있다.

ItemName 필드에 대한 에러를 다루기 때문에 new FieldError 를 사용했다.

arguments는 객체 이름(objectName), 필드 이름(field), 에러 기본 메세지(defaultMessage) 이다.

 

//특정 필드 예외가 아닌 전체 예외
 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 ObjectError를 사용한다.

arguments는 객체 이름(objectName) , 에러 기본 메세지(defaultMessage) 이다.

 

 

addForm.html 수정

<form action="item.html" th:action th:object="${item}" method="post">
 <div th:if="${#fields.hasGlobalErrors()}">
 <p class="field-error" th:each="err : ${#fields.globalErrors()}"
th:text="${err}">글로벌 오류 메시지</p>
 </div>
 
 <div>
 <label for="itemName" th:text="#{label.item.itemName}">상품명</label>
 <input type="text" id="itemName" th:field="*{itemName}"
 th:errorclass="field-error" class="form-control"
placeholder="이름을 입력하세요">
 <div class="field-error" th:errors="*{itemName}">
 상품명 오류
 </div>
 </div>

 

th:if 문에서 #fields.hasGlobalErrors() 이 참이면 실행.

th: each문으로 err에 많은 글로벌 에러들을 담고 출력해준다.

 

itemName 부분에서 th: field가 해주는 일이 많다 → 에러가 발생하면 th: errorclass 가 field-error를 class에 추가해준다.

에러가 발생하지 않으면, 본 class인 form-control이 실행된다.

또한, th:errors = *{itemName} 을 통해 발생한 오류의 defaultMessage를 사용자에게 보여줄 수 있다.

 

  • #fields : #fields 로 BindingResult 가 제공하는 검증 오류에 접근할 수 있다.
  • th:errors : 해당 필드에 오류가 있는 경우에 태그를 출력한다. th:if 의 편의 버전이다.
  • th:errorclass : th:field 에서 지정한 필드에 오류가 있으면 class 정보를 추가한다.

BindingResult

스프링이 제공하는 검증 오류를 보관하는 객체

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

 

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

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

 

BindingResult, FieldError, ObjectError 를 통해서 오류 메세지를 처리했다.

그런데, 오류가 발생하는 경우 고객이 입력한 내용이 사라지는 문제점이 남아있다. 이를 해결해보자!!


RejectedValue

사용자가 입력한 값( 거절된 값 ) 을 저장하는 rejectedValue 라는 파라미터가 있다.

위에서 사용한 FieldError, ObjectError 의 다른 생성자에 파라미터로 포함되어 있다.

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를 포함한 생성자이다.

 

if (!StringUtils.hasText(item.getItemName())) {
 bindingResult.addError(new FieldError("item", "itemName",
item.getItemName(), false, null, null, "상품 이름은 필수입니다."));
 }

이렇게 파라미터들을 넣어주면 된다.

이제 오류가 발생해도 사용자가 입력한 값들이 유지된다!!

 

이제는 다른 타입을 입력하면, 값이 유지되고 오류 메세지가 나타난다.

근데 해당 메세지는 스프링이 임의로 출력한 오류 메세지이다.

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"

위와 같이 나온다. 이는 친절한 오류 메세지가 아니다. 이를 우리가 원하는 형식으로 바꿔보자!

 


오류 코드와 메세지 처리

오류 메세지를 좀 더 체계적으로 다뤄보자.

 

FieldError , ObjectError 의 생성자는 codes , arguments 를 제공한다.

이것은 오류 발생시 오류 코드로 메시지를 찾기 위해 사용된다.

 

errors 메시지 파일 생성

→ messages.properties 를 사용해도 되지만, 오류 메시지를 구분하기 쉽게 errors.properties 라는 별도의 파일로 관리해보자

 

이를 사용하려면, application.properties에 설정을 추가해주어야 한다.

spring.messages.basename=messages,errors

 

src/main/resources/errors.properties

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

위의 경로에 errors.properties를 위와 같이 만들어주었다.

 

이제 FieldError, ObjectError 에 codes와 arguments 파라미터를 통해 erros.properties를 사용해보자.

 

if (!StringUtils.hasText(item.getItemName())) {
 bindingResult.addError(new FieldError("item", "itemName",
item.getItemName(), false, new String[]{"required.item.itemName"}, null,
null));
 }
 
 if (item.getPrice() == null || item.getPrice() < 1000 || item.getPrice() >
1000000) {
 bindingResult.addError(new FieldError("item", "price", item.getPrice(),
false, new String[]{"range.item.price"}, new Object[]{1000, 1000000}, 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));
 }

 

Codes 파라미터는 String[] 배열로 입력

Codes는 배열로 여러 값을 전달하는데, 이는 순서대로 매칭해서 처음 매칭되는 메세지가 사용된다. (우선 순위가 있음)

 

매번 new FieldError()로 새로 생성해야 할까??

생각해보면 우리는 Item item 뒤에 BindingResult를 작성하므로, 검증해야하는 객체를 bindingResult는 알고 있다.

그러면 매번 이렇게 객체를 생성해서 사용해야 할까?

 

→ bindingResult 가 제공하는 rejectValue() , reject() 를 사용하면 된다.

 

 rejectValue() , reject() 적용

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.getPrice() != null && item.getQuantity() != null) {
 int resultPrice = item.getPrice() * item.getQuantity();
 if (resultPrice < 10000) {
 bindingResult.reject("totalPriceMin", new Object[]{10000,
resultPrice}, null);
 }
 }

위에서 말했듯, bindingResult는 어떤 객체를 대상으로 검증하는지 알기 때문에, target 객체에 대한 정보를 따로 작성해주지 않아도 된다. 때문에, item에 관련된 정보는 없어도 된다. 

 

rejectValue()

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

errorCode를 보면 축약된 오류 코드가 있다.

( range.item.price → range )

이는 규칙이 있다. 이를 이해하기 위해선 MessageCodesResolver 에 대해 알아야 한다.

 

위와 같이 errors.properties에 메세지들을 다 작성해놓고 에러 코드를 통해 호출해서 사용하는 것이라면 

오류 코드를 어떻게 작성하는지에 따라 개발이 단순해지거나 복잡해질 수 있다.

 


 

+ 참고 ( ValidationUtils )

위의 rejectvalue() 를 작성한 부분을 아래와 같이 바꿔줄 수 있다.

ValidationUtils.rejectIfEmptyOrWhitespace(bindingResult, "itemName","required");

오류 코드 작성

오류 코드를 자세히 만들 수도 있고 단순하게 만들 수도 있다.

단순하게 만들면 범용성이 좋지만 메세지를 세밀하게 작성할 수 없고, 반대로 너무 자세하게 만들면 범용성이 떨어지지만 세밀한 내용을 전달할 수 있다.

 

예를 들어서 required 라고 오류 코드를 사용한다고 가정해보자.

#Level1
required.item.itemName: 상품 이름은 필수 입니다.

#Level2
required: 필수 값 입니다.

위와 같이 errors.properties에 오류 코드가 작성되어 있다면?

LEVEL 1 이 우선 순위를 가지고, LEVEL 2가 그 다음으로 우선 순위를 가진다. ( 좀 더 디테일한 코드가 우선 순위 )

 

이런 식으로, 메세지의 추가만으로 매우 편리하게 모든 오류 메세지를 관리할 수 있게 된다.

스프링은 MessageCodesResolver 를 통해 이러한 기능을 지원한다.


MessageCodesResolver

  • 인터페이스, DefaultMessageCodesResolver 가 기본 구현체
  • 검증 오류 코드로 메세지 코드들을 생성한다.
  • ObjectError, FieldError 들과 주로 함께 사용함

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"

 

동작 방식

  • rejectValue() , reject() 는 내부에서 MessageCodesResolver 를 사용한다. 여기에서 메시지 코드들을 생성한다.
  • FieldError , ObjectError 의 생성자를 보면, 오류 코드를 하나가 아니라 여러 오류 코드를 가질 수 있다. MessageCodesResolver 를 통해서 생성된 순서대로 오류 코드를 보관한다.

 

예시)

FieldError → rejectValue("itemName", "required") 

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

  • required.item.itemName
  • required.itemName
  • required.java.lang.String
  • required

 

해당 오류 코드들을 통해 타임리프가 화면을 렌더링할 때, th:errors 를 실행한다.

이때 오류가 있다면, 생성된 오류 메세지 코드를 순서대로 돌아가면서 메세지를 찾는다. 없다면, 기본 메세지를 출력함.


오류 코드 관리 전략

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

 

크게 중요하지 않은 메시지는 범용성 있는 requried 같은 메시지로 끝내고,

정말 중요한 메시지는 꼭 필요할 때 구체적으로 적어서 사용하는 방식이 더 효과적이다.

 

MessageCodesResolver 의 메세지 생성 규칙를 기반으로, 우리도 오류 코드를 작성해보자!

#==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} 까지의 문자를 허용합니다.
max.java.lang.Integer = {0} 까지의 숫자를 허용합니다.

#Level4
required = 필수 값 입니다.
min= {0} 이상이어야 합니다.
range= {0} ~ {1} 범위를 허용합니다.
max= {0} 까지 허용합니다.

구체적인 것에서 범용적인 것 순으로 만들었다.

이렇게 생성된 메시지 코드를 기반으로 순서대로 MessageSource 에서 메시지에서 찾는다.

 

이렇게 되면 만약에 크게 중요하지 않은 오류 메시지는 기존에 정의된 것을 그냥 재활용 하면 된다!

 


스프링이 직접 만든 오류 메시지 처리

스프링이 임의로 작성한 오류 메세지를 위에서 말한 것을 배경으로 바꿔보자!

 

사용자가 Integer 타입이 들어가야할 price 폼에 "qqq"와 같이 String 문자열을 입력하면, 오류가 발생한다.

typeMismatch 라는 스프링이 지정해놓은 오류 코드가 발생한다.

로그를 통해 확인해보면, 아래와 같다.

codes[typeMismatch.item.price, typeMismatch.price, typeMismatch.java.lang.Integer, typeMismatch]

 

그러면 우리는 간단하게 typeMismatch 코드에 대한 오류 메세지를 errors.properties 에 작성해주면 끝이다!

해당 문장을 errors.properties에 추가해보자.

#추가
typeMismatch.java.lang.Integer=숫자를 입력해주세요.
typeMismatch=타입 오류입니다.

 

이제 다시 실행해보면, 사용자가 "qqq"를 입력하면, 스프링의 오류 메세지가 나오지 않는다.

우리가 설정한 " 숫자를 입력해주세요 " 가 출력된다.

 


정리

검증에 대해서 살펴봤는데, 지금은 컨트롤러에 복잡한 검증 로직들이 함께 써져있다.

이는 컨트롤러에 부담을 높히고 좋은 설계가 아니다.

때문에, 이 검증 로직을 따로 분리하는 것이 좋다.

이를 Validator 라는 것을 통해 분리할 것이다.

 

728x90
반응형