WEB/Spring MVC 2

Spring MVC 2편 검증1 - Validation

Tony Lim 2021. 9. 5. 11:14

클라이언트가 준 입력이 우리의 Validation Requirement와 맞지 않을 때는
Model 에 잘못된 데이터와 오류의 원인을 담아서
다시 addForm 페이지를 보여주면서 클라이언트에게 어떤게 잘못되었는지를 알려주어야한다.

 

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

처음에는 Map을 이용하여 Error를 담게 된다. error 한개라도 생기면 그것을 model에 담아서 view로 넘어간다.

@ModelAttribute 는 Item을 model.addAttribute(item) 을 자동으로 해준다. error가 생겨서 view로 넘어가도 item+error가 생기는것이다.

 

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

errors?containsKey() 처럼 ? 가있어야 NullPointerException이 발생하는 대신 null을 반환을 해준다. 즉 그냥 무시해버린다.

 

Map을 사용할시에 단점

<label for="itemName" th:text="#{label.item.itemName}">상품명</label>
<input type="text" id="itemName" th:field="*{itemName}"
       th:class="${errors?.containsKey('itemName')} ? 'form-control field-error' : 'form-control'"
       class="form-control" placeholder="이름을 입력하세요">
<div class="field-error" th:if="${errors?.containsKey('itemName')}" th:text="${errors['itemName']}">
    상품명 오류
</div>

1. view 템플릿에서 중복처리가 많다. itemName을 계속해서 입력하고 있다.

2. Type오류 처리가 안된다. Item 필드값에 알맞지 않은 타입을 넣어주게되면 400예외가 발생하면서 오류페이지를 띄운다. Controller로 진입하기 전에 터지는 에러이다.

3. 2번 같은 에러가 나오기에 고객이 입력한 문자를 들고 있을 수 없다. 고객에게 뭘 입력했길레 틀렸는지 알려줄 수 가 없다.

 


