스프링

[스프링 MVC 2편] 검증1 - Validation

코딍코딍 2022. 7. 5. 16:54

검증 요구사항

요구사항: 검증 로직 추가

  • 타입 검증
    • 가격, 수량에 문자가 들어가면 검증 오류 처리
  • 필드 검증
    • 상품명: 필수, 공백X 가격: 1000원 이상, 1백만원 이하 수량: 최대 9999
  • 특정 필드의 범위를 넘어서는 검증
    • 가격 * 수량의 합은 10,000원 이상

클라이언트 검증, 서버 검증

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

먼저 검증을 직접 구현해보고, 뒤에서 스프링과 타임리프가 제공하는 검증 기능을 활용해보자.

 


검증 직접 처리 - 소개

상품 저장 성공

  • 사용자가 상품 등록 폼에서 정상 범위의 데이터를 입력하면, 서버에서는 검증 로직이 통과하고, 상품을 저장하고, 상품 상세 화면으로 redirect한다.

상품 저장 검증 실패

  • 고객이 상품 등록 폼에서 상품명을 입력하지 않거나, 가격, 수량 등이 너무 작거나 커서 검증 범위를 넘어서면, 서버 검증 로직이 실패해야 한다.
  • 이렇게 검증에 실패한 경우 고객에게 다시 상품 등록 폼을 보여주고, 어떤 값을 잘못 입력했는지 친절하게 알려주어야 한다.

이제 요구사항에 맞추어 검증 로직을 직접 개발해보자.

 


검증 직접 처리 - 개발

상품 등록 검증

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<>();
  • 만약 검증시 오류가 발생하면 어떤 검증에서 오류가 발생했는지 정보를 담아둔다.

addItem.html 수정

 

Safe Navigation Operator

errors?.containsKey('globalError')
errors?. 은 errors 가 null 일때 NullPointerException 이 발생하는 대신, null 을 반환하는 문법이다.

 

정리

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

남은 문제점

  • 뷰 템플릿에서 중복 처리가 많다. 
  • 타입 오류 처리가 안된다.
  • 고객이 입력한 값도 어딘가에 별도로 관리가 되어야 한다.
    • Item 의 price 는 Integer 이므로 문자를 보관할 수가 없기 때문

 

이제 스프링이 제공하는 검증 방법을 하나씩 알아보자.

 


프로젝트 준비 V2

ValidationItemControllerV2 컨트롤러 생성

  • hello.itemservice.web.validation.ValidationItemControllerV1 복사
  • hello.itemservice.web.validation.ValidationItemControllerV2 붙여넣기
  • URL 경로 변경: validation/v1/ validation/v2/

템플릿 파일 복사

  • validation/v1 디렉토리의 모든 템플릿 파일을 validation/v2 디렉토리로 복사
  • /resources/templates/validation/v1/ 복사
  • /resources/templates/validation/v2/ 붙여넣기
  • /resources/templates/validation/v2/ 하위 4개 파일 모두 URL 경로 변경: validation/v1/ => validation/v2/
    • Ctrl+Shift+R 명령어 이용해 쉽게 리팩토링 할 수 있다.

 


BindingResult1

스프링이 제공하는 검증 오류 처리 방법을 알아보자. 여기서 핵심은 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}";
}
  • 메서드 이름 변경: addItem() => addItemV1()
  • BindingResult bindingResult 파라미터의 위치는 @ModelAttribute Item item 다음에 와야 한다.
    • BindingResult 객체가 Item 객체의 바인딩 결과를 담고있기 때문이다.
  • BingingResult는 자동으로 Model에 들어가기에 따로 Model에 담을 필요가 없다.

필드 오류 - FieldError

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

#FieldError 생성자 요약
public FieldError(String objectName, String field, String defaultMessage) {}
  • 필드에 오류가 있으면 FieldError 객체를 생성해서 bindingResult 에 담아두면 된다.
    • 필드 오류는 FieldError 객체에 담는다.
  • objectName : @ModelAttribute 이름
  • field : 오류가 발생한 필드 이름
  • defaultMessage : 오류 기본 메시지

글로벌 오류 - ObjectError

