Spring

Validation , Bean Validation, 오류 코드 설계 (1/2)

Lea Hwang 2022. 6. 30. 16:27

Validation 이란

예를 들어 고객이 상품 등록 폼에서 상품명을 입력하지 않거나, 가격, 수량 등이 너무 작거나 커서 검증 범위를 넘어서면,

서버 검증 로직이 실패해야 합니다. 이렇게 검증에 실패한 경우 고객에게 다시 상품 등록 폼을 다시 보여주고, 입력한 데이터를 유지한 상태에서 어떤 값을 잘 못 입력했는지 알려주어야 합니다.

 

 

스프링이 제공하는 검증 오류 처리 방법을 코드를 통해 알아보겠습니다.

여기서 핵심은 BindingResult입니다.

@Slf4j
@Controller
@RequestMapping("/validation/v1/items")
@RequiredArgsConstructor
public class ValidationItemControllerV1 {

    @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 까지 허용합니다."));
        }

        //특정 필드가 아닌 복합 룰 검증
        //특정 필드가 아니므로 new FieldError가 아닌 new ObjectError 사용
        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("errors={} ", bindingResult);
            return "validation/v2/addForm";
        }
        ...

}

@ModelAttribute Item item

model.addAttribute("item", item); 와 같습니다.

폼으로 다시 돌아갈 때 고객이 입력했던 데이터가 그대로 찍히는 이유입니다.

 

 

문자열 유효성 검증 유틸 메소드 

import org.springframework.util.StringUtils;

 

유용한 메소드 두 개

1.  StringUtils.hasLength(String)

public static boolean hasLength(@Nullable String str) {
		return (str != null && !str.isEmpty());
	}

null체크 && String클래스의 isEmpty를 호출하여 길이가 0인지 판별

 

2. StringUtils.hasText(String)

public static boolean hasText(@Nullable String str) {
   return (str != null && !str.isEmpty() && containsText(str));
}

hasLength() + 공백이 아닌 문자열이 존재하는지까지 검증해주는 메소드로

containsText 메소드는 문자열을 하나씩 순회하며 공백이 아닌 character가 하나라도 있으면 true를 리턴하는 메소드이다.

 

참고: https://creampuffy.tistory.com/120

 

 

BindingResult bindingResult

@ModelAttribute Item item을 바인딩함,

bindingResult는 자동으로 뷰에 넘어감

💡주의
BindingResult의 순서가 중요합니다. BindingResult는 검증할 대상 바로 다음에 와야 하므로
BindingResult bindingResult 파라미터의 위치는 @ModelAttribute Item item 다음에 와야 합니다.

BindingResult는 Model에 자동으로 포함됩니다.

 

화면 수정

addForm.html

1. 글로벌 오류 메시지

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

 

2. 상품명 오류

<input type="text" id="itemName" th:field="*{itemName}"
       th:errorclass="field-error" class="form-control" placeholder="이름을 입력하세요">
<div class="field-error" th:errors="*{itemName}">
    상품명 오류
</div>

 

 

타임리프 스프링 검증 오류 통합 기능

타임리프는 스프링의 BindingResult를 활용해서 편리하게 검증 오류를 표현하는 기능을 제공합니다.

1. #fields : #fields로 BindingResult 가 제공하는 검증 오류에 접근할 수 있다.

2. th:errors : 해당 필드에 오류가 있는 경우에 태그를 출력한다. th:if의 편의 버전이다

3. th:errorclass : th:field에서 지정한 필드에 오류가 있으면 class 정보를 추가한다

 

검증과 오류 메시지 공식 메뉴얼 참고
https://www.thymeleaf.org/doc/tutorials/3.0/thymeleafspring.html#validation-and-error-messages

 

 

BindingResult

스프링이 제공하는 검증 오류를 보관하는 객체로 검증 오류가 발생하면 여기에 보관합니다.

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

 

예) @ModelAttribute에 바인딩 시 타입 오류가 발생하면? (Integer타입인 Price에 문자 입력 시)

