요구사항: 검증 로직 추가

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

그리고 정상 로직보다 이런 검증 로직을 잘 개발하는 것이 어쩌면 더 어려울 수 있다.

 

참고: 클라이언트 검증, 서버 검증

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

 

검증 직접 처리 - 소개

상품 저장 검증 실패

목표

  • 만약 검증 오류가 발생하면 입력 폼을 다시 보여준다.
  • 검증 오류들을 고객에게 친절하게 안내해서 다시 입력할 수 있게 한다.
  • 검증 오류가 발생해도 고객이 입력한 데이터가 유지된다.

 

ValidationItemControllerV1 - addItem() 수정

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

	//검증 오류 결과를 보관
	Map<String, String> errors = new HashMap<>();
    
	//검증 로직
	if (!StringUtils.hasText(item.getItemName())) {
		errors.put("itemName", "상품 이름은 필수입니다.");
	}
	if (item.getPrice() == null || 
    	item.getPrice() < 1000 || 
        item.getPrice() > 1000000) {
		errors.put("price", "가격은 1,000 ~ 1,000,000 까지 허용합니다.");
	}
	if (item.getQuantity() == null || item.getQuantity() >= 9999) {
		errors.put("quantity", "수량은 최대 9,999 까지 허용합니다.");
	}
    
	//특정 필드가 아닌 복합 룰 검증
	if (item.getPrice() != null && item.getQuantity() != null) {
		int resultPrice = item.getPrice() * item.getQuantity();
		if (resultPrice < 10000) {
			errors.put("globalError", "가격 * 수량의 합은 10,000원 이상이어야 합니다. 현재 값 = " + resultPrice);
		}
	}
    
	//검증에 실패하면 다시 입력 폼으로
	if (!errors.isEmpty()) {
		model.addAttribute("errors", errors);
		return "validation/v1/addForm";
	}
    
	//성공 로직
	Item savedItem = itemRepository.save(item);
	redirectAttributes.addAttribute("itemId", savedItem.getId());
	redirectAttributes.addAttribute("status", true);
	return "redirect:/validation/v1/items/{itemId}";
}

검증 오류 보관

Map errors = new HashMap<>();

만약 검증시 오류가 발생하면 어떤 검증에서 오류가 발생했는지 정보를 담아둔다

 

검증 로직

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

검증시 오류가 발생하면 errors 에 담아둔다.

이때 어떤 필드에서 오류가 발생했는지 구분하기 위해 오류가 발생한 필드명을 key 로 사용한다.

이후 뷰에서 이 데이터를 사용해서 고객에게 친절한 오류 메시지를 출력할 수 있다.

 

검증에 실패하면 다시 입력 폼으로

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

만약 검증에서 오류 메시지가 하나라도 있으면 오류 메시지를 출력하기 위해

model 에 errors 를 담고 입력 폼이 있는 뷰 템플릿으로 보낸다.

 

남은 문제점

  • 타입 오류 처리가 안된다.
    이러한 오류는 스프링MVC에서 컨트롤러에 진입하기도 전에 예외가 발생하기 때문에
    컨트롤러가 호출되지도 않고, 400 예외가 발생하면서 오류 페이지를 띄워준다.
  • 타입 오류가 발생해도 고객이 입력한 문자를 화면에 남겨야 한다.
    만약 컨트롤러가 호출된다고 가정해도 결국 문자는 바인딩이 불가능하므로 고객이 입력한 문자가 사라지게 되고
    고객은 본인이 어떤 내용을 입력해서 오류가 발생했는지 이해하기 어렵다
  • 결국 고객이 입력한 값도 어딘가에 별도로 관리가 되어야 한다.

 


지금부터 스프링이 제공하는 검증 오류 처리 방법을 알아보자. 여기서 핵심은 BindingResult이다.

 

ValidationItemControllerV2 - addItemV1

@PostMapping("/add")
public String addItemV1(@ModelAttribute Item item, 
			BindingResult bindingResult,
			RedirectAttributes redirectAttributes) {
	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() > 10000) {
		bindingResult.addError(new FieldError("item", "quantity", "수량은 최대 9,999 까지 허용합니다."));
	}
    
	//특정 필드 예외가 아닌 전체 예외
	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";
	}
    
	//성공 로직
	Item savedItem = itemRepository.save(item);
	redirectAttributes.addAttribute("itemId", savedItem.getId());
	redirectAttributes.addAttribute("status", true);
	return "redirect:/validation/v2/items/{itemId}";
}

 

주의

BindingResult bindingResult 파라미터의 위치는 
@ModelAttribute Item item 다음에 와야 한다.

 

필드 오류 -  FieldError

// Field Error 생성자
// public FieldError(String objectName, String field, String defaultMessage) {}
if (!StringUtils.hasText(item.getItemName())) {
	bindingResult.addError(new FieldError("item", "itemName", "상품 이름은 필수입니다."));
}

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

  • objectName : @ModelAttribute 이름
  • field : 오류가 발생한 필드 이름
  • defaultMessage : 오류 기본 메시지

 

글로벌 오류 - ObjectError

//ObjectError 생성자
//public ObjectError(String objectName, String defaultMessage) {}
bindingResult.addError(new ObjectError("item", "가격 * 수량의 합은 10,000원 이상이어야 합니다. 현재 값 = " + resultPrice));