bindingResult.addError(new ObjectError("item", "가격 * 수량의 합은 10,000원 이상이어야
						합니다. 현재 값 = " + resultPrice));
                        
#ObjectError 생성자 요약
public ObjectError(String objectName, String defaultMessage) {}
  • 특정 필드를 넘어서는 오류가 있으면 ObjectError 객체를 생성해서 bindingResult 에 담아두면 된다.
    • 글로벌 오류는 ObjectError 객체에 담는다.
  • objectName : @ModelAttribute 의 이름
  • defaultMessage : 오류 기본 메시지

 

validation/v2/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>
     <div>
         <label for="price" th:text="#{label.item.price}">가격</label>
         <input type="text" id="price" th:field="*{price}"
         	th:errorclass="field-error" class="form-control" placeholder="가격을 입력하세요">
         <div class="field-error" th:errors="*{price}">
         	가격 오류
         </div>
     </div>
     <div>
         <label for="quantity" th:text="#{label.item.quantity}">수량</label>
         <input type="text" id="quantity" th:field="*{quantity}"
         	th:errorclass="field-error" class="form-control" placeholder="수량을 입력하세요">
         <div class="field-error" th:errors="*{quantity}">
         	수량 오류
         </div>
     </div>
  • 타임리프는 스프링의 BindingResult 를 활용해서 편리하게 검증 오류를 표현하는 기능을 제공한다.
  • #fields : #fields 로 BindingResult 가 제공하는 검증 오류에 접근할 수 있다.
  • th:errors : 해당 필드에 오류가 있는 경우에 태그를 출력한다. th:if 의 편의 버전이다.
  • th:errorclass : th:field 에서 지정한 필드에 오류가 있으면 class 정보를 추가한다.

 


BindingResult2

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

 

@ModelAttribute에 바인딩 시 타입 오류가 발생하면? (정수 입력해야 하는 태그에 문자열 입력!)

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

 

BindingResult에 검증 오류를 적용하는 3가지 방법

  • @ModelAttribute 의 객체에 타입 오류 등으로 바인딩이 실패하는 경우 스프링이 FieldError 생성해서 BindingResult 에 넣어준다.
  • 개발자가 직접 넣어준다.
    • bindingResult.addError()
  • Validator 사용

주의

BindingResult 는 검증할 대상 바로 다음에 와야한다. 순서가 중요하다. 예를 들어서 @ModelAttribute Item item , 바로 다음에 BindingResult 가 와야 한다.
BindingResult 는 Model에 자동으로 포함된다.

 

BindingResult , FieldError , ObjectError 를 사용해서 오류 메시지를 처리하는 방법을 알아보았다. 그런데 오류가 발생하는 경우 고객이 입력한 내용이 모두 사라진다. 이 문제를 해결해보자.

 


FieldError, ObjectError

사용자 입력 오류 메시지가 화면에 남도록 하자.

예) 가격을 1000원 미만으로 설정시 입력한 값이 남아있어야 한다.

 

ValidationItemControllerV2 - addItemV2

@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 생성자

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);
  • FieldError 는 두 가지 생성자를 제공한다.
  • 파라미터 목록
    • objectName : 오류가 발생한 객체 이름
    • field : 오류 필드
    • rejectedValue : 사용자가 입력한 값(거절된 값)
    • bindingFailure : 타입 오류 같은 바인딩 실패인지, 검증 실패인지 구분 값
    • codes : 메시지 코드
    • arguments : 메시지에서 사용하는 인자
    • defaultMessage : 기본 오류 메시지

 

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

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

사용자의 입력 데이터가 컨트롤러의 @ModelAttribute 에 바인딩되는 시점에 오류가 발생하면 모델 객체에 사용자 입력 값을 유지하기 어렵다. 예를 들어서 가격에 숫자가 아닌 문자가 입력된다면 가격은 Integer 타입이므로 문자를 보관할 수 있는 방법이 없다. 그래서 오류가 발생한 경우 사용자 입력 값을 보관하는 별도의 방법이 필요하다. 그리고 이렇게 보관한 사용자 입력 값을 검증 오류 발생시 화면에 다시 출력하면 된다. FieldError 는 오류 발생시 사용자 입력 값을 저장하는 기능을 제공한다.

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

 

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

th:field="*{price}" 타임리프의 th:field 는 매우 똑똑하게 동작하는데, 정상 상황에는 모델 객체의 값을 사용하지만, 오류가 발생하면 FieldError 에서 보관한 값을 사용해서 값을 출력한다.

 

스프링의 바인딩 오류 처리

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

 


오류 코드와 메시지 처리

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

 

errors 메시지 파일 생성

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

 

 

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

application.properties

//spring.messages.basename=messages => Default
spring.messages.basename=messages,errors
  • 먼저 스프링 부트가 해당 메시지 파일을 인식할 수 있게 다음 설정을 추가한다. 이렇게하면 messages.properties , errors.properties 두 파일을 모두 인식한다. (생략하면 messages.properties 를 기본으로 인식한다.)

 

errors.properties 추가

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

 

 

errors 에 등록한 메시지를 사용하도록 코드를 변경해보자

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}";
}
  • public FieldError(String objectName, String field, Object rejectedValue, boolean bindingFailure, String[] codesObject[] arguments, String defaultMessage)
  • codes : required.item.itemName 를 사용해서 메시지 코드를 지정한다. 메시지 코드는 하나가 아니라 배열로 여러 값을 전달할 수 있는데, 순서대로 매칭해서 처음 매칭되는 메시지가 사용된다.
  • arguments : Object[]{1000, 1000000} 를 사용해서 코드의 {0} , {1} 로 치환할 값을 전달한다.

 


오류 코드와 메시지 처리2

컨트롤러에서 BindingResult 는 검증해야 할 객체인 target 바로 다음에 온다. 따라서 BindingResult 는 이미 본인이 검증해야 할 객체인 target 을 알고 있다.

 

rejectValue() , reject()

BindingResult 가 제공하는 rejectValue() , reject() 를 사용하면 FieldError , ObjectError 를 직접 생성하지 않고, 깔끔하게 검증 오류를 다룰 수 있다.

 

rejectValue() , reject() 를 사용해서 기존 코드를 단순화

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)
FieldError() 를 직접 다룰 때는 오류 코드를 range.item.price 와 같이 모두 입력했다. 그런데 rejectValue() 를 사용하고 부터는 오류 코드를 range 로 간단하게 입력했다. 그래도 오류 메시지를 잘 찾아서 출력한다. 이 부분을 이해하려면 MessageCodesResolver 를 이해해야 한다.

 

reject()

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

 


오류 코드와 메시지 처리3