- BindingResult 가 없으면 400 오류가 발생하면서 컨트롤러가 호출되지 않고, 오류 페이지로 이동한다.

- BindingResult 가 있으면 오류 정보( FieldError )를 BindingResult에 담아서 컨트롤러를 정상 호출한다.

 

BindingResult와 Errors

org.springframework.validation.Errors

org.springframework.validation.BindingResult

public interface BindingResult extends Errors

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

  인터페이스가 인터페이스 상속 시 extends 사용

 

Errors 인터페이스는 단순한 오류 저장과 조회 기능을 제공하고 BindingResult는 여기에 더해서 추가적인 기능들을 제공합니다. 

주로 관례상 BindingResult를 많이 사용합니다. 

 


 

 

여기까지

BindingResult , FieldError , ObjectError를 사용해서 오류 메시지를 처리하는 방법을 알아보았습니다.

여기서 문제는 오류가 발생하는 경우 고객이 입력한 내용이 모두 사라진다는 것입니다.

이를 해결하기 위해서 FieldError를 활용하는 방법에 대해 바로 알아보겠습니다.

 

 

 

사용자의 입력 데이터가 컨트롤러의 @ModelAttribute에 바인딩되는 시점에 오류가 발생하면 모델 객체에 사용자 입력 값을 유지하기 어렵습니다.

예를 들어 가격에 숫자가 아닌 문자가 입력된다면 가격은 Integer 타입이므로 문자를 보관할 수 있는 방법이 없습니다.

그래서 오류가 발생한 경우 사용자 입력 값을 보관하는 별도의 방법이 필요합니다. 그리고 이렇게 보관한 사용자 입력 값을 검증 오류 발생 시 화면에 다시 출력하면 되는데 이러한 기능을 FieldError 가 합니다.

 

 

FieldError는 오류 발생 시 사용자 입력 값을 저장하는 기능을 제공한다

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

        //검증 로직
        if (!StringUtils.hasText(item.getItemName())) {
            bindingResult.addError(new FieldError("item", "itemName", item.getItemName(), false, null, null, "상품 이름은 필수 입니다."));
        }
        if (item.getPrice() == null || item.getPrice() < 1000 || item.getPrice() > 1000000) {
            bindingResult.addError(new FieldError("item", "price", item.getPrice(), false, null, null, "가격은 1,000 ~ 1,000,000 까지 허용합니다."));
        }
        if (item.getQuantity() == null || item.getQuantity() >= 9999) {
            bindingResult.addError(new FieldError("item", "quantity", item.getQuantity(), false, null ,null, "수량은 최대 9,999 까지 허용합니다."));
        }

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

 

FieldError 생성자

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)

파라미터 목록

objectName : 오류가 발생한 객체 이름

field : 오류 필드

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

  * 오류 발생 시 사용자 입력 값을 저장하는 필드

bindingFailure : 타입 오류 같은 바인딩 실패인지, 검증 실패인지 구분 값

codes : 메시지 코드

arguments : 메시지에서 사용하는 인자

defaultMessage : 기본 오류 메시지

 

📢 참고
Ctrl + P
어떻게 무엇을 넘겨야 하는지 알려줌

 

오류 발생 시 사용자 입력 값 유지

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

여기서는 바인딩이 실패한 것은 아니기 때문에 false를 사용했습니다.

 

 

타임리프의 사용자 입력 값 유지

th:field="*{price}"

타임리프의 th:field는 정상적인 상황에는 모델 객체의 값을 사용하지만, 오류가 발생하면 FieldError에서 보관한 값을 사용해서 값을 출력합니다.

 

스프링의 바인딩 오류 처리

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

 

 

오류 코드와 메시지 처리

재 사용성을 위해 오류 메시지와 메시지 내용을 '일관성 있게'  관리해야 합니다.

errors 메시지 파일 생성

errors.properties라는 별도의 파일로 관리해봅시다.

 

먼저 스프링 부트가 해당 메시지 파일을 인식할 수 있게 다음 설정을 추가합니다.

