개발놀이터

스프링 Validation 본문

Spring/Spring

스프링 Validation

마늘냄새폴폴 2021. 10. 23. 22:49

본 포스트는 김영한님의 인프런강의 스프링 MVC 2편 - 백엔드 웹 개발 활용 기술을 보고 정리한 포스트입니다. 자세한 내용은 강의를 참고해주세요

*검증


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

 


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

스프링이 제공하는 검증 오류 처리 방법으로 BindingResult가 있다. 검증오류를 담아두는 곳이라고 생각하면 편하다. 

앞으로 이 BindingResult를 점진적으로 발전시켜 더 나은 코드로 바꿀 것이다. 
-version1
   

@PostMapping("/add")
    public String addItemV1(@ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model) {
        //bindingResult는 위치가 중요한데 검증할 객체바로 뒤에 선언해야 한다. ↑Item바로 뒤에 선언한 것을 볼 수 있다. 

        //검증 로직
        if (!StringUtils.hasText(item.getItemName())) {
            bindingResult.addError(new FieldError("item", "itemName", "상품 이름은 필수입니다.")); //FieldError = 말그대로 필드에러이다. 
        }
        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() >= 9999) {
            bindingResult.addError(new FieldError("item", "quantity", "수량은 최대 9999까지 허용합니다."));
        }

        //특정 필드가 아닌 복합 룰 검증
        if (item.getPrice() != null && item.getQuantity() != null) {
            int resultPrice = item.getPrice() * item.getQuantity();
            if (resultPrice < 10000) {
                bindingResult.addError(new ObjectError("item", "가격 * 수량의 합은 10,000원 이상이어야 합니다. 현재 값 = " + resultPrice)); //ObjectError = 글로벌에러
            }
        }

        //검증에 실패하면 다시 입력 폼으로
        if (bindingResult.hasErrors()) {
            return "validation/v2/addForm";
        }

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



addError함수의 첫번째 인자로 objectName, 두번째로 field, 세번재로 defaultMessage를 입력받는다. 

objectName은 검증하는 오브젝트이다(@ModelAttribute이름을 적으면 된다). 위에서 설명했듯이 bindingResult는 검증해야하는 객체 바로 뒤에 선언되기 때문에 자신이 어떤 것을 검증해야 하는지 이미 알고있다. 때문에 objectName을 직접 하드코딩 할 수도 있지만 bindingResult.getObjectName()이렇게 넣을 수도 있다. 

field는 오류가 발생한 필드 이름을 적으면 된다. 타임리프로 th:field를 선언할 때 적는 필드 이름과 동일하게 적으면 타임리프에서 관리를 해준다.

defaultMessage는 오류 기본 메시지를 적으면 된다. 그럼 타임리프에서 th:field와 같은 이름을 가진 오류가 발생한다면 오류 기본 메시지를 가져와서 적어준다. 

        <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}" class="form-control"
                   th:errorclass="field-error" placeholder="이름을 입력하세요">
            <div class="field-error" th:errors="*{itemName}">
                상품명 오류
            </div>
        </div>


#field : #field로 BindingResult가 제공하는 검증 오류에 접근할 수 있다.
th:errors : 해당 필드에 오류가 있는 경우에 태그를 출력한다. th:if의 편의 버전이다.
th:errorclass : th:field에 지정한 필드에 오류가 있으면 class정보를 추가한다. 

BindingResult는 검증에 실패한 오류를 담아두는 기능 뿐만 아니라 @ModelAttribute에 바인딩되는 값이 일치하지 않을 때도 (Integer에 String이 담긴다던가) 컨트롤러를 호출해준다. 

ex) @ModelAttribute에 바인딩 시 타입 오류가 발생하면?
-BindingResult가 없으면 = 400오류가 발생하면서 컨트롤러가 호출되지 않고 오류 페이지로 이동한다.
-BindingResult가 있으면 = 오류정보를 BindingResult에 담아서 컨트롤러를 정상 호출한다. 


V1에서의 문제점은 사용자 입력 오류 메시지가 화면에 남도록 해야한다. 예를 들어 검증 로직이 가격을 1000원 미만으로 설정시 오류가 발생한다고 하면 가격을 100원으로 설정하면 100이라는 숫자가 그대로 사용자에게 보여져야 한다는 것이다. 

이를 해결하기 위해 FieldError와 ObjecError에 대해서 더 알아본다.

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

        //검증 로직
        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() >= 9999) {
            bindingResult.addError(new FieldError("item", "quantity", item.getQuantity(), false, null, null, "수량은 최대 9999까지 허용합니다."));
        }

        //특정 필드가 아닌 복합 룰 검증
        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()) {
            return "validation/v2/addForm";
        }

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



