Spring

[Spring] 유효성 검증 - Validation

이덩우 2023. 7. 21. 18:18

지금까지 만든 웹 애플리케이션은 폼 입력 시 바인딩 오류가 발생하면 400 Bad Request 화면으로 이동했다.

아마 사용자 입장에서 이런 오류 페이지를 만난다면 당황할 것이다.

또한 입력값을 공백으로 비워놔도 정상처리가 됐다.

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

입력값의 유효성을 검증하고 처리하는 기능을 추가해보자.

순수 자바 코드로 검증 로직을 처리하는 것을 시작으로,

스프링이 지원하는 검증 기능을 적용하는 단계까지 점진적으로 발전시켜 나가볼 것이다.

 

 


 

- 요구사항 추가

상품 관리 웹 애플리케이션에 새로운 요구사항이 추가되었다.

아래의 사진과 같이 유효성 검사를 해야한다.

검증 처리 결과 사진

 

타입 검증 

  • 가격, 수량은 Integer 타입으로 입력받아야 한다. 문자가 들어가면 검증 오류 처리

필드 검증

  • 상품명 : 필수로 입력 받아야 한다. 공백 시 검증 오류 처리
  • 가격 : 1,000원 이상 1,000,000원 이하
  • 수량 : 최대 9,999개

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

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

 


 

- 클라이언트 검증? 서버 검증?

단순히 클라이언트 측에서 JS로 검증하면 쿼리파라미터를 조작할 수 있으므로 보안에 매우 취약하다.

그렇다고 서버만으로 검증하면, 즉각적인 반응성이 부족해진다.

따라서 둘을 적절히 섞어서 사용하는 것이 좋고 최종적으로 서버 검증은 필수이다.

 

 


 

- 검증 로직 직접 작성

검증 실패시 흐름

컨트롤러 단에 검증 로직을 추가 하고, 검증 오류 발생 시 입력 데이터를 지닌 채 다시 입력 폼 화면으로 보내줄 것이다.

 

최초 입력 폼으로부터 데이터를 받는 컨트롤러에 검증 로직을 직접 추가해보자.

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

        // 검증 실패한게 있으면 다시 입력 폼으로
        if (!errors.isEmpty()) {
            log.info("errors = {}", errors);
            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}";
    }
  • 오류 정보를 담을 맵을 생성한다.
  • 필드 에러가 발생하면 해당 필드 이름을 Key로, 렌더링 할 오류 메시지를 Value로 담는다.
  • 특정 필드 에러가 아닌 복합적 에러가 발생하면 'globalError' 를 Key로, 렌더링 할 오류 메시지를 Value로 담는다.
  • 이후 에러 맵이 비워져 있지 않다면, 모델에 에러정보를 담아 다시 입력 폼 뷰로 반환한다.
  • 에러가 없다면 정상 로직을 실행한다.

뷰 렌더링은 중요한 부분만 살펴보자.

    <form action="item.html" th:action th:object="${item}" method="post">
        <div th:if="${errors?.containsKey('globalError')}">
            <p class="field-error" th:text="${errors['globalError']}">복합 에러 표시</p>
        </div>
        .
        .
        .
    </form>
  • 컨트롤러에서 에러 정보인 errors를 모델에 담아 뷰로 보냈다.
  • ${errors}로 접근해 해당 오류 정보에 접근할 수 있다.
  • 근데 보면 ${errors?.containsKey(...)}로 접근하고 있다.
  • '?' 는 무슨 역할일까?
  • '?' 는 Safe Navigation Operator이다. 
  • 지금은 @PostMapping 방식의 메소드로 입력 폼 데이터가 들어왔다가 검증 오류가 있어 다시 입력 폼으로 돌아온 경우이다.
  • 하지만 최초 @GetMapping 메소드로 비워져 있는 입력 폼을 받았을 때는 errors 자체가 존재하겠는가?
  • 무조건 null이다.
  • 따라서 @GetMapping으로 최초 접근 시 항상 NullPointerException이 발생한다.
  • NPE를 방지하기 위해서 Safe Navigation Operator을 사용하면 NPE 대신 null을 반환해준다.
  • 따라서 조건에 만족하지 못하게 되므로 패스하는 방식인 것이다.