이렇게 하면 messages.properties , errors.properties 두 파일을 모두 인식하게 됩니다.

  (생략하면 messages.properties를 기본으로 인식한다.)

 

스프링 부트 메시지 설정 추가

application.properties

spring.messages.basename=messages,errors

 

errors.properties

  Level 숫자가 작을수록 우선순위가 높습니다.

#==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 - 생략

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

 

 

BindingResult 가 제공하는 rejectValue() , reject()로 검증 오류 다루기

    @PostMapping("/add")
    public String addItemV3(@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, 10000000}, 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);
            }
        }
        ....
}

 

실행

오류 메시지가 정상 출력됩니다.

 

❗여기서 잠깐❗

errors.properties 에 있는 코드를 직접 입력하지 않았는데 어떻게 된 것일까요?

void rejectValue(@Nullable String field, String errorCode,
@Nullable Object[] errorArgs, @Nullable String defaultMessage);
void reject(String errorCode, @Nullable Object[] errorArgs, @Nullable String 
defaultMessage);

field : 오류 필드명

errorCode : 오류 코드(이 오류 코드는 메시지에 등록된 코드가 아닌 messageResolver를 위한 오류 코드이다.)

errorArgs : 오류 메시지에서 {0}을 치환하기 위한 값

defaultMessage : 오류 메시지를 찾을 수 없을 때 사용하는 기본 메시지

 

축약된 오류 코드

오류 코드를 range.item.price 가 아닌 range로 간단하게 입력했습니다. 이 부분을 이해하면 MessageCodesResolver를 이해해야 하므로 이어서 알아보도록 하겠습니다.

 


 

오류 코드와 메시지 처리

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

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

range.item.price : 상품의 가격 범위 오류입니다.

 

또는 다음과 같이 단순하게 만들 수도 있습니다.

required : 필수 값입니다.

range : 범위 오류입니다.

 

단순하게 만들면 범용성이 좋아서 여러 곳에서 사용할 수 있지만, 메시지를 세밀하게 작성하기 어렵고

반대로 너무 자세하게 만들면 범용성이 떨어집니다.

가장 좋은 방법은 범용성으로 사용하다가, 세밀하게 작성해야 하는 경우에는 세밀한 내용이 적용되도록 

메시지에 단계를 두는 방법을 사용할 수 있습니다.

 

예를 들어서

required라는 메시지 있으면 이 메시지를 선택해서 사용합니다.

required: 필수 값 입니다.

 

그런데 오류 메시지에

required.item.itemName 와 같이 객체명과 필드명을 조합한 세밀한 메시지 코드가 있으면

이 메시지를 높은 우선순위로 사용하는 것입니다.

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

#Level2
required: 필수 값 입니다.

 

스프링은 MessageCodesResolver라는 것으로 이러한 기능을 지원합니다.

 

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를 사용해서 오류코드에서 메시지 코드들을 생성합니다.

 

 

오류 메시지 출력

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

 

 

정리)
메시지는 errors.properties에서 관리
개발 후 기획 쪽에서 "메세지 수정 요청"이 들어와도 코드를 수정하지 않고 errors.properties만 수정하면 됩니다.
범용성 있는 메세지를 default로 가져가지만 세밀한 메세지가 우선순위를 갖습니다.

 

 


 

 

생각보다 Validation을 정리하는데 길어졌습니다. 이어지는 다음 포스팅에서는 Bean Validation에 대해 알아보도록 하겠습니다.

 

 

 

 

 

 

참고:

https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81-mvc-2/dashboard

 

스프링 MVC 2편 - 백엔드 웹 개발 활용 기술 - 인프런 | 강의

웹 애플리케이션 개발에 필요한 모든 웹 기술을 기초부터 이해하고, 완성할 수 있습니다. MVC 2편에서는 MVC 1편의 핵심 원리와 구조 위에 실무 웹 개발에 필요한 모든 활용 기술들을 학습할 수 있

www.inflearn.com