스프링 검증(Validation) : 사용자로부터 입력받은 값이 유효한가

byeol·2023년 3월 16일
0

오늘 "김영한님의 스프링 mvc2"를 통해 검증에 대해서 배웠다.
검증은 사용자로부터 입력 받은 데이터가 유효한 값인지 판단하는 과정이다.

만약 문자로 입력받아야 할 부분에 숫자를 입력했던지
원하는 값의 범위를 초과하던가
또는 거기에 못 미치는 등의 문제가 발생했을 때

이를 어떻게 걸러내고
어떻게 사용자에게 알려줄 것인가에 대해서 배운다.

전체적인 흐름은 아래와 같다.

Map에 에러 메시지 저장하고 Model에 저장하기
BindingResult 이용하기
errors.properties 활용하기
MessageCodesResolver
검증 로직을 컨트롤러에서 분리하기

오늘도 🏃‍♀️🏃‍♂️🏃🏃‍♂️🏃‍♂️

💡 Map에 에러 메시지 저장 ➡️ 스프링 BindingResult

가장 처음 접근 방법은 사용자로부터 입력받은 값들이 조건을 만족하지 않으면 그에 대한 에러메시지를 Map에 저장하는 것이다.

저장할 때 key는 필드가 되고 value는 오류 메시지가 된다.

Map에 에러메시지를 저장하는 Controller단

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

뷰 템플릿 : errors.containKey()errors?.의 차이

 <div th:if="${errors?.containsKey('globalError')}">
           <p class="field-error" th:text="${errors['globalError']}">전체 오류 메세지</p>
       </div>

errors.containKey()의 경우 errors에 아무것도 들어있지 않은 null의 경우 NullPointerException이 발생한다.

그러나 errors?.의 경우 errors가 null일 때 예외를 발생시키지 않고 null을 반환한다. th:if는 값이 비어있는 경우 아무것도 하지 않는다. 조건에 해당되지 않으므로 아무것도 하지 않는 것이다.


뷰 템플릿 : th:classth:classappend

<input type="text" id="price" th:field="*{price}"
                   th:class="${errors?.containsKey('price')}? 'form-control field-error':'form-control'"
                   class="form-control" placeholder="가격을 입력하세요">
<input type="text" id="price" th:field="*{price}"
                   th:classappend="${errors?.containsKey('price')}? 'field-error':_"
                   class="form-control" placeholder="가격을 입력하세요">

th:classappend는 클래스명에 추가 내용을 뒤에 붙여주는 것이다. 따라서 값이 있는 경우에는 <input type="text" class="form-control field-error">이 호출되고 없는 경우에는 _을 호출하도록 되어 있는데 _는 아무것도 하지 않는다는 뜻으로 원래 form-control을 class 값으로 두겠다는 의미이다.


뷰 템플릿 : Map의 값을 키를 통해 호출하는 방법 ${Map이름['key']}"

    <div class="field-error" th:if="${errors?.containsKey('itemName')}" th:text="${errors['itemName']}">

단점

하지만 이 방법에 문제가 있다.
타입 오류가 발생하는 경우 예를 들어 price에 숫자가 아닌 문자를 입력하는 경우
서버에 데이터가 넘어오기 전에 화면에 오류 페이지가 뜬다.

따라서 타입 오류가 발생하더라고 오류페이지로 넘어가지 않도록 하는 장치가 필요하다

그러나 넘어왔다고 하더라도 int price로 저장된 변수에는 정수만 저장될 수 있다. 문자는 저장할 수 없기 때문에 바인딩 과정에서 오류가 발생한다. 그래서 바인딩하지 않고 이 넘어온 값들을 보관할 수 있는 기능이 필요하다.

이를 제공하는 것이 스프링의 BindingResult이다.

💡 BindingResult

앞서 생겼던 문제점

  • 타입 오류가 발생하는 경우 Controller로 값이 넘어오지 않고 바로 400 오류 페이지를 뜨게 한다.
  • 넘어오게 하더라도 바인딩 시 오류가 발생한다.

이 둘의 문제를 해결해주는 BindingResult에 대해서 알아보자

