둘셋 개발!

[spring mvc 2편-웹 개발 활용 기술-4(1) ] 검증 Validation 본문

SPRING/MVC

[spring mvc 2편-웹 개발 활용 기술-4(1) ] 검증 Validation

23 2022. 1. 26. 14:47

검증에는 타입검증 , 특정 필드 검증, 특정필드의 범위를 넘어서는 검증 등이 있다.

이러한 검증을 하기 위해서 일일히 구현을 해보고 점점 스프링에서 제공하는 기능들을 배워간다.


검증의 예시를 들기 위해 다음과 같은 상황이 있다고 가정한다

 

상품 관리 시스템을 운영하고 있고 여기에서는 상품명,가격,수량을 등록한다.

이때 검증 로직을 추가하라는 요구사항이 들어온다.

 


-타입 검증 :

  가격, 수량에 문자가 들어가면 검증 오류 처리

 

-필드 검증:

      상품명 : 필수, 공백x

          가격 : 1000원 이상, 1백만원 이하

     수량 : 최대 9999

 

-특정 필드의 범위를 넘어서는 검증

      가격 * 수량의 합은 10,000원 이상

 


version 1

 

검증을 했을 때 오류가 발생하면 어떤 오류가 났는지 정보를 담아 두는 공간으로

key를 필드명으로 하고 value에는 오류 메세지를 담아둔다.

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

 

1. 특정 필드가 오류가 났을때

 if(!StringUtils.hasText(item.getItemName())){errors.put("itemName","상품 이름은 필수입니다.");

만약 item의 itemName필드가 비어있다면 errors에 오류를 담아둔다.

 

 

2. 특정 필드의 범위를 넘어서는 오류가 났을 때

if (item.getPrice() != null && item.getQuantity() != null) {
      int resultPrice = item.getPrice() * item.getQuantity();
      if (resultPrice < 10000) 
	  	errors.put("globalError", "가격 * 수량의 합은 10,000원 이상이어야 합니다. 현재 값 = " + resultPrice);
}

errors에 넣을 때 필드이름이 없으므로  globalError로 넣는다

 

 

-오류로직

if (!errors.isEmpty()) {
      model.addAttribute("errors", errors);
      return "validation/v1/addForm";
}

오류가 있을 경우 다시 등록 폼으로 돌려보낸다(addForm이 등록폼을 의미함)

 

 

v1 요약 :

-오류를 담을 errors를 Map으로, 오류가 발생한 필드를 key,  필드 메세지를 value에 담는다
-errors가 비어있지 않으면 오류가 발생한 것이므로 다시 입력폼으로 돌려보낸다

 

v1의 문제점 :

타입오류 검증이 불가능하다.
타입오류가 발생하면 스프링mvc의 컨트롤러에 진입하기도 전에 400대 오류가 발생하기 때문에 사용자는 오류가 발생하는 이유를 모르게 된다.

version2

 

v2의 핵심은 BindingResult

 

BingdinResult는 위에서 오류를 담아주는 errors의 역할이다.

주의할 점은 꼭 @ModelAttribute 파리미터 바로 뒤에 위치해야 한다!(이유는 좀 더 뒤에서 설명)

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

 

1. 특정 필드가 오류가 났을 때

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

bingdingResult에 FieldError객체를 생성하여 담아준다

 

-FieldError객체 생성자 요약

public FieldError(String objectName, String field, String defaultMessage) {}

objectName : @ModelAttribute 이름

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

defaultMessage : 오류 기본 메세지

 

 

2. 특정 필드의 범위를 넘어서는 오류가 났을 때

bindingResult.addError(new ObjectError("item", "가격 * 수량의 합은 10,000원 이상이어야
합니다. 현재 값 = " + resultPrice));

마찬가지로 bingdingResult에 넣어주는데 이번에는 ObjectError객체를 생성해서 담아준다

 

-ObjectError객체 생성자 요약

public ObjectError(String objectName, String defaultMessage) {}

 

 

BindingResult

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

이 객체가 있으면 @ModelAttribute에서 데이터 바인딩 오류가 발생해도 컨트롤러가 호출된다(타입오류가 발생해도 컨트롤러 호출 가능)

만약 타입오류가 발생하면 스프링이 FieldError를 생성해서 bindingResult에 넣어준다

 

이처럼 스프링이 FieldError를 생성해서 직접 넣어주는 경우도 있고, 위의 예시들 처럼 개발자가 직접 넣어주는 경우도 있고

Validator를 사용하는 경우도 있다.(보통 오류가 나면 validator을 사용함. 이는 뒤에 설명)

 

그리고 앞에서 bingdingResult는 @ModelAttribute 바로 뒤에 와야 한다고 했는데

이는 bindingResult 바로 앞에 있는 파라미터가 바로 검증할 대상이기 때문이다.

 

그리고 bindingResult는 자동으로 Model에 들어가기 때문에 오류가 생성되고 다시 입력 폼으로 돌아갈 때 따로 Model에 넣어주는 작업을 하지 않아도 된다.

 


version3

v2까지는 사용자 입력 시 오류가 나면 사용자가 입력한 값이 사라지고 오류메세지가 등장했다.

v3에서는 사용가 입력한 값을 그대로 유지할 것이다.

 

우선 FieldError의 생성자가 하나 더 존재한다. 총 2개다.

 

-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)

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

field : 오류 필드

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

bindingFailure : 타입 오류 같은 바인딩 실패인지, 검증 실패인지 구분 값 (타입 오류 바인팅 -> True, 검증 실패 -> False)

codes : 메세지 코드(뒤에서 설명)

arguments : 메세지에서 사용하는 인자(뒤에서 설명)

defaultMessage : 기본 오류 메세지

 

 

1. 특정 필드가 오류가 났을 때

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

 

2. 특정 필드의 범위를 넘어서는 오류가 났을 때

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));
} }

 