자 이제 어느정도 요구사항에 맞춰 유효성 검증을 하는 로직을 만들었다.

하지만 아직 해결하지 못한 부분이 있다.

현재 가격, 수량은 데이터 타입이 Integer로 선언되어있다.

입력 폼에서 해당 자리에 문자를 넣으면, 컨트롤러 안의 검증 로직에 진입조차 하지 못하고 400 Bad Request 페이지가 반환된다. 

컨트롤러에 일단 들어오면 검증 로직을 만들 수 있지만, 애초에 바인딩 오류가 발생하면 어떻게 해결할까?

 

 


 

- BindingResult 도입

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

BindingResult가 있으면 @ModelAttribute에 데이터 바인딩 시 타입 오류가 발생해도 컨트롤러가 호출된다.

 

이렇게 설명할 수 있겠다.

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

  • BindingResult가 없다 --> 400 오류 발생, 오류 페이지로 이동 (컨트롤러 호출 X)
  • BindingResult가 있다 --> 오류정보를 BindingResult에 담고 컨트롤러를 정상 호출

신기한 기능이다! 단순히 BindingResult객체를 파라미터로 추가하는 것 만으로 이렇게 동작이 된다.

참고로 @ModelAttribute 자리 바로 뒤에 BindingResult를 넣어줘야 정상적으로 인식된다.

