본문 바로가기
Spring/스프링 MVC 활용

#4 검증 - Validation

by 히포파타마스 2021. 7. 28.

검증 - Validation

 

웹 서비스는 폼 입력 시 오류가 발생하면, 고객이 입력한 데이터를 유지한 상태로 어떤 오류가 발생했는지 친절하게 알려주어야 한다.

 

컨트롤러의 중요한 역할중 하나는 HTTP 요청이 정상인지 검증하는 것이다.

 

검증 오류 발생시 기본적인 흐름은 다음과 같이 이루어진다.

 

[기존 상품 등록 흐름]

 

[상품 검증 예시]

상품 등록 폼에서 받은 데이터가 검증에 실패할 경우 검증 오류 결과를 모델에 포함해서 상품 등록 폼에 전달한다.

상품 등록폼은 오류 결과가 들어있는 모델을 이용해서 어떤 오류가 발생했는지를 나타낸다.

 

 

 

 

1. BindingResult

스프링은 검증 오류가 발생했을 때, 오류가 발생한 객체와 필드, 메시지 등을 담을 수 있는 BindingResult라는 객체를 제공한다.

 

BindingResult를 사용해서 검증오류를 처리한 상품 등록 폼은 다음과 같다.

 

[상품 등록 - BindingResult]

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

	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() > 10000) {
	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));
		} 
	}
    
	if (bindingResult.hasErrors()) {
		log.info("errors={}", bindingResult);
		return "validation/v2/addForm"; }

	//성공 로직
	Item savedItem = itemRepository.save(item);
	redirectAttributes.addAttribute("itemId", savedItem.getId());
	redirectAttributes.addAttribute("status", true);
	return "redirect:/validation/v2/items/{itemId}"; 
}

 

[BindingResult 적용]

public String addItemV2(@ModelAttribute Item item, BindingResult bindingResult, 
	RedirectAttributes redirectAttributes)

BindingResult는 반드시 검증할 객체(Item) 뒤에 와야한다.

 

[필드 오류 - FieldError]

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

필드에 오류가 있으면 FieldError 객체를 생성해서 bindingResult에 담으면 된다.

FieldError에는 객체, 필드, 오류 메시지 등과 같은 정보들이 들어있으며 bindingResult는 자동으로 model에 넘어간다.

 

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 : 기본 오류 메시지

 

[글로벌 오류 - ObjectError]

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와 거의 같다.

 

 

 

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

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

 

[글로벌 오류 처리 - 타임리프]

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

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

 

[필드 오류 처리 - 타임리프]

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

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

th:errors 는 해당 필드에 오류가 있을 경우 태그를 출력한다. 

 

 

 

■ BindingResult 속성 - 타입 오류, 오류 적용,  입력 값 유지

□ 타입 오류

@ModelAttribute에 바인딩 시 타입 오류가 발생할 경우 400 오류가 발생하면서 컨트롤러가 호출되지 않는다.

하지만 BindingResult가 있으면 Spring은 오류정보(FiledError)를 담고 컨트롤러를 정상 호출한다.

 

□ 오류 적용

BindingResult에 검증 오류를 적용하는 방법은 크게 3가지가 있다.

 

· @ModelAttribute의 객체에 타입 오류 등으로 바인딩이 실패하는 경우 스프링이 FindError를 생성해서 BindigError에 넣어준다.

 

· 개발자가 직접 상황에 따라 넣어준다.

 

· Validator 사용

 

□ 입력 값 유지

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

 

FieldError의 rejectedValue가 오류 발생시 사용자 입력 값을 저장하는 필드이다. 

 

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

 

 

 

 

2. 오류 코드와 메시지 처리

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

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

 

오류 코드를 사용하기 위해 오류 메시지를 관리하는 파일인 errors.properties를 생성한다.

 

[errors.properties 추가]

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

 

errors.properties를 메시지로 사용하기 위해서는 application.propertiese에서 메시지 설정을 추가해야 한다.

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

 