V1과 V2가 다른 점은 FieldError, ObjectError를 생성할 때 인자값을 더 많이 입력받는다는 점이다. 첫번째부터 알아보자
1. objectName : 오류가 발생한 객체 이름이다. 이는 @ModelAttribute의 객체 이름을 적어도 되고 bindingResult.getObjectName()을 입력해도 된다. 
2. field : 오류 필드이다. 타임리프를 사용할 때 th:field와 같은 이름으로 설정하면 된다. 
3. rejectedValue : 사용자가 입력한 값. 사용자가 100을 입력했다면 100이라는 숫자가 남게 된다. 이 자리에는 item.getPrice()를 넣으면 된다.
4. bindingFailure : 타입 오류같은 바인딩 실패인지, 검증 실패인지에 대한 구분 값이다. boolean타입이며 검증실패면 false 바인딩 실패면 true를 입력한다. 
5. codes : 메시지 코드. 뒤에서 등장할 메시지를 입력하는 곳이다.
6. arguments : 메시지에서 사용하는 인자값
7. defaultMessage : 기본 오류 메시지 

사용자가 입력한 값이 사용자 인터페이스에 남아있게 하고 싶으면 rejectedValue에 값을 넣어주면 된다. 


앞서 FieldError, ObjectError에서 제공하는 파라미터중 codes와 arguments는 메시지를 잘 활용하기 위해서 제공하는 파라미터이다. 오류 발생시 오류 코드로 메시지를 찾기 위해서 사용된다. 

error 메시지 파일을 생성하고 스프링부트에 메시지 설정을 추가해주면 메시지를 오류코드로 찾아서 뿌려줄 수 있다. 
1. errors.properties를 먼저 생성한다. 
2. application.properties에 spring.messages.basename=messages,errors를 추가한다. 
3. errors.properties에 
required.item.itemName=상품 이름은 필수입니다.
range.item.price=가격은 {0} ~ {1} 까지 허용합니다.
max.item.quantity=수량은 최대 {0} 까지 허용합니다.
totalPriceMin=가격 * 수량의 합은 {0}원 이상이어야 합니다. 현재 값 = {1}
를 추가한다. 

이제 이 errors.properties를 V3로 사용해보자

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

        //검증 오류 결과를 보관

        //검증 로직
        if (!StringUtils.hasText(item.getItemName())) {
            bindingResult.addError(new FieldError("item", "itemName", item.getItemName(), false, new String[]{"required.item.itemName"}, 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}, "가격은 1,000 ~ 1,000,000 까지 허용합니다"));
        }
        if (item.getQuantity() == null || item.getQuantity() >= 9999) {
            bindingResult.addError(new FieldError("item", "quantity", item.getQuantity(), false, new String[]{"max.item.quantity"}, new Object[]{9999}, "수량은 최대 9999까지 허용합니다."));
        }

        //특정 필드가 아닌 복합 룰 검증
        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}, "가격 * 수량의 합은 10,000원 이상이어야 합니다. 현재 값 = " + resultPrice));
            }
        }

        //검증에 실패하면 다시 입력 폼으로
        if (bindingResult.hasErrors()) {
            return "validation/v2/addForm";
        }

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