그럼 기존에 HashMap을 만들어서 오류 정보를 보관했던 방식을 BindingResult에 담는 방식으로 바꿔보자.

 

    @PostMapping("/add")
    public String addItemV1(@ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model) {
        // 특정 필드 검증 로직
        if (!StringUtils.hasText(item.getItemName())) {
            bindingResult.addError(new FieldError("item", "itemName", "상품 입력은 필수입니다."));
        }
        
        .
        .
        .
       
        // 복합 룰 검증
        if (item.getPrice() != null && item.getQuantity() != null) {
            int result = item.getPrice() * item.getQuantity();
            if (result < 10000) {
                bindingResult.addError(new ObjectError("item", "가격 * 수량의 합은 10,000원 이상이어야 합니다. 현재 값 = " + result));
            }
        }

        // 검증 실패한게 있으면 다시 입력 폼으로
        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

  • bindingResult.addError() 메소드로 검증 오류 추가 가능
  • addError(new FieldError(오브젝트명(@ModelAttribute 이름) , 오류가 발생한 필드명, 오류 기본 메시지)) 형식으로 만들 수 있다.

글로벌 오류 - ObjectError

  • bindingResult.addError() 메소드로 검증 오류 추가 가능
  • addError(오브젝트명(@ModelAttribute 이름) , 오류 기본 메시지) 형식으로 만들 수 있다.

이전에 직접 검증 로직을 만들었을 때는 해시맵에 오류 정보를 담아서 모델에 추가한 뒤 뷰에 렌더링했지만,

BindingResult를 사용하면 자동으로 모델에 담아준다.

실제 뷰에서는 ${#fields}로 해당 오류에 접근할 수 있다.

 

여기까지 오면 기존처럼 직접 작성한 검증 로직에 대해서도 오류 메시지를 출력해줄 수 있고, 바인딩 에러가 발생해도 이제 400 에러 페이지로 이동하는 것이 아닌 지정한 오류 메시지를 출력해줄 수 있다.

 

하지만 미처 생각하지 못한 부분이 있다.

이전에 직접 검증 로직을 작성한 부분은 입력 폼으로 다시 돌아갈 때 입력했던 정보를 보여줬지만,

현재는 모두 공백으로 비워진 채 입력 폼으로 되돌아가게 된다.

이 문제는 어떻게 해결할까? 바로 뒤에서 알아보자.

 

정리해보면, BindingResult에 검증 오류를 적용하는 방법은 현재 두 가지 방식이었다.

  • 바인딩 자체가 실패할 경우 스프링이 알아서 FieldError를 생성해서 BindingResult에 넣어준다.
  • 개발자가 직접 검증 로직을 작성해 넣어준다.

뒤에서 설명하겠지만 Validator를 사용하는 방법까지 총 세 가지이다.

 

 


 

- FieldError & ObjectError

사실 FieldError는 두 가지 생성자를 제공한다.

첫 번째 생성자가 위에서 적용했던 방식이다.

첫 번째 생성자를 다시 살펴보면, 단순히 필드명과 기본 오류 메세지를 담고 있을 뿐, 기존의 어떤 값이 들어왔는지를 판단하는 부분이 없다.

 

설명할 두 번째 생성자에는 다양한 정보들이 포함되어있다.

  • FieldErrer(new FieldError(오브젝트명, 필드명, 사용자가 입력한 값, 바인딩오류인지 아닌지, 메시지 코드, 메시지 파라미터, 기본 오류 메시지))
  • 정말 정보가 많다. 예시로 아래를 보자.
bindingResult.addError(new FieldError("item", "itemName",
	item.getItemName(), false, null, null, "상품 이름은 필수입니다."));
  • 위와 같이 적용할 수 있고 여기서 중점적으로 볼 부분은 item.getItemName()이다.
  • 이렇게 이전에 @ModelAttribute로 받아온 입력 값을 다시 넣어줌으로써 기존 데이터를 보관한 채 되돌아갈 수 있는 것이다.
  • 메시지코드, 메시지 파라미터는 왜 사용하는 걸까?

 


 

- 메시지 코드 적용

이전 포스팅에서 배웠던 메시지 기능을 활용해볼 것이다.

어떤 오류 메시지는 간단하게 "타입 오류입니다." 라고 보여주고 싶을 수 있고,

중요도가 높은 데이터는 "상품명은 공백을 넣을 수 없습니다. 타입 오류입니다. " 이런식으로 자세하게 설명하고 싶을 수 있다.

 

스프링은 항상 그렇듯, 디테일하게 작성한 부분이 우선순위를 갖는다.

따라서 공통적으로 포함되는 부분을 중요도가 적은, 간단한 메세지로 만들어놓고

추가적으로 정보를 제공하고 싶다면 해당 부분만 디테일한 메시지 코드를 추가시켜주면 되는 것이다.

예시를 보자.

#==FieldError==
//Level1
required.item.itemName=상품 이름은 필수입니다.
range.item.price=가격은 {0} ~ {1} 까지 허용합니다.
max.item.quantity=수량은 최대 {0} 까지 허용합니다.

//Level2 - 생략
//Level3 - 생략

//#Level4
required = 필수 값 입니다.
min= {0} 이상이어야 합니다.
range= {0} ~ {1} 범위를 허용합니다.
max= {0} 까지 허용합니다.

이렇게 공통부분을 간결하게, 이 후로 점점 디테일하게 만드는 것이다.

 

FieldError에서 이 메시지 코드를 쓰는 부분을 보면 다음과 같다.

 bindingResult.addError(new FieldError("item", "quantity",
  item.getQuantity(), false, new String[]{"max.item.quantity"}, new Object[]
  {9999}, null));
  • new String[]{.....} 부분이 메시지 코드를 담는 부분이다.
  • 배열의 형태이다. 여러개의 메시지 코드를 담을 수 있다는 뜻이다.
  • 원하는 우선순위에 맞춰 순서대로 넣어줄 수 있겠다!

하지만 하나하나 넣어줘야 한다는건 개발자에게 귀찮은 일이다.

간단하게 requied만 딱 적으면, required에 관련된 디테일한 항목이 있는지 자동으로 판단해주고

적절하게 메시지 코드를 선택해주는 방법은 없을까?

 

 


 

- rejectValue(), reject() 도입

rejectValue(), reject()의 장점

  • 기존처럼 FieldError, ObjectError을 직접 생성하지 않고 검증 오류를 깔끔하게 처리할 수 있다.
  • 간결한 메시지 코드만 적어도 알아서 디테일한 부분을 찾을 수 있다.
  • rejectValue()는 기존의 FieldError, rejevt()는 기존의 ObjectError에 매칭된다.

사용 예시를 보자.

if (!StringUtils.hasText(item.getItemName())) {
          bindingResult.rejectValue("itemName", "required");
}

if (resultPrice < 10000) {
              bindingResult.reject("totalPriceMin", new Object[]{10000,
  resultPrice}, null);
}
  • rejectValue()는 단순하게 필드명, 에러코드만 넣어줬다.
  • 하지만 결과는 required.item.itemName을 찾게된다.
  • reject()는 당연하게 필드명을 제거해준 모습이고, 뒷 부분은 메시지 코드 인자를 전달하는 모습이다.

이렇게 간단하게 적었는데 디테일한 메시지 코드를 찾을 수 있는 이유가 무었일까?

단순하게 생각해보면 

"메시지 코드는 기본값이 required이고, @ModelAttribute 객체인 item이 붙고 그 뒤에 필드명이 붙는건가?"

라고 생각할 수 있다.

반은 맞았다! 추가적으로 얽혀있는 몇 가지 기능들이 있다.

그럼 해당 기능을 도대체 누가 대신 해주길래 이렇게 간단하게 코드를 사용할 수 있을까?

 

 


 

- MessageCodesResolver

rejectValue(), reject()의 내부에서 MessageResolver를 사용한다. 여기서 메시지 코드를 생성한다.

오류 코드를 만들 때 다음과 같이 자세하게 만들 수 있고 간단하게 만들 수 있다.

  • range.item.price : 상품의 가격 범위 오류입니다.
  • range : 범위 오류입니다.

단순하게 만들면 범용성이 좋아서 여러곳에서 사용할 수 있지만, 메세지를 명확하게 작성하지 못한다.

이와 반대로 너무 자세하게 만들면 특정 위치 이외에는 사용하지 못하기 때문에 범용성이 떨어진다.

 

이 MessageCodesResolver는 여러개의 에러 코드가 있을 때 더 자세한 것이 있으면 해당 에러 코드를 선택해 메시지를 출력해주고, 없다면 간단한 에러 코드가 있는지 탐색한다.

 

MessageCodesResolver는 인터페이스이고, 기본 구현체는 DefaultMessageCodesResolver이다.

주로 FieldError, ObjectError와 함께 사용되며

다음과 같은 규칙을 통해 자세한 코드 --> 간단한 코드 순서로 탐색하게 된다.

 

FieldError

  • 아래 순서로 4가지의 메시지 코드를 생성한다.
  • 1. code + "." + object name + "." + field name
  • 2. code + "." + field name
  • 3. code + "." + field type
  • 4. code

ObjectError

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

이렇게 내부적으로 MessageResolver 안에서 오류 코드들을 생성하고 FieldError, ObjectError의 생성자로 넘겨준다.

한참 전에 FieldError, ObjectError에서 메시지 코드를 넣어줄 때 왜 배열로 넘겨줄까? 라는 의문이 들었을 것이다.

이처럼 MessageResolver에서 간단한 레벨부터 디테일한 레벨의 메시지 코드를 모두 만들어서 넘겨주기 때문이다.

 

어떤 규칙으로 생성되는지 확인했으니 간단한 값을 디폴트로 깔아두고,

디테일을 원하는 항목이 있다면 해당 메시지 코드를 추가해 작성해주면 된다!

 

 


 

- 바인딩 오류 메시지 처리

BindingResult 객체를 사용해서, 바인딩 오류가 발생했을 때 400 Bad Request 오류 페이지로 이동하는 대신

오류 메시지를 반환하도록 만들 수 있었다. 하지만 오류 메시지가 마음에 안든다. 아래 사진을 보자

바인딩 오류 메시지

개발자라면 무슨 소린지 알겠지만 일반 사용자 입장에서 보면 갑자기 뭔 소린가 싶을 것이다.

 

스프링에서는 바인딩 오류가 발생하면 자동으로 FieldError를 추가해줬다.

그때 기본 오류 메시지로 해당 부분을 넣어놓은 것이다.

 

메시지를 바꾸려면? 마찬가지로 메시지 코드를 추가하면 된다!

#errors.properties

.
.
.

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

오류 코드의 이름을 typeMismatch로 만들기 때문에 원하는 레벨에 맞춰 메시지 코드를 추가하면 된다.

위 예시처럼 만든다면 더 디테일한 "숫자를 입력해주세요." 가 출력될 것이다.

출력 결과

 


 

- Validator 분리

원하는 검증 로직을 모두 만들었지만, 컨트롤러에서 검증 로직이 차지하는 부분이 매우 크다.

이런 경우 별도의 검증 클래스를 만들어 역할을 분리해주는 것이 바람직하다.

 

스프링은 이렇게 검증 클래스를 위한 인터페이스를 제공한다.

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

참고로, Errors는 BindingResult의 부모 클래스이다. 

초반에 addError()는 BindingResult에만 있는 메소드이지만 

더 편리한 rejectValue(), reject() 메소드는 Errors에도 존재한다.

관례상 BindingResult를 더 많이 사용한다.

 

이제 검증을 위한 클래스를 만들고 해당 인터페이스를 상속받자.

@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 result = item.getPrice() * item.getQuantity();
            if (result < 10000) {
                errors.reject("totalPriceMin", new Object[]{10000, result}, null);
            }
        }
    }
}
  • 실제 컨트롤러에 있던 검증 로직을 그대로 사용하면 된다. (객체이름 혼동 주의)
  • 이후 컨트롤러에서 Validator 객체를 생성하고 validate() 메소드를 사용하면 모든 검증 로직이 대체된다!

 