objectError도 FieldError와 유사한 생성자를 제공한다.

 

 


version4

v4에서 부터는 오류 메세지를 체계적으로 다룰 것이다.

위의 FieldError 두번째 생성자에서 codes와 arguments가 오류 발생시 오류코드로 메세지를 찾기 위해서 사용된다.

 

먼저 오류메세지를 보관하는 파일을 생성해야한다.

messages.properties를 사용해도 되지만, 오류 메세지를 구분하기 쉽게 errors.properties라는 파일을 만든다

그러면 application.properties에 errorsf를 추가해야한다.

ex ) spring.messages.basename = messages, errors

 

 

-errors.properties 

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

 

1. 특정 필드가 오류가 났을 때

if (!StringUtils.hasText(item.getItemName())) {
            bindingResult.addError(new FieldError("item", "itemName", item.getItemName(), false
            ,new String[]{"required.item.itemName"}, null, null)); }

 

2. 특정 필드의 범위를 넘어서는 오류가 났을 때

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));
}

 

메세지 코드는 하나가 아니라 배열로 여러 값을 전달 할 수 있고, 순서대로 매칭해서 처음 매칭되는 메세지가 사용된다.

arguments는 Object[]{값1, 값2} 가 코드의 {0}, {1}로 치환할 값을 전달한다.

 


version5

v5에서는 fieldError와 ObjectError를 번거롭게 직접 생성하지 않고 BindingResult가 제공하는 rejectValue(), reject()를 사용하면 깔끔하게 검증 오류를 다룰 것 이다.

 

우선 컨트롤러에서 BindingResultsms 검증해야할 객체 바로 뒤에 오게 한다. 그래서 이미 검증해야할 객체를 알고있다.

따라서 rejectValue(),reject()에서는 검증해야할 객체를 생략할 수 있다.

 

-rejectValue()

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

 

field : 오류 필드명

errorCode : 오류코드(뒤에서 설명)

errorArgs : 오류 메세지에서 {0}, {1} 등을 치환하기 위한 값

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

 

-reject()

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

(rejectValue() 파라미터와 같은 설명임)

 

 

1. 특정 필드가 오류가 났을 때

//상품명 검증
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);
}

 

2. 특정 필드의 범위를 넘어서는 오류가 났을 때

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

}

 

특정 필드가 오류가 났을 때에는 rejectValue()를 사용했고 아닌 경우에는 reject()를 사용했다.

그리고 또 한가지 주목해야 할 점은 오류 코드를 모두 입력하지 않고 required처럼 간단하게 입력해도 알아서 잘 찾아서 오류코드를 출력해 준다는 것이다. 이 부분을 이해하려면 MessageCodesResolver를 알아야 하는데 이건 뒤에 설명할 것이다.

 


어떤 식으로 오류 코드를 설계할 것인가

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

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