spring.messages.basename=messages,errors

기본적으로 messages만 설정되어있기 때문에 errors를 추가로 설정해주어야 한다.

 

errors에 등록한 메시지는 다음과 같이 사용하면 된다.

 

[errors 메시지 사용 예]

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

 

· codes : 에러 코드를 사용해 에러 메시지를 지정한다. 메시지 코드는 하나가 아니라 배열로 여러 값을 전달할 수 있는데, 순서대로 매칭 해서 처음 매칭 되는 메시지가 사용된다.

 

· arguments : 에러 코드에서 치환될 부분이 값이 전달된다.

 

 

 

■ rejectValue & reject

오류 메시지를 출력하기 위해서는 FieldError나 ObjectError에 너무 많은 인자가 들어간다.

 

rejectValue & reject는 필요 최소한의 인자만 사용해서 자동으로 FieldError나 ObjectError를 생성해서 bindingResult에 넣어준다.

 

[rejectValue & reject 사용 예]

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는 항상 검증 객체의 뒤에 온다.

따라서 BindingResult는 어떤 객체를 대상으로 검증하는지를 알 수 있다.

이 때문에 resultValue는 인자로 검증 객체를 받지 않으며 필드만 입력하면 필드 정보와 타깃 정보를 조합해 FieldError를 자동으로 생성해서 BindingResult에 추가한다.

 

[rejectValue()]

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

rejectValue의 생성자이다.

 

파라미터는 다음과 같다.

 

· field : 오류 필드 명

· errorCode : 오류 코드(메시지에 등록된 코드 아님)

· errorArgs : 오류 메시지로 전달되는 치환 값

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

 

[rejectValue - errorCode]

if (item.getQuantity() == null || item.getQuantity() > 10000) {
	bindingResult.rejectValue("quantity", "max", new Object[]{9999}, null);
}

FieldError와는 다르게 rejectValue는 errorCode 부분에 "max"라고만 표기해도 error.properties에 등록된 max.item.quantity 라는 에러 코드 값을 제대로 출력해준다.

 

rejectValue & reject는 내부에 MessageCodesResolver를 사용해서 errorCode를 기반으로 메시지 코드들을 생성한다.

이때 생성되는 메시지 코드는 특별한 규칙에 의해 생성된다.

 

[rejectValue - 메시지 코드 생성 규칙]

1.: code + "." + object name + "." + field
2.: code + "." + field
3.: code + "." + field type
4.: code

예를 들어, 검증 객체가 item이고 rejectValue("itemName", "required")와 같이 작성하면 rejectValue는 메시지 코드를 다음과 같이 생성한다.

 

· required.item.itemName
· required.itemName
· required.java.lang.String
· required

 

이 메시지 생성 규칙 방식에 따르면 error.properties에 정의된 에러 코드 중 구체적인 순서에 따라 에러 코드가 결정된다고 볼 수 있다.

 

예를 들어 error.properties에 required.item.itemName과 required라는 오류 코드가 있다면 위의 예시에서는 메시지 코드 생성 순서상 required.item.itemName이 선택된다.

 

ObjectError를 생성하는 reject의 경우도 rejectValue와 거의 유사한 규칙으로 메시지 코드를 생성한다.

ex) reject("item", "totalPriceMin")의 경우 

      totalPriceMin.itme, totalPriceMin 가 생성된다.

 

타입 오류가 날 경우 스프링은 FieldError를 생성해서 BindingResult에 추가한다고 했다.

이 경우 생성되는 에러 코드는 "typeMismatch"를 기반으로 생성된다.

예를 들어 item 검증 객체의 price 필드에서 바인딩 오류가 발생했다면 다음과 같은 메시지 코드가 생성된다.

 

· typeMismatch.item.price
· typeMismatch.price
· typeMismatch.java.lang.Integer
· typeMismatch 

 

타입 오류가 났을 때 원하는 메시지를 출력하고 싶다면 위 예제의 규칙대로 에러 코드를 생성해 놓으면 된다.