rejectedValue는 String배열로, arguments는 Object배열로 입력값을 넘겨주면 된다. 이렇게 FieldError와 ObjectError를 심도있게 사용해봤는데 아무래도 사용하기엔 무리가 있다. 너무 번거롭고 복잡하다. BindingResult가 제공하는 rejectValue()를 사용하면 FieldError, ObjectError를 직접 생성하지 않고, 깔끔하게 검증 오류를 다룰 수 있다. 

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

        //검증 로직
        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() >= 9999) {
            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()) {
            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()의 인자값으로 무엇을 넘기는지 알아보자
1. field : 오류 필드명
2. errorCode : 오류코드 (이 오류 코드는 메시지에 등록된 코드가 아니다. 뒤에서 설명할 messageResolver를 위한 오류코드이다.)
3. errorArgs : 오류 메시지에서 {0}를 치환하기 위한 값
4. defaultMessage : 오류 메시지를 찾을 수 없을 때 사용하는 기본 메시지

FieldError를 직접 다룰 때는 오류 코드를 required.item.itemName과 같이 모두 입력했다. 그런데 rejectValue를 사용하고부터는 오류코드를 required로 간단하게 입력했다. 그래도 오류 메시지를 잘 찾아서 출력한다. 무언가 규칙이 있는것처럼 보인다. 이 부분을 이해하려면 MessageCodesResolver를 이해해야한다. 


*MessageCodesResolver
오류 코드를 만들 때는 다음과 같이 자세히 만들수도 있고 단순하게 만들수도 있다. 
required.item.itemName=상품 이름은 필수입니다.
required=필수 값 입니다. 

단순하게 만들면 범용성이 좋아서 여러곳에서 사용할 수 있지만, 메시지를 세밀하게 작성하기 어렵다. 반대로 너무 자세하게 만들면 범용성이 떨어진다. 가장 좋은 방법은 범용성으로 사용하다가, 세밀하게 작성해야 하는 경우에는 세밀한 내용이 적용되도록 메시지에 단계를 두는 방법이다. 

MessageCodesResolver의 구현체인 DefaultMessageCodesResolver를 이용하여 errorCode, objectName, field를 넘기면 다음과 같은 규칙으로 에러코드들이 생성된다.
1. code + "." + object name + "." + field
2. code + "." + field
3. code + "." + field type
4. code

그래서 errorCode = required, objectName = item, field = itemName이렇게 세개를 넘기면 
1. required.item.itemName
2. required.itemName
3. required.java.lang.String
4. required
이렇게 네개가 만들어지는 것이다. 

rejectValue는 내부에서 MessageCodesResolver를 사용한다. 여기에서 메시지 코드들을 생성하는 것이었다. 그래서 rejectValue의 인자값으로 field 와 errorCode를 넣으면 저절로 오류코드를 생성해줬던 것이다. objectName은 BindingResult가 이미 알고있는 값이므로 MessageCodesResolver에 의하여 생성되는 것이었다. 

딱 보면 MessageCodesResolver는 꽤나 복잡하게 돌아가는 것처럼 보인다. 이렇게 복잡하게 왜 사용하는 것일까? 모든 오류 코드에 대해서 메시지를 각각 다 정의하면 개발자 입장에서 관리하기가 너무 힘들다. 크게 중요하지 않은 메시지는 범용성있는 required와 같은 메시지로 끝내고 정말 중요한 메시지는 꼭 필요할 때 구체적으로 적어서 사용하는 방식이 더 효율적이다. 

이제 메시지를 활용하여 오류코드를 다룰 수 있게 되었다. 하지만 바인딩시 타입 정보가 맞지 않을때 생기는 스프링이 자동적으로 생성해주는 오류메시지는 별로 이쁘지않다. 이 오류 내용을 사용자에게 그대로 보여줄 수는 없을것이다. 이 메시지도 이쁘게 다듬을 수 있을까?

바인딩시 타입 정보가 맞지 않을 때 스프링에서는 MessageCodesResolver를 이용하여 네가지 메시지 코드를 자동적으로 생성해준다.
1. typeMismatch.item.price
2. typeMismatch.price
3. typeMismatch.java.lang.Integer
4. typeMismatch

그렇다. 스프링은 타입 오류가 발생하면 typeMismatch라는 오류 코드를 사용해서 메시지를 뿌리고 있었던 것이었다. 그럼 errors.properties에 메시지를 추가해주면 내가 원하는 메시지를 사용자에게 보여줄 수 있을 것이다. 


*Validator
지금까지의 검증은 컨트롤러 안에서 이루어졌다. 하지만 이는 객체지향적이지 않다. 하나의 메서드에서는 하나의 역할만 하게 하기 위해서 우리는 Validator라는 것을 사용할 것이다. 

ItemValidator.java

@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 < 10000) {
                errors.reject("totalPriceMin", new Object[]{10000, resultPrice}, null);
            }
        }
    }
}


눈여겨 볼 만한 것은 바로 Validator라는 인터페이스를 implements했다는 것이다. 구현체로 supports, validate 두개를 오버라이드 하는 모습을 볼 수 있는데 supports는 Class를 인자값으로 받아와 ItemValidator가 실행될 때 validate를 실행할지 말지를 결정하는 구현체이다. 그리고 ItemValidator를 스프링빈에 컴포넌트스캔 대상으로 만들어 스프링이 관리하도록 만들어주었다. 

그렇게하여 만들어진 V5를 보자

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

        //검증 오류 결과를 보관
        itemValidator.validate(item, bindingResult);

        //검증에 실패하면 다시 입력 폼으로
        if (bindingResult.hasErrors()) {
            return "validation/v2/addForm";
        }

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


target으로 item을 Errors으로 BindingResult를 넘긴 것을 볼 수 있다. 모든 타입의 부모인 Object이기 때문에 item을 넘길 수 있고 Errors도 BindingResult의 부모이기 때문에 넘길 수 있었다. 

스프링이 Validator 인터페이스를 별도로 제공하는 이유는 체계적으로 검증 기능을 도입하기 위해서이다. 그런데 V5에서는 검증기를 직접 불러서 사용했고, 이렇게 사용해도 된다. 그런데 Validator 인터페이스를 사용해서 검증기를 만들면 스프링의 추가적인 도움을 받을 수 있다. 

바로 WebDataBinder를 통해서 사용하는 방법이다. WebDataBinder는 스프링의 파라미터 바인딩의 역할을 해주고 검증 기능도 내부에 포함한다. 컨트롤러에 다음 코드를 추가하자

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



이렇게 하면 @Validated 어노테이션을 사용하여 스프링의 도움을 받을 수 있다. 

    @PostMapping("/add")
    public String addItemV6(@Validated @ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model) {
        //검증에 실패하면 다시 입력 폼으로
        if (bindingResult.hasErrors()) {
            return "validation/v2/addForm";
        }

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

 

'Spring > Spring' 카테고리의 다른 글

스프링 필터, 인터셉터  (0) 2021.11.09
스프링 Bean Validation  (0) 2021.10.25
스프링 메시지 / 국제화  (0) 2021.10.18
타임리프심화1  (0) 2021.10.16
타임리프기본2  (0) 2021.10.15