특정 필드를 넘어서는 오류가 있으면 ObjectError 객체를 생성해서 bindingResult 에 담아두면 된다.

  • objectName : @ModelAttribute 의 이름
  • defaultMessage : 오류 기본 메시지

 

BindingResult

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

 

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

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

 

남은 문제점

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

그런데 오류가 발생하는 경우 고객이 입력한 내용이 모두 사라진다. 이 문제를 해결해보자.

 

 

@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}";
}

 

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

 

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

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

여기서 rejectedValue 가 바로 오류 발생시 사용자 입력 값을 저장하는 필드다.

 

스프링의 바인딩 오류 처리

타입 오류로 바인딩에 실패하면 스프링은 FieldError 를 생성하면서 사용자가 입력한 값을 넣어둔다.

그리고 해당 오류를 BindingResult 에 담아서 컨트롤러를 호출한다.

따라서 타입 오류 같은 바인딩 실패시에도 사용자의 오류 메시지를 정상 출력할 수 있다.

 

오류 코드와 메시지 처리1

오류 메시지를 체계적으로 다루어보자.

ValidationItemControllerV2 - addItemV3() 추가

@PostMapping("/add")
public String addItemV3(@ModelAttribute Item item, BindingResult bindingResult, 
		RedirectAttributes redirectAttributes) {
	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.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));
		}
	}
	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}";
}

errors.properties 추가

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

 

FieldError 예시

//FieldError의 2번째 생성자
public FieldError(
            String objectName, 
            String field, 
            @Nullable Object rejectedValue, 
            boolean bindingFailure, 
            @Nullable String[] codes, 
            @Nullable Object[] arguments, 
            @Nullable String defaultMessage
		)

//range.item.price=가격은 {0} ~ {1} 까지 허용합니다.
new FieldError("item", "price", item.getPrice(), false, 
			new String[] {"range.item.price"}, new Object[]{1000, 1000000}
		)
  • codes : required.item.itemName 를 사용해서 메시지 코드를 지정한다.
    메시지 코드는 하나가 아니라 배열로 여러 값을 전달할 수 있는데
    순서대로 매칭해서 처음 매칭되는 메시지가 사용된다.
  • arguments : Object[]{1000, 1000000} 를 사용해서 코드의 {0} , {1} 로 치환할 값을 전달한다.

 

rejectValue() , reject()

BindingResult 가 제공하는 rejectValue() , reject() 를 사용하면

FieldError , ObjectError 를 직접 생성하지 않고, 깔끔하게 검증 오류를 다룰 수 있다

 

ValidationItemControllerV2 - addItemV4() 추가

@PostMapping("/add")
public String addItemV4(@ModelAttribute Item item, BindingResult bindingResult, 
		RedirectAttributes redirectAttributes) {
	log.info("objectName={}", bindingResult.getObjectName());
	log.info("target={}", bindingResult.getTarget());
	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.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);
		}
	}
	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}";
}

 

rejectValue()

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

파라미터

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

예시)

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

BindingResult 는 어떤 객체를 대상으로 검증하는지 target을 이미 알고 있다.

따라서 target(item)에 대한 정보는 없어도 된다. 오류 필드명은 동일하게 price 를 사용한다.

 

reject()

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

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

 


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 를 통해서 생성된 순서대로 오류 코드를 보관한다.

 

오류 코드 관리 전략

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

MessageCodesResolver 는 required.item.itemName 처럼 구체적인 것을 먼저 만들어주고,

required 처럼 덜 구체적인 것을 가장 나중에 만든다

 

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

모든 오류 코드에 대해서 메시지를 각각 다 정의하면 개발자 입장에서 관리하기 너무 힘들다.

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

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

 

ValidationUtils

스프링이 제공하는 ValidationUtils 클래스를 사용해보자.

ValidationUtils 사용 전

if (!StringUtils.hasText(item.getItemName())) {
	bindingResult.rejectValue("itemName", "required", "기본: 상품 이름은 필수입니다.");
}

ValidationUtils 사용 후

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

동작 방식

  • rejectValue() 호출
  • MessageCodesResolver 를 사용해서 검증 오류 코드로 메시지 코드들을 생성
  • new FieldError() 를 생성하면서 메시지 코드들을 보관

 

Validator 분리

컨트롤러에서 검증 로직이 차지하는 부분은 매우 크기 때문에 별도의 클래스로 역할을 분리하는 것이 좋다.

그리고 이렇게 분리한 검증 로직을 재사용 할 수도 있다.

 

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

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

public interface Validator {
	boolean supports(Class<?> clazz);
	void validate(Object target, Errors errors);
}
  • supports() {} : 해당 검증기를 지원하는 여부 확인
  • validate(Object target, Errors errors) : 검증 대상 객체와 BindingResult

 

ItemValidator 직접 호출하기

ValidationItemControllerV2 - addItemV5()

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 를 스프링 빈으로 주입 받아서 직접 호출했다.

 

@Validated 적용

ValidationItemControllerV2 - addItemV6()

@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}";
}

동작 방식

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

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

그런데 여러 검증기를 등록하면 그 중 어떤 검증기가 실행되어야 할지 구분이 필요하다.

이때 supports() 가 사용되고 여기서는 supports(Item.class) 호출되고,

결과가 true 이므로 ItemValidator 의 validate() 가 호출된다.

+ Recent posts