Spring MVC : Validation

    인프런의 김영한님의 강의를 듣고 개인적으로 정리했습니다. 

     

    Validation → HTTP 요청이 정상적인지 검증하는 과정! 

    서버에서 정상적인 값이 왔는지 확인하는 것은 굉장히 중요하다. 클라이언트 쪽에서도 어느정도의 검증은 가능하지만, 이런 검증은 정확하지 않을 수 있다. 왜냐하면 POSTMAN 따위로 값을 변조해서 요청을 하면 클라이언트를 거치지 않고 값이 들어오기 때문이다.

    이런 이유 때문에 서버 차원에서 들어온 값이 정상적인지 아닌지를 확인하는 것은 굉장히 중요하다. 서버에서 들어온 값의 대한 검증을 하고, 이상한 값이 나왔다면 이것들을 클라이언트에 다시 알려줘서 정상적으로 사용할 수 있도록 하려고 한다. 

     

    Validation 참고사항

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

     

     

     

    요구사항 추가

    기존의 개발툴에 요구사항이 추가되었다.

    • 타입 검증
      • 가격, 수량에 문자가 들어가면 검증 오류 처리를 한다
    • 필드 검증
      • 상품명 : 필수값. 공백이 있으면 안된다.
      • 가격 : 1,000원 이상, 1,000,000 이하
      • 수량 : 최대 9999개까지 가능
    • 특정 필드 범위를 넘어서는 검증
      • 가격 * 수량은 10,000 이상이어야 함

    누구냐 너! 자동문인데요?

    지금까지 만들어 온 웹 어플리케이션은 빈 값을 넣어도 값이 잘 저장되었다. 거의 자동문이나 다름이 없다. 이런 쓸모 없는 값들이 들어오지 않도록 Validation 절차를 추가해서 문제가 있는 부분을 다 해결해보고자 한다. 

     

    구현하고자 하는 것

    상품 저장이 성공했을 때

    1. GET /ADD로 상품등록폼으로 이동한다
    2. 상품등록폼에서 HTML 형식으로 POST /ADD를 쏴준다.
    3. 정상적일 경우 값을 저장하고 저장된 상품의 상세 페이지로 Redirect 한다
    4. 저장된 상품의 상세 페이지로 GET 한다.

     

    HTTP 요청이 이상할 때 → 상품등록폼으로 이동

    1. GET /Add로 접근한다.
    2. 상품 등록폼을 띄워준다
    3. 상품 등록폼을 활용해서 POST 한다.
    4. 값이 이상하면, 검증 오류 결과를 포함해서 다시 한번 상품 등록폼을 렌더링한다. 

    HTTP 검증 결과, 이상한 값이 들어왔다면 이 값을 Model에 포함해서 View Template에 넘겨준다. 그리고 그것을 다시 한번 표현해주면서, Client가 어떤 것을 잘못 표기했는지 친절히 알려줄 수 있도록 한다. 

     

    상품 등록 검증 컨트롤러 Version1 만들기

    컨트롤러에 아래 코드를 추가한다.

    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", "수량은 최대 9999개입니다.");
    }
    
    
    // 글로벌 검증.
    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";
    }
    • 에러가 발생할 경우, 에러 메세지를 담을 Map을 하나 만든다.
    • 에러가 발생할 경우, 필드명으로 에러 메세지를 담아준다. 
    • StringUtils를 사용하면 해당 변수가 문자값을 가지고 있는지 쉽게 점검할 수 있다. 
    • 필드 검증과 글로벌 검증으로 나눠서 값을 정할 수 있다. 
    • 만약에 Errors가 비어있지 않다면, 에러가 발생한 것이기 때문에 addForm으로 errors를 Model에 저장해서 보내준다. 

    • 위 코드를 작성해서, addForm에서 빈 값을 보내보면 어떠한 변화도 없는 것을 볼 수 있다. 
    • 이 때 Log를 찍어보면 다음과 같이 모든 필드에서 에러가 발생하고 있는 것을 볼 수 있다.
    • 현재는 Error가 있을 때, Error 메세지가 뷰 템플릿에 나올 수 있도록 되지 않았기 때문에 그렇다. 

     

    에러 메세지 View Templeate 표현하기

    field-error 강조 클래스 추가

    .field-error{
        border-color: #dc3545;
        color: #dc3545;
    }
    • <style></style>에 위의 Tag를 추가한다.
    • 메세지는 빨간색으로 표현, 박스는 빨간색 테두리가 나오도록 해준다. 

     

    글로벌 메세지 표현하기

    <div th:if="${errors?.containsKey('globalError')}">
        <p class="field-error" th:text="${errors['globalError']}"> 전체 오류 메세지 출력</p>
    </div>
    • globalError라는 Key에 해당하는 Value가 있다면 값을 출력하도록 설정한다. 
    • errors의 ?는 안전지시자인데 아래에 설명한다.
    • th:if 명령어는 if절이 True이면 이 Tag를 랜더링하겠다는 것이다. 이 때, 클래스는 필드 에러로 설정한다. 

     

    필드 에러 메세지 표현하기.

    <div>
        <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>
    <div class="field-error" th:if="${errors?.containsKey('itemName')}" th:text="${errors['itemName']}">상품명 오류</div
    • 필드 에러가 발생하면, 박스 타입을 field-error로 표현해주기 위해 th:class 명령어를 사용했다.
    • "${...} ? a : b" 를 사용했는데, ${...} 변수값이 True면 a가 class가 되고, false면 b가 class가 된다. 
    • 아래에 th:if 문을 하나 더 추가해서 'itemName'으로 된 error가 있으면, 에러 메세지를 출력하도록 했다.
    • 동일하게 모든 값에 설정될 수 있도록 했다. 

     

    설정 적용 결과

    설정 적용 결과를 살펴보면, 다음과 같이 에러 메세지가 잘 적용된 것을 볼 수 있다. 그런데 특이한 점이 있다. addForm.HTML을 다시 랜더링 해주는데 어떻게 기존에 잘못 입력되었던 값들이 남아있을 수 있는 것일까? 

     

    검증 실패 시, 값이 남아있는 이유

    • 검증 실패 시, 값이 남아있는 이유는 위의 그림으로 정리할 수 있다.
    • 처음에 HTML Form에서 값을 담아서 전송을 해주고, 이 때 컨트롤러는 @ModelAttribute 기능으로 Item을 받는다.
    • @ModelAttribute으로 받은 매개변수는 Model에 자동으로 담기게 된다.
    • 실패 시, 기존 Model에 Errors를 함께 담아서 addForm으로 다시 보내서 랜더링을 다시 한다.

    위의 매커니즘으로 동작하기 때문에 AddForm에는 기존의 값이 남아있을 수 있게 되는 것이다. 

     

    참고 : Safe Navigation Operator (errors? ← Spring EL 문법)

    • 처음 addForm.html로 접근하게 되면 errors에는 어떠한 값도 없다. 
    • 이 때 errors를 특정 key로 접근하게 되면, errors가 없기 때문에 NullPointerException이 발생한다. 
    • errors?는 springEL이 제공하는 문법으로 null이면, NullPointerException이 발생하는 대신 null 값을 return 해준다.
    • th:if 문법에서 null은 실패로 처리되므로 오류 메세지가 출력되지 않는다. 

     

    HTML 좀 더 간단하게 처리하기 

    th:class.append="${errors?.containsKey('itemName')} ? 'field-error' :_ "
    th:class="${errors?.containsKey('itemName)} ? 'form-control field-error' : 'field-error'"
    • 기존에는 class에서 if문으로 class를 덕지덕지 붙인 값을 출력하게 만들었다. 
    • class.append + No Operation 문을 사용해서 좀 더 깔끔하게 사용할 수 있다. 참이면 기존 class 값이 field-error를 붙이고, 거짓이면 아무것도 하지 않는다로 작성하면서 깔끔하게 보여줄 수 있다. 

     

    Controller Version1을 만든 후 정리 

    • 컨트롤러1을 만들고 난 다음 살짝 코드를 돌아본다. 돌아보면 반복되는 것들이 있어보인다. 가장 대표적인 것은 Error가 확인되면, 거기에 값을 추가하는 것이다. 
    • 또한 Error 저장소를 위해서 따로 Map을 만들고, 번거롭게 그 Map을 다시 Model에 넣어줘야한다. 
    • View Template에 중복되는 것들이 참 많다.
    • 타입 오류 처리가 안된다. 타입 오류가 발생해도, 고객이 입력한 값이 남아있도록 해야한다. 즉, 고객이 입력한 값이 에러가 발생해도 어딘가에 남아있어야 한다? 

    위의 것들을 좀 더 해결하는 것이 목표다.

     

     

    Validation Controller Version2 → 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","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("globalError","가격 * 수량은 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}";
    }
    • BindingResult를 도입한다.
    • BindingResult에는 기존의 errors Map처럼 error를 담아둘 수 있는 공간이 있다.
    • BindingResult에 Error를 담아둘 때 new FieldError(), new ObjectError()로 생성해서 담아두면 된다.
    • BindingResult는 반드시 검증하고자 하는 매개변수의 바로 뒤에 위치하도록 한다.
    • BindingResult는 자동으로 Model에 담겨서 ViewTemplate으로 넘어간다

    이렇게 코드를 바꿀 경우, 한 가지 문제점이 생긴다. 예전에는 Model에 'errors'라는 이름으로 HashMap을 담아서 넘겨주었다. 그래서 뷰 템플릿에서는 errors라는 이름으로 Validation 객체로 접근했었다. 그렇지만 이제는 이름이 바꼈으니 그렇게 사용할 수는 없다. 따라서 뷰 템플릿을 약간 손봐야한다. 

     

    BindingResult 사용 시의 View Template 변경

    • 먼저 알고 가야하는 것은 BindingResult에 담긴 Field Error, Object Error들은 #fields로 접근이 가능하다.
    • #fields = errors(이전 hashMap으로 생각)으로 보고 사용하면 된다. 

     

    Global Error 출력하기

    <div th:if="${errors?.containsKey('globalError')}">
        <p class="field-error" th:text="${errors['globalError']}"> 전체 오류 메세지 출력</p>
    </div>
    • 기존의 코드는 위의 코드였다. 그렇지만 errors라는 Validation 객체에 이제 접근하지 못하기 때문에 코드의 수정이 필요하다.
    <div th:if="${#fields.hasGlobalErrors()}">
        <p class="field-error" th:each="err : ${#fields.globalErrors()}"
           th:text="err"> 전체 오류 메세지 출력</p>
    </div>
    • 위에서 말했듯이 #fields는 앞서 이야기했던 Map과 똑같다고 볼 수 있다. 
    • Map이 globalError(Object Error)를 가지는지 확인한다.
    • 확인한 후, 반복문으로 해당 Error를 모두 출력한다. 

     

    Field Error 출력하기 → th:errors

    개인적으로 강의만 봤을 때는 휙휙 넘어가서 잘 이해가 되지 않던 부분이 많았다. 하나씩 고쳐가볼까 한다. 

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

    먼저 errors Map을 사용할 때의 초기 코드는 위에서 볼 수 있다.

    <div class="field-error" th:if="${#fields.hasErrors('itemName')}"
         th:each="err : ${#fields.errors('itemName')}"
         th:text="${err}"> 상품명 오류</div>
    • BindingResult를 사용해서 #field로 접근할 경우 다음과 같이 작성할 수 있다.
    • #fields.hasError('필드명')으로 검색해서 Error가 있다면, 반복문으로 이 필드명으로 되어있는 모든 오류를 출력하는 것이다.
    • Object Error와 마찬가지로 Field Error는 여러 개가 있을 수 있기 때문에 List 형식으로 Return 되기 때문에 반복문으로 출력한다.
    <div class="field-error" th:errors="*{itemName}"> 상품명 오류 </div>
    • th:errors를 사용하면 위의 코드를 다음과 같이 줄일 수 있다.
    • th:errors는 선택변수식을 사용하는데, 선택변수 이름으로 된 error명을 #fields에서 검색하고, #fields에서 값이 확인되면서 모든 값을 th:text로 출력해주는 축약형 코드ㅏ.

     

    Field Error 출력하기 → th:errorclass

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

    기존 코드는 위와 같다. 마찬가지로 errors를 사용하기 때문에 수정이 필요하다.

    <input type="text" id="itemName" th:field="*{itemName}"
           th:classappend="${#fields.hasErrors('itemName')? 'field-error' : _}"
           class="form-control" placeholder="이름을 입력하세요">
    • 위 코드에서 errors.containsKey를 #fields.hasErrors로 바꾸었다. 나머지는 모두 동일하다.
    • 그렇지만 개발자의 욕심은 끝이 없다. 여기서 더 코드를 줄이기 위해서 th:errorclass라는 것을 지원한다.
    <input type="text" id="itemName" th:field="*{itemName}"
           th:errorclass="field-error"
           class="form-control" placeholder="이름을 입력하세요">
    • th:errorClass는 th:field에 있는 선택변수명으로 검색되는 error가 있을 경우, errorclass로 지정된 error 클래스가 append되는 형태다.
    • 즉, 위의 코드의 축약형이 된다.

     

    Controller 2번째 버전 + 뷰 템플릿 수정 이후 결과

    왼 : 등록 전 / 오 : 등록 후

    • Controller 2번째 버전을 등록하고, 뷰 템플릿을 수정한 다음의 정상 실행유무 확인 결과다.
    • 확인을 해보니 Validation은 정상적으로 이루어지는 것이 확인된다.
    • Validation 이후, 이전에 잘못 입력한 값은 남아있지 않은 것을 확인했다.

     

    왜 이런 결과가 나오게 되는 것일까? 처음에는 잘못 입력된 값은 Model 객체에 바인딩이 되지 않을 것이기 때문에 addForm에서 나오지 않을 것이라 생각했다. 그런데 실제로 로그를 찍어보면 그렇지 않다.

    로그를 찍어보면, 비록 Validation을 통과하지 못하는 가격과 수량이 들어오더라도 @ModelAttribute를 통해서 바인딩 되는 Item 객체에는 정상적으로 Binding 되는 것을 확인했다. 그렇다면 진짜 왜 이런 결과가 나온 것일까? 현재까지는 도대체 어떤 이유 때문인지 알 수가 없다. 뒷쪽으로 넘어가면서 좀 더 공부를 하면 자연스럽게 알게 될 것이다. 

     

     

    Binding Result

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

     

    @ModelAttribute에 바인딩 시 타입 오류가 발생하면?

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

     

    BindingResult를 활용해서 Validation 하는 3가지 방법

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

     

     

    2번째 버전 적용 후, 타입 오류는?

    • 2번째 컨트롤러를 만든 후, 가격과 수량에 타입 미스 매치가 발생하도록 값을 입력하고 저장해봤다.
    • 저장 결과, 이전 컨트롤러에서 400 에러가 뜨는 것과 다르게 컨트롤러가 호출된 것을 알 수 있다. 
    • 특이하게 잘못 입력된 값이 Validation 이후에도 잘 떠있는 것을 볼 수 있다.
    • 잘못 입력된 값에서 생소한 에러 메세지가 나오는 것을 볼 수 있다.

    여기서 알 수 있는 것은 현재 개발자가 직접 넣어준 Validation과 BindingResult에서 자동으로 생성되어 추가되는 Validation 형태가 뭔가가 다르다는 것이다. 이 부분을 해결하기 위해서는 BindingResult를 새로운 형태로 사용해줘야한다. 

     

     

    컨트롤러 버전업그레이드 → BindingResult의 Rejected Value값 사용하기. 

    위의 현상을 로거로 찍어보면 다음과 같은 결과를 받을 수 있다.

    • 타입 미스매치를 유발했던 'ㅂㅂㅂ' 입력값은 내가 만들지 않았으나, FieldError가 만들어져있다.
    • 타입 미스매치가 유발되었던 것은 Rejected Value에 'ㅂㅂㅂ'가 들어가있는 것을 볼 수 있다.
    • 개발자가 직접 Validation 했던 값들은 rejected Value가 null로 설정되어있다. 

    사용자의 입력 데이터가 컨트롤러의 @ModelAttribute에 바인딩되는 시점에 오류가 발생하면 모델 객체에 사용자 입력 값을 유지하기가 어렵다. 예를 들어서 가격에 숫자대신 문자가 입력된다면, 가격은 Integer 타입이므로 문자를 보관할 수 있는 방법이 없다.

    그래서 오류가 발생한 경우 사용자 입력 값을 보관하는 별도의 방법이 필요하다. 그리고 이렇게 보관한 사용자 입력 값을 검증 오류 발생 시, 화면에 다시 출력하면 된다! 

    • 1. 이전 사용자 입력값을 FieldError가 Rejected Value 형태로 가지고 있는다.
    • 2. 이 상태에서 ㅂㅂㅂ라는 값을 넣으면 Validation 이후에도 값이 정상적으로 유지되는 것을 볼 수 있다.
    • 3. 이 경우, item.price = qqq가 될 수 없다. 따라서 rejectedValue에 qqq가 저장된다. 

     

    여기서 느끼는 부분은 BindingResult에 에러값을 넣어줄 때, Rejected Value값을 개발자가 지정해주면 된다는 것이다. 그렇다면 어떤 값을 지정해주는게 맞을까? 

    앞서 로그를 찍어서 알 수 있지만, 타입 미스 매치가 아니라 단순히 개발자가 넣은 Validator에서 걸러진 값들은 Model 객체에 잘 Binding 되어있는 것을 볼 수 있다. 이 값들을 가져오면 되겠다.

     

    >> 새로운 생성자 타입
    new fieldError("objectName", "itemName", rejectedValue, bindingFailure, codes, argument, defaultMessages)
    new ObjectError("objectName", codes, argument, defaultMessages)

    FieldError와 ObjectError에는 이전에 우리가 사용했던 것과 다른 생성자 타입이 있다. 

    • ObjectName : 오류가 발생한 객체 이름. (검증 대상)
    • field : 오류가 난 필드
    • rejectedValue : 사용자가 입력한 값(거절된 값)
    • bindingFailure : 타입 오류 같은 바인딩 실패인지, 검증 실패인지 구분 값. 
    • codes : 메세지 기능의 코드
    • arguments : 메세지 기능에서 사용하는 이자
    • defaultMessage : 기본 오류 메세지

    위 생성자를 사용해서 다시 한번 코드를 리팩토링 할 수 있다. 

     

    컨트롤러 버전업그레이드 → BindingResult의 Rejected Value값으로 리팩토링

    @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("globalError", 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}";
    }

    다음과 같이 RejectedValue가 들어가는 형태의 생성자를 사용하고 다시 한번 잘못된 값을 넣어서 Validation에서 필터링 되게 유발해보았다. 

     

    BindingResult의 RejectedValue 사용 후, 결과 정리 

    • BindingResult에 addError를 할 때 Reject Value 파라미터를 쓰도록 코드를 리팩토링 했다.
    • 리팩토링 한 결과, Validation을 통과하지 못한 값이 들어간다고 하더라도 그 값이 다시 표시되는 것을 볼 수 있다.

    위의 내용을 정리하면 다음과 같다.

    • 에러가 발생하면, 비록 Item에 값이 Binding 되어있다고 하더라도 RejectValue의 값이 출력된다

    앞에서 왜 @ModelAttribute를 통해 Item에 값이 Binding 되어서 잘 전달되었는데, 왜 Validation 실패했을 때 다시 한번 화면에 나타나지 않는지에 대해 궁금해했었다. 위의 동작은 똑같이 이루어지고 있다. 그렇다면 왜 그럴까?

    정답은 th:field에 있다.

    • th:field="*{price}"는 error가 발생하지 않으면 Model 객체의 값을 사용한다.
    • th:field="*{price}"는 error가 발생하면 FieldError 객체의 값을 사용한다. 

    th:field가 위와 같이 동작하기 때문에 Model에 정상적으로 값이 Binding 되었어도 나오지 않았었던 것이다! 

     

    컨트롤러 버전 업그레이드! 메세지 기능 사용으로 오류 코드 처리하기

    FieldError, ObjectError의 생성자를 보면 한 가지 우리가 사용하지 않고 있는 기능이 있다는 것을 알고 있다.  codes와 arguments라는 파라메터를 사용하고 있지 않았다. 얘들은 어떤 역할을 하는 것일까?

    • codes : errors.properties에 있는 error의 key value. 즉, 메세지 코드를 사용한다.
    • arguments : 메세지 코드에 필요한 매개변수를 전달한다.

    위 두 가지 기능을 넣어서 좀 더 컨트롤러를 유연하게 만들 수 있다. 왜냐하면 기존에 하드코딩 되어있던 에러 메세지를 메세지 기능을 사용해서 리팩토링 해주기 때문이다. 

     

    error.properties에 메세지 추가

     

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

     

    error.properties에 위의 메세지를 추가해준다.

     

    BindingResult의 codes, arguments 사용하기.

    bindingResult.addError(new FieldError("Item","quantity","수량은 최대 9999개입니다."));

    기존에 사용하던 코드는 위의 코드이다. 그런데 이 코드를 아래와 같이 바꿀 수 있따.

    bindingResult.addError(new FieldError(
            "Item", "quantity",item.getQuantity(),false,
            new String[]{"max.item.quantity"}, new Object[]{9999},"에러가 발생했습니다."
    ));

    마지막에 Default 메세지를 넣어주어서, 만약에 에러 코드가 찾아지지 않는다면 기본 메세지가 출력될 수 있도록 해두었다. 

     

    중간 정리

    여기까지 구현한 다음에 우리가 원하는 목표를 한번 본다.

    • 1. 사용자가 입력된 값이 오류 값이라도 다시 출력될 수 있도록 했다 → Rejected Value 활용
    • 2. 타입 오류가 나더라도 Controller가 호출될 수 있도록 했다 → BindingResult

    두 가지 번거로운 것들이 있다

    • 1. 타입 오류가 났을 때, 에러 메세지가 이상하다.
    • 2. FieldError, ObjectError를 쓸 때 마다 번거롭다.

    다음에는 FieldError, ObjectError를 개선해보도록 한다

     

    reject, rejectedValue 사용해서 코드 개선하기

    우리가 사용하고 있는 BindingResult는 사실 우리가 검증해야하는 대상이 무엇인지 알고 있다.

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

    BindingResult가 검증해야할 @ModelAttribute 뒤에 오게 되면서, 이미 BindingResult는 검증 대상 객체가 무엇인지, 검증 대상 필드가 무엇인지를 다 알고 있다. 로그를 찍으면 그것은 더욱 명확해진다.

    log.info("object Name : {} ", bindingResult.getObjectName());
    log.info("target Name : {} ", bindingResult.getTarget());

    다음 코드를 활용해서 로그를 찍어본다.

    로그는 다음과 같이 나온다. 

    • 대상 객체 이름 : Item
    • 대상 객체의 검증 가능 대상 : id, ItemName, price, quantity

    그렇다면 우리가 앞으로 검증 대상 객체의 이름을 적어주지 않아도 된다는 결론에 도달한다.

     

    RejecteValue, Reject를 사용해서 코드 개선하기.

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

    앞서 우리는 위의 메세지 기능을 사용해서 변경에 대한 확장성을 조금쯤은 높인 상태이다. 그런데 FieldError를 사용하는 것은 너무나 번거로웠다. 그래서 우리는 위와 같이 RejecteValue라는 기능을 사용해서 코드를 좀 더 줄일 수 있다. 

    bindingResult.rejectValue("itemName","required");

    다음과 같이 코드를 줄일 수 있다.  

    bindingReuslt.addError(new FieldError());

    즉, BindingResult는 크게 보면 다음과 같은 코드를 대신해주는 것을 알 수 있다. 

     

    BindingResult + rejectvalue에게 이미 주어진 정보

    • BindingResult는 @ModelAttribute 바로 뒤에 있기 때문에 이미 대상 객체를 알고 있다.(Item)

    이 정보가 있기 때문에 Ojbect "Item"을 사용하지 않는다는 것은 알겠다.

    bindingResult.rejectValue("itemName","required");

    그렇다면 단순히 저 코드만 가지고 어떻게 errors.properties에서 필요한 메세지를 찾아서 사용하는 것일까? 이의 확인을 위해서 log.info로 Bindingresult가 가지고 있는 errors를 찍어보았다.

    • itemName에만 우선 rejecteValue를 적용했는데 여기서 codes를 확인 한번 해보았다.
    • 사용자가 넣은 것은 required 밖에 없는데, 4개의 에러 코드가 만들어져있다.
    • codes는 배열 형태로 주어져 있고, 앞에 것부터 우선순위를 가지며 출력된다. 

    위와 같은 내용을 알 수가 있게된다. 그런데 codes가 만들어지는 규칙이 있어 보인다. 조금 분리해서 정리해보면 다음과 같다

    • Level1 : 코드.객체.필드 →  Required.item.itemName
    • Level2 : 코드.필드 →  Required.ItemName
    • Level3 : 코드.필드 타입 → Required.Java.Lang.String
    • Level4 : 코드 →  Required

    위와 같은 형태로 순서대로 만들어진다는 것을 알게 되었다. 그렇다면 정말로 다음과 같은 순서로 출력이 되는지 확인해본다.

     

    Level4로만 출력해보기

    // Level4만 출력하고자 했을 때 
    required=필수입니다.
    #required.item.itemName=상품 이름은 필수입니다.
    
    
    // Level1 출력하고자 했을 때
    required=필수입니다.
    required.item.itemName=상품 이름은 필수입니다.

    messages.properties에 변경점을 위와 같이 주고 실제로 오류 메세지를 확인해보니 상품명에서 LEVEL4만 출력하면 '필수입니다'가 나오게 되고, LELVE1만 출력하면 '상품 이름은 필수입니다'가 나오게 되는 것을 확인해보았다. 이를 다시 한번 정리해보면 아래와 같다.

    • RejectValue, Reject를 쓰면 자동으로 특정 로직으로 메세지 코드가 만들어지면서 Errors에 등록이 된다.
    •  Errors에 있는 코드는 앞에 있는 순서대로 사용이 된다. 

    어떤 것이 이런 것이 가능하도록 돕고 있는 것일까? 이를 위해서 MessageResolver라는 것을 찾아봐야한다! 넘어가기 전에 RejectValue, Reject에 대해서 간단히 정리한다

     

    RejectValue, Reject

    • RejectValue는 add.errors(new FieldError())를 대신해준다.
    • RejectValue는 메세지 코드(required)만 넣으면 다음과 같이 자동 메시지 코드를 만들어준다.
      • required.item.ItemName
      • required.itemName
      • required.Java.LangString
      • required
    • Reject는 addError(new ObjectError())를 대신해준다.
    • Reject는 메시지 코드(totalPriceMin)만 넣으면 다음과 같이 자동 메세지 코드를 만들어준다.
      • totalPriceMin.item
      • totalPriceMin

     

    오류 코드는 어떻게 만드는 것이 좋을까?

    오류 코드를 너무 세밀하게 만들면, 하나하나 전부 만들어야 한다는 단점이 있다. 반대로 오류 코드를 너무 범용적으로 만들면 세심한 표현이 불가능하다는 단점이 있다. 그렇다면 어떻게 오류 코드를 설계하는 것이 좋을까?

    오류 코드는 기본적으로 범용적으로 설계하되, 필요한 부분에서는 세심한 오류 코드를 설정해서 사용할 수 있도록 한다. 즉, 오류 코드의 단계를 두고 세심한 코드가 필요할 때는 높은 레벨로 표현을 해주면 된다. 

    앞에서 우리는 BindingResult의 RejectValue, Reject 메서드가 오류 메세지를 레벨별로 자동으로 생성해주는 것을 확인했다. 그러면 우리는 이걸 사용하면 좋을 것 같다! 

    다행이다! Spring은 MessageCodesResolver라는 것으로 이러한 기능을 지원한다! 

     

    MessageCodesResolver 테스트 해보기! 

    앞서 말한 것처럼 RejectValue, Reject는 new FieldError(), new ObjectError()를 해주는 것뿐만이 아니라 자동으로 단계별 에러 메세지를 생성해서 넣어준다고 했다. 그리고 이것을 MessageCodesResolver라는 것이 돕는다고 했다. 정확하게는 RejectValue, Reject 메서드가 동작할 때 내부적으로 MessageCodesResolver가 사용된다. 

     

    public interface MessageCodesResolver {
       String[] resolveMessageCodes(String errorCode, String objectName);
       String[] resolveMessageCodes(String errorCode, String objectName, String field, @Nullable Class<?> fieldType);
    
    }
    • MessageCodesResolver는 Interface로 제공이 된다.
    • resolveMessageCodes라는 메서드만 있고, 사용되는 매개변수만 다르다.
    • Return 되는 값은 String 배열이다. 즉, 메세지 코드를 받을 것이다. 
    MessageCodesResolver codesResolver = new DefaultMessageCodesResolver();

    MessageCodesResolver를 사용하기 위해서 구현체를 하나 만들었다. 이제 MessageCodesResolver를 이용해서 테스트를 할 준비가 완료되었다.

     

    MessageCodesResolver로 ObjectError() 테스트하기

    @Test
    void messageCodesResolverObject(){
        String[] messageCodes = codesResolver.resolveMessageCodes("required", "item");
        assertThat(messageCodes).containsExactly("required.item", "required");
    }
    • MessageCodesResovler는 Reject 안에서 위의 메서드가 사용된다.
    • OjbectError는 2단계의 메세지 코드만 만들어진다. 
      • 메세지 코드.객체명
      • 메세지 코드

     

    MessageCodesResolver로 FieldError() 테스트하기

    @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"
        );
    • MessageCodesResolver는 RejectValue 안에서 사용되고, 위의 메서드가 사용된다.
    • 메서드는 총 4단계의 메세지 코드를 만든다.
      • 메세지 코드. 객체. 필드명
      • 메세지 코드. 필드명
      • 메세지 코드. 현재 클래스 타입
      • 메세지 코드

     

    RejectValue, Reject 동작방식

    • RejectValeu(), Reject는 모두 내부적으로 MessageCodesResolver를 사용한다. 여기서 메세지 코드를 생성한다.
    • FieldError, ObjectError 모두 다양한 형태의 메세지 코드를 가질 수 있다. 여기에는 MessageCodesResolver를 통해서 생성된 순서대로 오류 코드를 보관한다.
    • 메세지 코드는 이미 BindingResult를 통해서 대상 객체가 무엇인지 잘 알고 있다.
    • FieldError는 총 4가지의 오류 코드를 생성한다.
    • ObjectError는 총 2가지의 오류 코드를 생성한다.
    • 코드는 먼저 생성된 에러 저장소에 저장된다. 그리고 앞에 있는 메세지 코드부터 조회되면서, 있을 경우 사용된다. 
    • th:errors가 있으면, 타임리프 화면을 렌더링할 때 에러 메세지가 출력된다.
      • errors가 있으면, 넘어간 메세지 코드를 앞에서부터 찾는다.
      • 찾는 것이 있으면 그것을 바로 출력해준다. 없으면 Default Message를 출력해준다. 

     

     

    Tyep MisMatch로 인한 오류 코드는?

    예를 들어서 숫자 타입의 변수에 String을 넣으려고 해보자. 그렇다면 이런 전송은 클라이언트가 서버에 데이터를 주고, 서버에서 데이터를 @ModelAttribute 같은 것으로 Binding할 때 문제가 발생할 것이다. 이 때, BindingResult는 이런 타입 에러에 대한 입력값을 RejectValue에 넣어주고 Errors에 Field Error를 하나 넣어준다.

    그런데 문제가 있다. 이 Field Error에 대해서 메세지 기능을 적용하지 않았기 때문에 일반 사용자 입장에서 봤을 때는 굉장히 생소한 에러 메세지가 발생할 것이다. 이를 수정해야한다. 

     

    타입미스 시 발생하는 에러는?

    먼저 타입미스에서 발생하는 에러를 확인한다. 타입미스도 결국에는 BindingResult.RejectValue로 값이 생성되서 들어올 것이기 때문에 만들어지는 Codes가 다른 FieldError()와 아주 유사하게 만들어지는 것을 확인할 수 있다. 개발자는 이 코드를 확인하고, errors.properties에 해당 메세지를 출력할 수 있도록 만들어주면 된다. 

    typeMismatch.java.lang.Integer=숫자를 입력해주세요.

    즉, 다음 코드를 한 줄 추가해주기만 하면 된다. 위의 코드를 추가하게 되면 다음과 같이 살펴볼 것이다.

    1. typeMismatch.item.price → 이 메세지는 없으니 넘어간다
    2. typeMismatch.price → 이 메세지는 없으니 넘어간다
    3. typeMismatch.java.lang.Interger → 메세지 정의 확인. "숫자를 입력해주세요" 출력한다.

     

    여기서 잠깐!

    그런데 위 그림을 보면 한 가지 애매한 것이 있는 것을 알 수 있다. 바로, 타입 미스매치 Validation과 특정 필드 로직의 Validation이 같이 들어가게 되면서 두 개의 에러 메세지가 나온 것을 볼 수 있다. 애매하긴 한데, 이것을 개선하는 방법도 있다. 개선하는 방법은 컨트롤러로 들어왔을 때, 아래의 코드가 바로 실행될 수 있도록 해주는 것이다. 

    if(bindingResult.hasErrors()){
        return "validation/v2/addForm";
    }

    • 위 코드를 컨트롤러 가장 앞에 넣어주면, 다음과 같이 개선되는 모습을 볼 수 있다.
    • 로직은 아주 쉽다. 들어오자마자 에러가 있는지 확인한다. 이 때 에러는 typeMisMatch 에러일 가능성이 가장 높다. 따라서 에러가 있다면, 바로 addForm.html로 다시 return시켜주면 된다.

     

     

    Validator로 분리하기

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

    먼저 addForm 컨트롤러를 한번 살펴보면 성공로직은 딱 위 4줄인 것을 알 수 있다. 그렇지만 성공로직까지 도착하기 위해서 필요한 Validation이 최소 수십 줄이라는 것을 알 수 있다. 주객이 전도된 상황이기 때문에 이 경우 검증하는 것들을 Validator로 분리시켜준다. 

     

    Validator 만들기

    @Component
    @Slf4j
    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;
            BindingResult bindingResult = (BindingResult) errors;
    
            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,10000},"잘못된 값입니다.");
            }
    
            if (item.getQuantity() == null || item.getQuantity() > 9999) {
                bindingResult.rejectValue("quantity","max",new Object[]{9999} ,"잘못된 값입니다.");
            }
    
    
            // 글로벌 검증.
            if (item.getPrice() != null && item.getQuantity() != null) {
                int resultPrice = item.getPrice() * item.getQuantity();
                if (resultPrice < 10000) {
                    bindingResult.reject("totalPriceMin", new Object[]{10000, resultPrice},"잘못된 값입니다.");
                }
            }
        }
    }
    • 먼저 ItemValidator라는 클래스를 만든다
    • Spring의 Validator interface를 implements한다. (스프링에서 제공하는 검증 기능을 사용하려고 하기 때문)
    • @Component로 스프링빈으로 등록한다.
    • 메서드를 Override한다. 
      • supports는 현재 주어진 Validator가 주어진 객체에 대한 검증을 지원하는지 확인한다.
        • 이 때 .class.isAssignablFrom(clazz)를 활용해서 True, False로 값을 반환한다
        • 이 때, 자식 클래스도 지원을 할 수 있기 때문에 '=='으로 비교하는 것보다 .class.isAssignableFrom으로 지원여부를 확인하는 것이 좋다. (상속, 인터페이스인지까지)
      • Validate는 오류 값을 검증하는 부분이다.
        • 다운 캐스팅으로 필요한 객체를 바꾸어 사용한다.
        • Errors는 BindingResult의 부모 클래스다. 

    위처럼 Validator를 implements를 하고 스프링빈으로 등록하면, 스프링에서 제공하는 Validator 기능을 사용할 수 있도록 준비가 완료되었다. 그렇다면 이제 Validator를 사용해서 코드를 리팩토링 하는 방향으로 가보자

     

    Validator 직접 꺼내서 쓰기

    @Autowired
    private final ItemValidator itemValidator;
    
    public String addItemV5(@ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model) {
        log.info("errors = {}", bindingResult);
    
        if(bindingResult.hasErrors()){
            return "validation/v2/addForm";
        }
    
        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를 사용하기 위해서는 스프링빈으로 된 빈을 가져와야한다. 따라서 private final로 itemValidator를 선언하고, @Autowired로 DI해준다.
    • 이후 itemValidator.validate(item, bindingResult)로 코드를 간소화해주면 된다. 

    그렇다면 여기서 의문이 든다. 검증을 해야할 것이 많을텐데, 이렇게 하나하나 모든 컨트롤러에 검증기를 넣을 수 있을까? 컨트롤러가 너무 많아지면 어떻게 하지?라는 생각이 들 것이다. 그래서 개발자들은 또 하나의 기가 막힌 것을 준비해왔다. 바로 WebDataBinder와 @InitBinder라는 어노테이션이다.

     

     

    Validator 사용하기 → WebDataBinder, @InitBinder, @Validated로 자동화하기

    • WebDataBinder는 기본적으로 파라미터 바인딩의 역할을 해준다. 그리고 내부에 검증 기능도 포함하고 있다.
    • Item 객체의 파라미터 바인딩도 해주지만, 내부적으로 검증 기능까지 같이 해줄 수 있다. 
    • WebDataBinder는 컨트롤러가 호출될 때 마다 새로 만들어진다. 이 때, Global로 설정된 Validator는 자동으로 계속 들어간다. Global로 설정되지 않은 Validator는 @InitBinder로 내가 설정해줘야한다. 
    • @InitBinder는 전체 클래스 내의 모든 컨트롤러가 호출될 때 마다 WebDataBinder가 새로 만들어졌을 때, 초기화 과정에서 불러지는 어노테이션이다.
      • 이 어노테이션을 활용해서, 우리가 필요한 검증을 실행하면 된다. 
    • WebDataBinder가 Validator를 가지고 있다고 검증을 해주는 것은 아니다. 개발자의 최소한의 개입이 필요하다. 바로 검증이 필요한 매개 변수에 @Validated를 붙여주면 된다. 
    • @Validated는 검증기를 실행하라는 어노테이션이다.
    • 이 어노테이션이 붙으면 WebDataBinder에 등록한 검증기를 찾아서 실행한다.
    • 여러 검증기가 사용된다면 어떤 검증기를 사용해야할지 구분이 필요한데, 이 때 supports()가 사용된다.
    	private final ItemValidator itemValidator;
    
        @InitBinder
        public void init(WebDataBinder dataBinder){
            log.info("init Binder = {}", dataBinder);
            dataBinder.addValidators(itemValidator);
        }
        
        @PostMapping("/add")
        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}";
        }

    코드는 위와 같이 간소화할 수 있다.

     

     

     

    글로벌 Validator 등록

    @SpringBootApplication
    public class ItemServiceApplication implements WebMvcConfigurer {
     
     public static void main(String[] args) {
     SpringApplication.run(ItemServiceApplication.class, args);
     }
     
     @Override
     public Validator getValidator() {
     return new ItemValidator();
    
    }
    • 위처럼 Validator에 자동으로 글로벌 Validator를 설정할 수 있다. 
    • WebMvcConfigurer를 implements한 후에 필요한 부분을 구현해주면 된다.

     

    위와 같이 글로벌 Validator를 설정할 수 있지만 추천을 하지 않는다고 한다. 왜냐하면 이후에 사용될 더 간편한 BeanValidation도 글로벌 Validator이기 때문이다. 만약, 우리가 직접 글로벌 Validator를 등록한다면, Bean Validator는 자동으로 글로벌 등록이 되지 않는다. 그래서 글로벌 Validator를 설정하지 않는 것을 추천한다. 

     

     

     

     

    Validator를 스프링 빈에 등록하고 

     

     

     

     

    댓글

    Designed by JB FACTORY