하지만 조금 더 간단하게 검증기 사용 부분을 적지도 않고 컨트롤러가 호출 될 때마다 알아서 검증기를 거치도록 만드는 방법도 있다.

public class ValidationController {

    private final ItemValidator itemValidator; // 의존관계 주입 되어있음
    
    @InitBinder
    public void init(WebDataBinder webDataBinder) {
        webDataBinder.addValidators(itemValidator);
    }
    
    public String addItemV5(@Validated @ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model) {
            // 검증기 안불러와도 됨
            
            // 검증 실패한게 있으면 다시 입력 폼으로
            if (bindingResult.hasErrors()) {
                log.info("errors = {}", bindingResult);
                return "validation/v2/addForm";
            }

            // 검증 성공 시 아래 실행
            .
            .
            .
     }
}
  • WebDataBinder는 스프링의 파라미터 바인딩의 역할을 해주고, 검증 기능도 내부에 포함한다.
  • @InitBiner 애노테이션을 붙인 메소드를 하나 만들고 WebDataBinder에 검증기를 추가시켜놓는다.
  • 이후 기존에 @ModelAttribute 로 받은 파라미터 앞에 @Validated 애노테이션을 추가한다.
  • 이제 컨트롤러는 모든 메소드가 호출될 때 검증기를 자동으로 적용할 수 있다.
  • @Validated 애노테이션이 붙은 메소드에만 적용된다!

 

 

 

출처 : 인프런, 김영한의 스프링 MVC 2편 - 백엔드 웹 개발 핵심 기술