#Level 2
required: 필수 값 입니다.

그렇다면 이렇게 메세지 추가만으로 편리하게 오류 메세지를 관리 할 수는 없을까?

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

 


MessageCodesResolver

MessageCodesResolver는 검증 오류 코드로 메세지 코드들을 생성한다

위에서 봤던 rejectValue(), reject()는 내부에서 MessageCodesResolver를 사용하는데 여기에서 메세지 코드들을 생성한다.

FieldError, ObjectError의 생성자를 보면 오류 코드를 여러개 담을 수 있다. 

그 여러개의 오류 코드들을 MessageCodesResolver가 만들어 주는 것이다.

 

 

-DefaultMessageCodesResolver의 기본 메세지 생성규칙

(DefaultMessageCodesResolver는  MessageCodesResolver의 기본 구현체)

 

#1 객체 오류

   1. : code + "." + object name
   2. : code

   예시) 오류코드: required, object name = item
            1. : required.item
            2. : required
   

 

#2 필드 오류

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"

 

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

 

정리:

1. rejectValue() 호출
2. MessageCodesResolver를 사용해서 검증 오류 코드로 메세지 코드들을 생성
3. new FieldError(), new ObjectError()를 생성하면서 메세지 코드들을 보관
4. th:errors에서 메세지 코드들로 메세지를 순서대로 메세지에서 찾고, 노출(뒤에서 설명)

version6

검증로직이 컨트롤러에 너무 길게 들어가 있다.

분리 시켜보자.

 

스프링은 검증을 체계적으로 제공하기 위해 다음 인터페이스를 제공한다.

public interface Validator {
    boolean supports(Class<?> clazz);
    void validate(Object target, Errors errors);
}

supports() {} : 해당 검증기를 지원하는 여부 확인(뒤에서 설명)

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

 

-ItemValidator

@Component
  public class ItemValidator implements Validator {
  
      @Override
      public boolean supports(Class<?> clazz) {
          return Item.class.isAssignableFrom(clazz);
      }
      
      @Override
      public void validate(Object target, Errors errors) {
      
          Item item = (Item) target;
          
          ValidationUtils.rejectIfEmptyOrWhitespace(errors, "itemName","required");
          
          if (item.getPrice() == null || item.getPrice() < 1000 ||item.getPrice() > 1000000) {
              errors.rejectValue("price", "range", new Object[]{1000, 1000000}, null);
          }
          
          if (item.getQuantity() == null || item.getQuantity() > 10000) {
              errors.rejectValue("quantity", "max", new Object[]{9999}, null);
          }
          
          
	//특정 필드 예외가 아닌 전체 예외
	if (item.getPrice() != null && item.getQuantity() != null) {
    	int resultPrice = item.getPrice() * item.getQuantity();
        if (resultPrice < 10000) {
        	errors.reject("totalPriceMin", new Object[]{10000,resultPrice}, null);
	} 
    
    	}
	} 
}

상품이름을 검증 할 때 ValidationUtils를 사용할 수 있는데 이것은 공백 같은 단순한 기능만 제공한다

 

그리고 ItemValidator를 컨트롤러에서 직접 호출 할 수도 있지만

WebDataBinder를 사용하면 스프링의 파라미터 바인딩의 역할을 해주고 검증 기능도 내부에 포함한다

 

-WebDataBinder사용

 

  컨트롤러에 다음을 추가

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

 


version7

@Validated적용

@Validated는 검증기를 실행하라는 애노테이션이다.

이 애노테이션을 사용하면 WebDataBinder에 등록한 검증기를 찾아서 실행한다.

그렇다면 여러 검증기가 등록되어 있을 때 어떤 검증기를 사용할지 구분이 필요하다.

이때 supports()가 사용된다.

 

@PostMapping("/add")
  public String addItemV6(@Validated @ModelAttribute Item item, BindingResult
  bindingResult, RedirectAttributes redirectAttributes){
  
  //로직
  
  }

 

이렇게 할 경우 supports(Item.class)가 호출되고 결과는 true이므로 ItemValidator의 validate()가 작동하는 것이다.

 

(참고로 @Validated를 사용할 경우 build.gradle 의존관계 추가가 필요하다)

 

 

 

(참고 : 인프런 김영한 강사님 - 스프링 MVC 2편 - 백엔드 웹 개발 활용 기술 )