BindingResult를 이용하는 Controller단

  public String addItemV1(@ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes) {
        //검증 오류 결과를 보관
        //Map<String, String> errors = new HashMap<>();

        // 검증 로직 (아무것도 입력 안되었을 때)
        if(!StringUtils.hasText(item.getItemName())) {
           bindingResult.addError(new FieldError("item","itemName","상품 이름은 필수입니다."));
        }
        
        
        ...
         // 특정 필드가 아닌 복합 룰 검증

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

** BindingResult는 무조건 @ModelAttribute Item item 뒤에 와야 한다.
그 이유는 BindingResult가 바로 이전에 오는 것은 targer 객체로 인식하기 때문이다.

오류를 저장할 때 FieldError 객체를 생성해서 넣어놓는다.

 if(!StringUtils.hasText(item.getItemName())) {
           bindingResult.addError(new FieldError("item","itemName","상품 이름은 필수입니다."));
        }
        
        // public FieldError(String objectName, String field, String defaultMessage) {}
  • objectName : targer 객체인 @ModelAttrubute 이름
  • field : 오류가 발생하는 필드 이름
  • defaultMessage : 오류 메시지

글로벌 오류는 FieldError가 아닌 ObjectError를 생성해서 저장한다. 이 경우는 오류가 발생하는 특정 필드가 없기 때문에 필드이름이 매개변수로 없다.

public ObjectError(String objectName, String defaultMessage) {}

BindingResult는 타임리프와 통합하여 오류 검증을 더 편리하게 만들어준다.

뷰 템플릿 : 글로벌 오류의 경우 "${#fields.hasGlobalErrors()}"

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

뷰 템플릿 : 에러가 발생한 경우 특정 클래스를 선택하게 하는 방법 th:errorclass="field-error"

   <input type="text" id="quantity" th:field="*{quantity}"
                   th:errorclass="field-error" class="form-control" placeholder="수량을 입력하세요">

뷰 템플릿 : 오류 내용 출력 th:errors="*{오류가 발생한 필드 이름}"

 <div class="field-error" th:errors="*{itemName}">

💡 FieldError, ObjectError를 활용해서 사용자가 잘못 입력한 데이터 화면에 남기기

Field 생성자는 두가지 있다.

public FieldError(String objectName, String field, String defaultMessage);
//new FieldError("item","itemName","상품 이름은 필수입니다.")

public FieldError(String objectName, String field, @Nullable Object
rejectedValue, boolean bindingFailure, @Nullable String[] codes, @Nullable
Object[] arguments, @Nullable String defaultMessage)

위에 보았듯이 첫번째 생성자를 이용했을 때 사용자가 잘못 입력한 값을 저장할 수 있는 공간이 없었다(저장할 변수가 없었다는 말)

하지만 두번째로 있는 생성자의 rejectedValue를 통해서 사용자가 잘못 입력한 값을 저장해서 화면에 보여줄 수 있다.

추가적으로 code와 argument라는 배열로 선언된 변수들이 있다.
이 부분은 errors.properties를 통해서 메시지 기능에 이용된다.

또한 boolean으로 선언된 bindingFalilure라는 매개변수는 현재 일어난 오류가 타입오류에 따른 바인딩 오류인지 검증 오류인지를 타나내는 부분이다.

예를 들어

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

이 조건으로 들어온 에러의 필드에러의 경우는 바인딩에 대한 오류가 아닌 검증 오류이므로 false로 선언되어져 있다.

💡 errors.properties 사용하기

FieldError 생성자의 메시지 코드와 메시지 인자 매개변수

FieldError 생성자 중에서 errors.properites를 활용할 수 있는 생성자는 아래와 같다.

public FieldError(String objectName, String field, @Nullable Object
rejectedValue, boolean bindingFailure, @Nullable String[] codes, @Nullable
Object[] arguments, @Nullable String defaultMessage)

여러개의 매개변수 중에서 "codes"와 "argument"는 errors.properties를 통해서 쉽게 오류 메세지를 출력할 수 있도록 도와준다.

활용

errors.properties가 아래와 같다고 하자

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

이 경우 Controller에서는

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

위와 같이 이용할 수 있다.
codes 부분은 String 배열이 들어가는데
메시지 코드를 배열로 넣을 수 있다.

new FieldError("item", "price", item.getPrice(), false, new String[]
{"range.item.price"}, new Object[]{1000, 1000000}

errors.properties의 메시지 코드 중에서 {}와 같은 괄호에는 argument가 들어간다.
따라서 인자 또한 위와 같이 Object 배열로 값을 넣어서 출력할 수 있게 도와준다.

💡 bindingResult.addError(new FieldError()) 보다 편리한 bindingResult.rejectValue()

그리고 bindingResult.addError(new ObjectError())보다 편리한 bindingResult.reject()

new FieldError()를 보면 매개변수가 너무 복잡하다. (ObjectError도 마찬가지로)
따라서 두 개의 생성자를 FieldError와 ObjectError를 생성하지 않는 rejectValue()와 reject()를 이용해보자

//before
 bindingResult.addError(new FieldError("item", "price", item.getPrice(), false, new String[]
{"range.item.price"}, new Object[]{1000, 1000000});
//after
bindingResult.rejectValue("price", "range", new Object[]{1000,
1000000}, null);

// == reject()==
//before
if (resultPrice < 10000) {
 bindingResult.addError(new ObjectError("item", new String[]
{"totalPriceMin"}, new Object[]{10000, resultPrice}, null));
 }
//after
if (resultPrice < 10000) {
 bindingResult.reject("totalPriceMin", new Object[]{10000,
resultPrice}, null);
 }

훨씬 깔끔하다.
보면 target 객체를 생략한다. 이미 bindingResult는 자기 이전에 오는 @ModelAttribute가 target 객체임을 알기 때문에 rejectValue()에서는 생략이 가능하다.

또한 errors.properties에서 메시지 코드를 찾을 때 FieldError 생성자보다 더 간단하게 String 배열이 아닌 String으로 찾는데 그 이유는 MessageCodesResolver가 저 String 문자열을 보고 알아서 String 배열을 생성해 주기 때문이다. 그 String 배열은 저 예시를 활용해서 "range"가 들어간 메시지 코드를 내부 우선순위에 따라 배열에 넣어 만든다. 그리고 그 배열에서 존재하는 메시지 코드를 선택한다. 선택의 기준은 좀 더 구체적인 것을 먼저 선택한다. 없으면 점점 범용적인 메시지 코드를 선택한다.

+) FieldError의 메시지 코드 배열이 선언되었다고 했을 때 메시지 코드 선택 기준은 MessageCodesResolver의 우선순위 기준으로 선택된다. 마찬가지로 구체적인 것에서 범용적인 순으로 선택된다.

따라서 정리하면

rejectValue()는 target 객체를 생략한다. 또한 MessageCodeResolver를 통해서 오류 메시지 코드를 배열이 아닌 String 객체로 표현한다.

💡 MessageCodeResolver가 어떻게 String만으로 메시지 코드를 생성하는가

테스트 코드 assertThat().containsExactly()

테스트 코드에서 assertThat().containsExactly()를 사용한다.
이는 개수는 물론 순서 또한 똑같은 것을 증명하기 위한 것인데
즉 생성되는 우선순위를 보기 위함이다.

테스트 1

//MessageCodesResolver : 인터페이스
// DefaultMessageCodesResolver : 기본 구현체
MessageCodesResolver codesResolver = new DefaultMessageCodesResolver();

 @Test
 void messageCodesResolverObject() {
   String[] messageCodes = codesResolver.resolveMessageCodes("required",
"item");
   assertThat(messageCodes).containsExactly("required.item", "required");
 }

객체 오류의 경우 두가지 정보를 가지고 메시지 코드를 만든다.
code와 객체이름이다.
1. code . 객체이름
2. code
순으로 만들어지는 것을 확인할 수 있다.
더 구체적인 것은 먼저 확인하고 이후 범용적인 것을 만들어 확인한다는 것을 알 수 있다.

테스트 2

//MessageCodesResolver : 인터페이스
// DefaultMessageCodesResolver : 기본 구현체
     MessageCodesResolver codesResolver = new DefaultMessageCodesResolver();

 @Test
void messageCodesResolverField() {
     String[] messageCodes = codesResolver.resolveMessageCodes("required",
"item", "itemName", String.class);
     assertThat(messageCodes).containsExactly(
                "required.item.itemName",
                "required.itemName",
                "required.java.lang.String",
                "required"
                 );
 }

필드 오류의 경우에는
code, 필드이름, 객체, 타입 4가지 정보를 가지고 메시지 코드를 생성한다.
우선순위는 아래와 같다.
1. code + . + 객체이름+.+필드이름
2. code + . + 필드이름
3. code + . + 타입
4. code

errors.properties 만들 때 전략 세우기

MessageCodesResolver의 우선순위 규칙에 따라 만들면 된다.
이미 개발자는 어떤 순서에 따라 메시지 코드를 생성해서 선택하는지 알기 때문이다.

💡 타입 오류 발생, 스프링이 직접 만든 오류 메시지를 출력할 때

개발자가 오류 메시지를 설정하지도 않았는데 스프링은 직접 만들어서 오류 메시지를 출력하는 경우가 있다.


가격에 A라는 문자를 입력해서 타입오류가 발생했다.
타입 오류의 메시지 코드는 개발자가 만들어서 설정한 것이 아니라 스프링이 직접 만든 오류 메시지이다.

따라서 타입 오류가 발생했을 때 스프링이 직접 만든 오류메시지가 아닌 개발자가 직접 등록한 오류 메시지로 바꾸고 싶다면 errors.properties에 아래와 같이 추가한다.

typeMismatch.java.lang.Integer=숫자를 입력해주세요.
typeMismatch=타입 오류입니다.

Controller와 검증 로직 분리하기

스프링이 제공하는 Validator 인터페이스

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

활용

따로 뺀 검증 로직

@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.getPrice() !=null && item.getQuantity()!=null){
            int resultPrice = item.getPrice() * item.getQuantity();
            if(resultPrice <10000){
                errors.reject("totalPriceMin",new Object[] {10000,resultPrice},null);
            }
        }

Controller

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

💡 @InitBinder와 @Validated : 검증기를 직접 호출하지 않고 스프링에서 호출하게 하는 방법

itemValidator.validate(item, bindingResult);

위와 같이 검증기를 직접 불러서 사용하지 않고
스프링 프레임 워크에서 호출하는 방법이 있다.

바로 @InitBinder라는 어노테이션을 이용하는 것이다.

@InitBinder
public void init(WebDataBinder dataBinder) {
 log.info("init binder {}", dataBinder);
 dataBinder.addValidators(itemValidator);
}

Controller에 위 코드르 추가하면 된다.
WebDataBinder의 역할

  • 브라우저를 통해서 요청받은 값이 실제 서버의 객체에 바인딩 될 때 중간 역할을 한다.
  • 타입 변환을 해주고 그 후 데이터 검증을 실시
  • 변환 결과나 에러는 BindingResult에 저장
  • addValidators() : 검증기 등록

@InitBinder이란

  • WebDataBinder를 초기화하기 위한 메서드에 붙는 어노태이션
  • @RequestMapping과 같은 어노테이션이 붙은 요청 처리 메서드에 명령어나 form으로 넘어온 인자들을 채우기 위해 사용
  • 속한 Controller의 모든 요청 전에 InitBinder를 선언한 메서드가 실행된다.

Controller에서 @ModelAttribute에 @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}";
}
  • @Validatedr가 WebDataBinder에 등록한 검증기를 찾아서 호출
  • 등록된 여러 검증기 중에서 앞서 Validator의 supports()메서드 이용
  • supports(Item.class)의 결과가 true -> ItemValitor의 validate() 호출

@InitBinder 없이 모든 컨트롤러 적용하기

main 함수에 아래와 같이 설정하기

@SpringBootApplication
public class ItemServiceApplication implements WebMvcConfigurer {

  public static void main(String[] args) {
 
      SpringApplication.run(ItemServiceApplication.class, args);
   }
 @Override
 public Validator getValidator() {
 
      return new ItemValidator();
  }
}

WebMvcConfigurer 인터페이스를 구현하여 getValidator 메서드를 오버라이딩 했다.
그러나 글로벌 설정을 직접 사용하는 경우는 드물다고 한다.

profile
꾸준하게 Ready, Set, Go!

0개의 댓글