BindingResult

    @PostMapping("/add")
    public String addItem(@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.getPrice()< 1000 || item.getPrice() > 1000000)
        {
            bindingResult.addError(new FieldError("item","itemName","가격은 1,000 ~ 1,000,000 까지 허용합니다."));

        }

        if(item.getQuantity() == null || item.getQuantity() >= 9999)
        {
            bindingResult.addError(new FieldError("item","itemName","수량은 최대 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}";
    }

기존의 Map<String, String> 에서 Error를 담아주던 역할을 BindingResult가 해준다.

FieldError는 스프링에서 제공해주는 클래스 , ObjectError의 자식이다.

하지만 글로벌 오류는 필드가 존재하지않는다. 따라서 ObjectError를 BindResult에 넣어준다. 

model.addAttribute("errors", errors);

이렇게 model 에 따로 노출시킬 필요없다. BindingResult는 알아서 view쪽으로 날라간다.

BindingResult 를 써줄때는 항상 @ModelAttribute Item item  다음 인자로 넣어줘야 뭔가 오류가 있을때 오류들을 넣어준다. 저 위에 우리가 넣어주는 new FieldError말고도 다른 값을 넣어준다. 
엉뚱한 타입을 입력받았을 때 나오는 TypeError 같은것을 넣어준다 

 

 

 

        if(!StringUtils.hasText(item.getItemName()))
        {
            bindingResult.addError(new FieldError("item","itemName","상품 이름은 필수 입니다."));
        }
        <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>

#fields로 BindingResult가 제공가는 검증오류에 접근가능하다

만약 글로벌에러 (위에서 ObjectError)가 있다면 loop를 돌면서 렌더링 해준다.

th:field를 보고(BindingResult에 itemName이 있는지 판단을 한다) 있으면 th:errorClass 가  class="form-control" 에 추가로 "field-error"를 넣을지 말지 판단을 한다.

th:errors="*{itemName}" 해당 필드에 오류가 있으면 해당 태그를 출력한다. 이 경우에는 field-error class가 추가 되어있는 div 태그를 보여주면서 상품명 오류를 보여줄것이다. 내부적으로 th:text 로 작동해서 아래처럼 나온다.

상품명 오류대신 FieldError 인자로 주어진 String이 출력된것을 볼 수 있다.

 

 

크게 3가지 방법으로 BindingResult는 사용이 된다.

 

1. @ModelAttribute할 당시 바인딩 오류가 발생하면? = 해당 필드랑 다른 타입의 값이 입력으로 주어짐

BindingResult가 없으면 400 오류페이지를 보내주지만 있으면 Spring이 Fielderror를 만들어서 BindingResult에 담아서 Controller를 호출한다.

 

2. 우리가 기존에 했던 방식처럼 비즈니스 로직에 관한 Error 처리를 BindingResult로 해줄 수 있다.

3. Validator 로 처리할 수 있다.

 

BindingResult는 인터페이스이고 Errors 인터페이스를 상속 받고 있다. 실제 넘어오는 구현체는 "BeanPropertyBindingResult" 이지만 이것말고도 여러 구현체들이 존재하고 BindingResult가 Errors에 비해 기능이 더 많다. 

 


 

잘못 입력한 Value를 그대로 화면에 유지하고싶다면

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

다른 생성자를 이용하면서 rejectedValue(사용자가 입력했다가 validate 되어버린 값을 넣는 곳)에 해당 item의 값을 가져오면 에러메세지와 함꼐 value는 그대로 유지한체 화면에 뿌려주게된다.

ObjectError 같은경우에는 다른 생성자에 rejectedValue가 따로 존재하지 않는다. 여러 필드들을 조합해서 나온 에러이기 때문에 바인딩이 실패하는 경우는 이미 처리 되었을 것이다.

아예 Integer 필드에 qqq 같은 String이와도 스프링이 내부적으로 Controller를 호출하기전에 저렇게 new FieldError를만들어서 qqq를 rejectedValue에 넣어주기에 나중에 써먹을 수 있다

th:field 가 똑똑하게 동작한다. 정상 상황에는 model 객체의 값을 이용하지만 오류가 발생하면 FieldError에 rejectvalue 값을 사용하여 렌더링한다.

 

 


오류 메시지들을 관리하는 방법

application.properties에 저렇게 작성해줘야 message관련된것을 messages Bundle과 error.properties를 참조해서 찾게된다.

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

위의 new FieldError생성자에서 code argument에 String[]의 형태로 required.item.itemName 같은 메세지 코드를 사용한다. 이 메세지 코드는 errors.properties에 적혀있는것들 중하나이고 arguments에 new Object[]{}로 형태로 인자를 전달해줄 수 있다.

new String[] 배열인 이유는 첫번째 것을 못찾으면 그 다음것으로...

 


 

좀더 간단하게 쓸 수있다. BindingResult는 어떤 객체를 자신이 관찰해야하는지 알고 있기 때문이다.

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

field error인 경우 rejectValue , global error (object error) 인 경우에는 reject 를 쓰면 된다.

range.item.price=가격은 {0} ~ {1} 까지 허용합니다.

rejectValue도 내부적으로 FieldError를 생성하는것이다. itemName=price , errorcode=range 에 각각 매핑된다.

1000,100000sms {0}, {1} 에 매핑된다.

위의 FieldError 생성자를 보면 규칙이 있다. new String[] "range.item.price" 차례대로 error.proerties 의 첫번쨰 인자 2번째는 관찰하고있는 객체의 이름 , 3번쨰는 해당 필드의 이름이다.

error.properties 에서 required = "" 와 required.item.itemName =""  처럼 2개가 존재하면 좀 더 자세한것이 우선순위를 가진다.  이 방식이 rejectValue에서 내부적으로 처리해주는 방식이다. 이렇게 하면 클라이언트코드를 건들필요가 없다.

 


MessageCode Resolver

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

bindingResult.rejectValue 에서는 실제로 resolveMessageCodes 를 내부적으로 호출하고 거기서 만들어지는 String[] 을 new FieldError 의 인자로 넣어주게 된다.

구체적인게 가장 우선순위가 높다(java.lang.String처럼 타입은 예외 , 아래 usecase를 살펴보자). 차례대로 훑으며 걸리는 놈을 뿌리게 된다.

 

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

#Level2 - 생략

#Level3
required.java.lang.String = 필수 문자입니다.
required.java.lang.Integer = 필수 숫자입니다.
min.java.lang.String = {0} 이상의 문자를 입력해주세요.
min.java.lang.Integer = {0} 이상의 숫자를 입력해주세요.
range.java.lang.String = {0} ~ {1} 까지의 문자를 입력해주세요.
range.java.lang.Integer = {0} ~ {1} 까지의 숫자를 입력해주세요.
max.java.lang.String = {0} 까지의 숫자를 허용합니다.
max.java.lang.Integer = {0} 까지의 숫자를 허용합니다.

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

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

#Bean Validation 추가

#NotBlank.item.itemName=상품 이름을 적어주세요.

#NotBlank={0} 공백X
Range={0}, {2} ~ {1} 허용
Max={0}, 최대 {1}

이런식으로 error.properties에 적혀있으면 범용성(Level4) 도 가져가고 개별화된 error message도 가능하다. 레벨이 낮을 수록 우선순위가 높다. application code에서 원하는 구체적인 error message는 level1에 추가하면 거기만 알아서 바뀌고 나머지는 그대로 유지될것이다.


스프링이 직접 검증 오류에 추가하는 경우

검증오류 코드는 크게 2가지인데 여태까지 개발자가 직접 작성한 rejectValue() 와 스프링이 직접 검증오류에 추가한 경우가 있다.(주로 타입정보가 맞지 않는것)

2번쨰의 경우 error.properties에 정의가 되어있지 않으면 이상한 긴 에러가 클라이언트 화면에 출력되게 된다.

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

이런식으로 추가해준다면 이것들이 화면에 출력이 된다. 이렇게 메세지가 분리되어있는 메커니즘을 스프링이 제공해준다. 

 


Validator

@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, 10000000}, 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);
            }
        }
    }
}

이런식으로 Validation logic을 따로 클래스로 만들고 @Component를 통해 빈으로 만들어 싱글톤으로 주입받아서 사용이 가능하다. 

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

        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 에 생성자 주입을 통해 넣어주고 사용하면 된다. 굳이 Validate Interface를 구현한 이유는 뭘까?

 


위의 itemValidator.validate() 를 spring단에서 알아서 처리 해줄 수 있게 해주는 방법이 있다.

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

Controller가 호출이될때마다 WebDataBinder가 내부적으로 만들어지고 우리가 만든 Validator를 넣어 준다.
이렇게 되면 어떤 Controller 가 호출이되든지  먼저 검증을 해준다.
하지만 명시된 Controller단에서만 사용이 가능하다. 글로벌하게 적용되지 않는다.

 

    public String addItemV6(@Validated @ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model) {

        //검증에 실패하면 다시 입력 폼으로
        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 를 원하는 @ModelAttribute앞에 적용하면 된다.

@Validated 는 검증기를 실행하라는 뜻, webdatabinder를 에서 돌면서 support에 함당하는 validator를 찾아 적용시킨다.

검증기가 여러개일 땐 어떻게 해야할꼬? 이떄 ItemValidator 의 support 메소드가 실력을 발휘한다. itemvalidator를 food에 적용할 수 없을 것이다 support 때문에