만약 맞는 에러 코드가 없는 경우 스프링이 생성한 기본 메시지가 출력된다.

 

 

 

 

3. Validator

지금 까지 BindingResult와 rejectValue 메서드를 통해 검증 로직을 컨트롤러에 구현하였다.

그러나 이 방식은 컨트롤러에서 검증 로직이 차지하는 부분이 너무 많아진다.

때문에 검증 로직을 별도로 처리하는 Validator라는 것을 생성하고 컨트롤러에서는 Validator를 호출해서 검증을 시행한다.

 

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

 

[Validator]

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

 

· supports() : 해당 검증 기를 지원하는 여부 확인

· validate(Object target, Errors error) : 검증 대상 객체와 오류를 처리할 수 있는 객체(BindingResult)

 

[Validator - 검증 로직 적용]

@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;
        
		if (!StringUtils.hasText(item.getItemName())) {
			errors.rejectValue("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() >= 9999) {
			errors.rejectValue("quantity", "max", new Object[]{9999}, null);
		}

		//특정 필드가 아닌 복합 룰 검증
		if(item.getPrice() != null && item.getQuantity() != null) {
		int resultPrice = item.getPrice() * item.getQuantity();
			if (resultPrice < 100000) {
				errors.reject("totalPriceMin", new Object[]{10000, resultPrice}, null);
			}
		}
	}
}

supports에는 검증할 객체의 정보를 반환하게 한다.

validate에는 기존의 검증 로직을 작성한다.

 

[Validator 호출 - 컨트롤러]

private final ItemValidator itemValidator; 

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

	itemValidator.validate(item, bindingResult);
    
	if (bindingResult.hasErrors()) {
		log.info("errors={}", bindingResult);
		return "validation/v2/addForm"; 
	}
        
	//성공 로직
	Item savedItem = itemRepository.save(item);
	redirectAttributes.addAttribute("itemId", savedItem.getId());
	redirectAttributes.addAttribute("status", true);
	return "redirect:/validation/v2/items/{itemId}"; 
}

ItemValidator를 스프링 빈으로 주입받아서 직접 호출하였다.

 

실행하면 기존의 방식과 동일하게 검증 로직이 작동한다.

 

 

 

■ WebDataBinder

Validator 인터페이스를 사용해서 검증기를 만들면 스프링의 추가적인 도움을 받을 수 있다.

 

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

 

[WebDataBinder 적용]

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

위의 코드를 컨트롤러에 추가하면 해당 컨트롤러에서는 검증기를 자동으로 적용할 수 있다.

※ @InitBinder를 사용하면 해당 컨트롤러에만 영향을 준다.

 

[@Validated 적용]

@PostMapping("/add")
public String addItemV6(@Validated @ModelAttribute Item item, 
BindingResult bindingResult, RedirectAttributes redirectAttributes) {
	
	if (bindingResult.hasErrors()) {
		log.info("errors={}", bindingResult);
		return "validation/v2/addForm"; 
	}
	
	//성공 로직
	Item savedItem = itemRepository.save(item);
	redirectAttributes.addAttribute("itemId", savedItem.getId()); 
	redirectAttributes.addAttribute("status", true);
	return "redirect:/validation/v2/items/{itemId}"; 
}

validator를 직접 호출하는 부분이 사라지고 검증 객체 앞에 @Validated가 붙는다.

 

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

이 애노테이션이 붙으면 앞서 WebDataBinder에 등록한 검증기를 찾아서 실행한다.

검증기가 복수 등록되어있는 경우 supports()에 필요한 검증기가 선택된다.

'Spring > 스프링 MVC 활용' 카테고리의 다른 글

#6 로그인 처리 - 쿠키, 세션  (0) 2021.07.30
#5 Bean Validation  (0) 2021.07.28
#3 메시지, 국제화  (0) 2021.07.27
#2 타임리프 - 스프링 통합 폼  (0) 2021.07.27
#1 타임리프 - 기본 기능  (0) 2021.07.25

댓글