(스프링 mvc2) 검증1 - Validation

짜스의 하루 ·2024년 3월 11일

검증 요구사항

신규 요구사항: 검증 로직 추가

  • 타입 검증
    가격, 수량에 문자가 들어가면 검증 오류 처리를 해주세요.
  • 필드 검증
    상품명: 필수값 이어야 합니다. (공백 X)
    가격: 1000원 이상, 1백만원 이하여야 합니다.
    수량: 최대 9999개 까지만 가능합니다.
  • 특정 필드의 범위를 넘어서는 검증
    (가격 * 수량)의 합은 10,000원 이상이어야 합니다.

지금까지 만든 웹 애플리케이션은 폼 입력시 숫자를 문자로 작성하거나해서 검증 오류가 발생하면 오류 화면으로 바로 이동한다.

이렇게 되면 지금 우리가 만든 웹 서비스의 구조상, 사용자는 처음부터 해당 폼으로 다시 이동해서 입력을 해야 한다. 아마도 이런 서비스라면 사용자는 금방 떠나버릴 것이다. 웹 서비스는 폼 입력시 오류가 발생하면, 고객이 입력한 데이터를 유지한 상태로 어떤 오류가 발생했는지 친절하게 알려주어야 한다.

컨트롤러의 중요한 역할 중 하나는 HTTP 요청이 정상인지 검증하는 것이다. (그리고 정상 로직보다 이런 검증 로직을 잘 개발하는 것이 어쩌면 더 어려울 수 있다.)

참고

  • 클라이언트 검증과 서버 검증
    (검증은 크게 클라이언트에서 하는 검증과 서버에서 하는 검증이 있다. 클라이언트에서 하는 검증은 주로 자바스크립트 기반의 검증을 말하고, 서버에서 하는 검증은 HTTP 요청 데이터에 대해 서버에서 이뤄지는 뒷단의 검증을 말한다.)
  • 클라이언트만으로 검증하면, 조작할 수 있으므로 보안에 취약하다.
  • 서버만으로 검증하면, 즉각적인 고객 사용성이 부족해진다.
    아무래도 클라이언트에서 하는 자바스크립트 검증같은 경우, 고객의 입력이 일어날 때 마다 실시간으로 반응해줄 수 있기 때문에, 고객이 빠른 피드백을 받을 수 있다. 반대로 서버에서는 서버에 데이터를 보내봐야 이게 정상적인지 아닌지 알 수 있기때문에 즉각적인 고객 사용성이 조금 부족할 수 있다.
  • 따라서 둘을 적절히 섞어서 사용하되, 최종적으로 서버 검증은 필수이다.

검증 직접 처리 - 소개

상품 저장 성공

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

상품 저장 검증 실패

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

검증 직접 처리 - 개발

상품 등록 검증 : 먼저 상품 등록 시 검증 코드를 작성해보자

ValidationItemControllerV1 = addItem

 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);
            }
        }
  • 상품명을 입력하지 않았을 때,
    log가 찍히는 것을 확인할 수 있다.

검증 오류 보관
Map<String,String> errors = new HashMap<>();

  • 만약 검증시 오류가 발생하면 어떤 검증에서 오류가 발생했는지 정보를 담아둔다.

검증 로직

  • StringUtils.hasText(item.getItemName()): item 객체의 getItemName() 메서드로부터 반환된 문자열이 비어 있지 않고, 실제로 텍스트를 포함하고 있는지를 확인한다.
    --> !StringUtils.hasText(item.getItemName()) 앞에 ! 가 붙어있으므로 문자열이 비어있다면~ 의 if문을 의미한다.
  • item.getPrice() == null || item.getPrice() < 1000 || item.getPrice() > 1000000 : 가격이 null이거나 1000 미만이거나 1000000 초과인 경우 "price" 키로 오류 메시지를 맵에 추가한다.
  • item.getQuantity() == null || item.getQuantity() > 9999 : 수량이 null이거나 9999 초과인 경우 "quantity" 키로 오류 메시지를 맵에 추가합니다.

addForm.html 수정
1. css 추가: 오류 메시지 노출 시 보여줄 디자인 적용

 .field-error{
            border-color: #dc3545;
            color: #dc3545;
        }

2. 글로벌 오류 메시지 처리 : 특정 필드가 아닌 복합 룰 검증에 실패한 글로벌 오류 메시지를 처리해보자

 <div th:if="${errors ?.containsKey('globalError')}">
            <p class="field-error" th:text="${errors['globalError']}">전체 오류 메시지</p>
        </div>
  • ${errors ?.containsKey('globalError')} : errors 맵에 'globalError' 키가 존재하는지 여부를 검사한다.
    ?.는 errors가 null인 경우 NullPointerException을 방지하기 위한 safe navigation 연산자를 의미한다.
  • th:text="${errors['globalError']}": Thymeleaf의 th:text 속성을 사용하여 errors 맵에서 'globalError' 키에 해당하는 값을 출력한다.

3. 필드 오류 메시지 처리 - 상품명

 <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>

4. 필드 오류 메시지 처리 - 가격

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

5. 필드 오류 메시지 처리 - 수량

<input type="text" id="quantity" th:field="*{quantity}"
                   th:class="${errors?.containsKey('quantity')}? 'form-control field-error' : 'form-control'"
                   class="form-control" placeholder="수량을 입력하세요">
            <div th:class="field-error" th:if="${errors.containsKey('quantity')}" th:text="${errors['quantity']}">
                수량 오류
            </div>

실행

정리
1. 만약 검증 오류가 발생하면 (입력 데이터를 유지한 상태로) 입력 폼을 다시 보여준다.
2. 검증 오류들을 고객에게 친절하게 안내해서 다시 입력할 수 있게 한다.
3. 검증 오류가 발생해도 고객이 입력한 데이터가 유지된다.


BindingResult1

스프링이 제공해주는 검증 오류 처리 방법을 알아보자
핵심은 BindingResult이다.

BindingResult 추가

  • Item item 에 바인딩이 된 결과가 bindingResult에 담긴다.
  • BindingResult bindingResult 파라미터의 위치는 @ModelAttribute Item item 바로 다음에 와야 한다.

필드 오류 처리 - FieldError

 //검증 로직
        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","수량은 최대 9,999 까지 허용합니다."));
        }
  • 필드에 오류가 있으면, 스프링이 제공하는 FieldError 객체를 생성해서 bindingResult.addError에 담아두면된다.
  • FieldError 생성자 요약
  • public FidleError(String ObjectName, String Field, String defaultMessage){}
    objectName : @ModelAttribute 이름
    field : 오류가 발생한 필드 이름
    defaultMessage : 오류 기본 메시지

특정 필드가 아닌 복합 룰 검증 오류 처리(글로벌 오류) - ObjectError

 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 객체를 생성해서 bindingResult에 담아두면 된다.
  • ObjectError 생성자
    public ObjectError(String objectName, String defaultMessage) {}
    objectName : @ModelAttribute 의 이름
    defaultMessage : 오류 기본 메시지

검증 실패 시 다시 입력 폼으로 이동

  if(bindingResult.hasErrors()){
            log.info("errors={}", bindingResult);
            return "validation/v2/addForm";
        }
  • 만약, bindingResult에 오류가 존재하면(bindingResult.hasError()), Validation/v2/addForm 으로 이동한다.
  • bindingResult는 자동으로 뷰에 전달되기 때문에, 따로 model에 담을 필요가 없다.

v2/addForm.html

<div th:if="${#fields.hasGlobalErrors()}">
           <p class="field-error" th:each="err : ${#fields.globalErrors()}"
                   th:text="${err}">글로벌 오류 메시지</p>
       </div>
  • 글로벌 오류는 하나가 발생할 수도 있고, 여러개가 발생할 수도 있다
    (따라서 globalErrors는 컬렉션으로, each를 사용해서 반복을 적용한다)

필드 오류 처리

<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>
                <label for="price" th:text="#{label.item.price}">가격</label>
                <input type="text" id="price" th:field="*{price}"
                       th:errorclass="field-error" class="form-control" placeholder="가격을 입력하세요">
                <div th:class="field-error" th:errors="*{price}">
                    가격 오류
                </div>

            </div>
            <div>
                <label for="quantity" th:text="#{label.item.quantity}">수량</label>
                <input type="text" id="quantity" th:field="*{quantity}"
                       th:errorclass="field-error" class="form-control" placeholder="수량을 입력하세요">
                <div th:class="field-error" th:errors="*{quantity}">
                    수량 오류
                </div>
            </div>

타임리프 스프링 검증 오류 통합 기능

  • 타임리프는 스프링의 BindingResult를 활용해서 편리하게 검증 오류를 표현하는 기능을 제공한다
  • #fields : #fields로 BindingResult가 제공하는 검증 오류에 접근할 수 있다.
  • th:errors : 해당 필드에 오류가 있는 경우, 태그를 출력한다.
  • th:errorclass : th:field에서 지정한 필드에 오류가 있으면, class 정보를 추가한다.

BindingResult2

BindingResult

  • 스프링이 제공하는 검증 오류를 보관하는 객체
  • BindingResult가 있으면 @ModelAttribute 에 데이터 바인딩시 오류가 발생해도, 컨트롤러가 호출된다.

@ModelAttribute에 바인딩 타입 오류?

  • BindingResult 가 없으면 : 400 오류가 발생하면서 컨트롤러가 호출되지 않고, 오류 페이지로 이동한다.
  • BindingResult 가 있으면 : 오류 정보( FieldError )를 BindingResult 에 담아서 컨트롤러를 정상 호출한다.
  • 스프링은 바인딩에 문제가 생긴 경우, BindingResult가 있으면, 그 문제에 대한 결과를 BindingResult에 담아둔다.--> 담아두고 컨트롤러를 정상 호출한다.
    따라서 로그인 화면에서 그 오류 정보가 노출되는 것이다.

BindingResult에 검증 오류를 적용하는 3가지 방법
1. ModelAttribute 의 객체에 타입 오류 등으로 바인딩이 실패하는 경우, 스프링이 FieldError 생성해서 BindingResult 에 넣어준다.
2. 개발자가 직접 넣어준다 -->bindingResult.addError(...)
3. Validator를 사용한다.

주의

  • BindingResult 는 검증할 대상 바로 다음에 와야한다. 순서가 중요하다. (예를 들어서, @ModelAttribute Item item , 바로 다음에 BindingResult 가 와야 한다.)
  • BindingResult 는 Model 에 자동으로 포함된다.

BindingResult와 Errors

  • BindingResult 는 인터페이스이고, Errors 인터페이스를 상속받고 있다.
    실제 넘어오는 구현체는 BeanPropertyBindingResult 라는 것인데, 둘다 구현하고 있으므로 BindingResult 대신에 Errors 를 사용해도 된다.
  • BindingResult 는 여기에 더해서 추가적인 기능들을 제공한다. addError() 도 BindingResult 가 제공하므로 여기서는 BindingResult 를 사용하자. 주로 관례상 BindingResult 를 많이 사용한다.

FieldError, ObjectError

사용자 입력 오류 메시지가 화면에 남도록 해보자

ValidationItemControllerV2

 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, "수량은 최대 9,999 까지 허용합니다."));
        }

        //특정 필드가 아닌 복합 롤 검증
        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));
            }
        }

실행 결과

  • 사용자의 입력값이 그대로 유지되는 것을 확인할 수있다
  • 수정된 코드를 보면, FieldError 와 ObjectError 생성자 파라미터가 몇개 추가되었다. 자세히 알아보자.

FideldError 생성자

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

new FieldError("item", "itemName",item.getItemName(),false, null, null,"상품 이름은 필수 입니다.")

파라미터 목록

  • objectName: 오류가 발생한 객체 이름 (item)
  • field : 오류가 발생한 필드 이름(itemName)
  • rejectedValue : 사용자가 입력한 값 = 거절한 값 (item.getItemName())
  • bingingFailure : 타입 오류 같은 바인딩 실패인지, 검증 실패인지 구분값

    true인 경우, 에러가 바인딩 실패로 인한 것이며, false인 경우는 유효성 검사 실패로 인한 것 --> 간단히 말하면, 바인딩 실패는 사용자의 입력 데이터를 서버 객체로 매핑하는 도중에 발생하는 문제를 나타내며, 유효성 검사 실패는 바인딩이 성공했지만, 특정 규칙을 위반했을 때 발생하는 문제를 나타낸다.

  • codes: 메시지 코드 (null)
  • arguments : 메시지에서 사용하는 인자 (null)
  • defaultMessage : 기본 오류 메시지

오류 발생 시 사용자 입력 값 유지

new FieldError("item", "price", item.getPrice(), false, null, null, "가격은 1,000 ~ 1,000,000 까지 허용합니다.")
  • FieldError 는 오류 발생시 사용자 입력 값을 저장하는 기능을 제공한다
  • 여기서 rejectedValue 가 바로 오류 발생시 사용자 입력 값을 저장하는 필드다.
  • bindingFailure은 클라이언트가 서버로 데이터를 전송할 때, 생긴 오류를 의미한다. 지금은 데이터를 전송한 뒤, 규칙을 위반 했으므로 false 을 적으면 된다.

타임리프의 사용자 입력 값 유지

  • th:field="*{price}"
    --> 타임리프의 th:field는 매우 똑똑하게 동작하는데, 정상 상황에는 모델 객체의 값을 사용하지만, 오류가 발생하면, FieldError에서 보관한 값을 사용해서 값을 출력한다.

스프링의 바인딩 오류 처리
타입 오류로 바인딩에 실패하면 스프링은 FieldError 를 생성하면서 사용자가 입력한 값을 넣어둔다.
그리고 해당 오류를 BindingResult 에 담아서 컨트롤러를 호출한다. 따라서 타입 오류 같은 바인딩 실패시에도 사용자의 오류 메시지를 정상 출력할 수 있다.


오류 코드와 메시지 처리 1

오류 메시지를 좀 더 체계적으로 다루어보자

FieldError 생성자

public FieldError(String objectName, String field, String defaultMessage);

 public FieldError(String objectName, String field, @Nullable Object 
rejectedValue, boolean bindingFailure, @Nullable String[] codes, @Nullable 
Object[] arguments, @Nullable String defaultMessage)
  • objectName : 오류가 발생한 객체 이름
  • field : 오류가 발생한 필드 이름
  • rejectedValue : 사용자가 입력한 값(거절한 값)
  • bindingFailure : 타입 오류 같은 바인딩 실패인지, 검증 실패인지 구분 값
    *** codes : 메시지 코드
  • arguments : 메시지에서 사용하는 인자**
  • defaultMessage : 기본 오류 메시지

지금까지는 FieldError 와 ObjectError 를 사용하면서, 오류 메시지를 defaultMessage 를 통해서 직접 작성해서 적용하였다.
--> 그런데 이런 오류 메시지도 한 군데서 일관성있게 관리하는 것이 더 좋다.
--> 그래서 FieldError 와 ObjectError는 생성자의 codes , arguments 파라미터를 제공하여 오류 메시지를 일관성있게 관리할 수 있도록 지원한다.
--> 이것을 통해서 이전에 메시지, 국제화에서 학습했던 것 처럼, 오류 메시지를 messages.properties 같은 곳에서 찾아온 다음, 없으면 defaultMessage 를 적용하는게 가능해진다.)

errors 메시지 파일 생성

스프링 부트 메시지 설정 추가를 해줌으로써, 해당 메시지 파일을 인식할 수 있게 설정을 추가한다. --> messages.properties , errors.properties 두 파일을 모두 인식한다.

errors.properties 파일추가

ValidationItemControllerV2 - addItemV3

  //검증 로직
        if (!StringUtils.hasText(item.getItemName())) {
            bindingResult.addError(new FieldError("item","itemName",item.getItemName(),false,new String[]{"required.item.itemName"},null,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,10000000},null));
        }
        if (item.getQuantity() == null || item.getQuantity() > 9999) {
            bindingResult.addError(new FieldError("item", "quantity",item.getQuantity(),false,new String[]{"max.item.quantity"},new Object[]{9999}, null));
        }

        //특정 필드가 아닌 복합 롤 검증
        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}, null));
            }
        }
  • codes는 String 배열로 넣어야 한다.
  • argument는 Object 배열로 넣어야 한다. Object[]{1000, 1000000}와 같이 사용되며, 오류 메시지에서 {0}, {1}를 치환하기 위한 값을 전달한다.

오류 코드와 메시지 처리 2

컨트롤러에서 BindingResult 는 검증해야 할 객체인 target 바로 다음에 온다.
따라서, BindingResult 는 이미 본인이 검증해야 할 객체인 target 을 알고있다.

log.info("objectName={}",bindingResult.getObjectName());
log.info("target={}",bindingResult.getTarget());

로그를 찍은 후 실행해보니,
결론적으로 bindingResult 에서 target에 대한 정보를 가지고있는 것을 알수있다.

rejectValue(), reject()
BindingResult가 제공하는 rejectValue(), reject() 를 사용해서 깔끔하게 검증 오류를 작성해보자

addItemV4

 //검증 로직
        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);
            }
        }
  • rejectValue() : 필드 검증 오류 처리를 위해 사용한다. (참고로 첫 번째 파라미터로 바로 field가 나온다. BindingResult 가 이미 target을 알고있기 때문에 objectName은 입력하지 않는다.)
  • reject() : 글로벌 검증 오류를 처리하기 위해 사용한다.
  • 정상적으로 실행이 되었고, 오류 메시지도 잘 출력이 되었다
  • errors.properties에 있는 코드를 전체를 다 입력하지 않았는데 어떻게 오류 메시지를 찾은걸까?

rejectValue()

 bindingResult.rejectValue("price","range",new Object[]{1000, 1000000},null);
        }
  • field : 오류가 발생한 필드 명
  • errorCode : 오류 코드
  • errorArgs : 오류 메시지에서 {0}, {1}을 치환하기 위한 값(배열)
  • defaultMessage : 오류 메시지를 찾을 수 없을 때, 사용하는 디폴트 메시지

축약된 오류 메시지 코드

  • 오류 코드를 range.item.price를 모두 입력하지 않고, range만 입력했을 뿐인데, 오류 메시지를 잘 찾아서 출력을 도와준다.
    --> MessageCodesResolver를 이해해야 한다.

오류 코드와 메시지 처리 3

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

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

또느 다음과 같이 단순하게 만들 수도 있다.

  • required=필수 값 입니다
  • range=범위 오류 입니다
  • max=최대 {0}까지 허용합니다.

단순하게 만들면, 범용성이 좋아서 여러 곳에서 사용할 수 있지만, 메시지를 세밀하게 작성하기는 어렵다
반대로 너무 자세하게 만들면, 범용성이 떨어진다.

가장 좋은 방법은 범용성으로 사용하다가, 세밀하게 작성해야 하는 경우에는 , 세밀한 내용이 적용되도록 메시지에 단계를 두는 것이다.

예를 들어, required라고 오류 코드를 사용한다고 가정해보자.
requird=필수 값 입니다 라는 메시지만 있으면 이 메시지를 선택해서 사용하는 것이다.

하지만 이렇게 required.item.itemName과 같이 객체명과 필드명을 조합한 세밀한 메시지 코드가 있으면 이 메시지를 높은 우선 순위로 사용하는 것이다.

--> 스프링은 MessageCodesResolver 라는 것으로 이러한 기능을 지원한다.


오류 코드와 메시지 처리 4

MessageCodesResolver에 대해서 알아보자
( MessageCodesResolver 의 개념을 이해하게 되면, 이전에 errorCode로 "required" 만 넣었음에도 불구하고, "required.item.itemName" 또한 오류 코드로 만들어진 배경을 이해할 수 있다. )

MessageCodesResolverTest

public class MessageCodesResolverTest {
    MessageCodesResolver codesResolver = new DefaultMessageCodesResolver();

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

    @Test
    void messageCodesResolverField(){
        String[] messageCodes = codesResolver.resolveMessageCodes("required", "item", "itemName", String.class);
        for (String messageCode : messageCodes) {
            System.out.println("messageCode = " + messageCode);
        }
        assertThat(messageCodes).containsExactly("required.item.itemName","required.itemName"
                ,"required.java.lang.String","required");

    }
}

MessageCodesResolver

  • 검증 오류 코드(errorCode)로 메시지 코드들을 생성한다
  • MessageCodesResolver는 인터페이스고, DefaultMessageCodesResolver 는 기본 구현체이다.
  • 주로 다음과 함께 사용 : ObjectError, FieldError

DefaultMessageCodesResolver의 기본 메시지 생성 규칙
1. 객체 오류
1) code + "." + object name
2) code
예) 오류 코드 = required, object name = item
1) required.item
2) required

2. 필드 오류
필드 오류의 경우 다음 순서로 4가지 메시지 코드 생성
1) code + "." + object name + "." + field
2) code + "." + field
3) code + "." + field type
4) code
(디테일한 것에서부터 범용적인 순서로)
예) 오류 코드 = typeMismatch, object name = user, field = age, field type = int
1) typeMismatch.user.age
2) typeMismatch.age
3) typeMismatch.int
4) typeMismatch

동작 방식
rejectValue(), reject()는 내부에서 MessageCodesResolver를 사용한다.

  • FieldError , ObjectError 의 생성자를 보면, 오류 코드를 하나가 아니라 여러 오류 코드를(배열) 가질 수 있다. MessageCodesResolver 를 통해서 생성된 순서대로 오류 코드를 보관한다.

FieldError
예시) rejectValue("itemName", "required")
다음 4가지 오류 코드를 자동으로 생성
required.item.itemName
required.itemName
required.java.lang.String
required

ObjectError
예시) reject("totalPriceMin")
totalPriceMin.item
totalPriceMin

오류 메시지 출력
타임리프 화면을 렌더링 할 때 th:errors 가 실행된다. 만약 이때 오류가 있다면 생성된 오류 메시지 코드를 순서대로 돌아가면서 메시지를 찾는다. 그리고 없으면 디폴트 메시지를 출력한다. (디폴트 메시지도 없는 경우 오류가 발생한다.)


오류 코드와 메시지 처리 5

오류 코드 관리 전략 --> 핵심은 구체적인것에서 덜 구체적인 것으로

  • MessageCodesResolver 는 required.item.itemName 처럼 구체적인 것을 먼저 만들어주고, required 처럼 덜 구체적인 것을 가장 나중에 만든다.

왜 이렇게 복잡하게 사용할까?

  • 모든 오류 코드에 대해서 메시지를 각각 다 정의하면, 너무 복잡할 것이다
    --> 크게 중요하지 않은 메시지는 범용성이 있는 required같은 메시지로 끝내고 정말 중요한 메시지는 꼭 필요할 때 구체적으로 적어서 사용하는 방식이 더 효과적이다.

errors.properties

  • ObjectError는 두 가지 레벨로 만들었다
  • FieldError는 네 가지 레벨로 만들었다.
    (Lv4는 가장 범용적으로 메시지를 만들어두고, Lv3은 Lv4에서 타입에 대한 부분만 추가되었다. Lv2는 생략하였고, Lv1에는 가장 디테일하게 만들었다. )
    (결국, MessageCodesResolver 에 의해서 Lv1 -> Lv2 -> Lv3 -> Lv4 순서로 매칭되도록 해두었다. )

erroers.properties 를 보면, 크게 객체 오류와 필드 오류를 나누었고, 각각 범용성에 따라 레벨을 두었다.

예를 들어, itemName의 경우, required 검증 오류 메시지가 발생하면, 다음 코드 순서대로 메시지가 생성된다.
① required.item.itemName
② required.itemName
③ required.java.lang.String
④ required

그리고 이렇게 생성된 메시지 코드를 기반으로 순서대로 MessageSource에서 메시지를 찾는다.
구체적인 것에서 덜 구체적인 순서대로 찾는다(메시지에 1번(①)이 없으면 2번(②)을 찾고, 2번(②)이 없으면 3번(③)을 찾는다.)

이렇게 되면 만약에 크게 중요하지 않은 오류 메시지는 기존에 정의된 것을 그냥 재활용 하면 된다.

정리
1. rejectValue() 호출
2. MessageResolver를 사용해서 검증 오류 코드로 메시지 코드들을 생성
3. new FieldError()를 생성하면서, 메시지를 보관
4. th:errors에서 메시지 코드들로 메시지를 순서대로 메시지에서 찾고, 노출


오류 코드와 메시지 처리 6

스프링이 직접 만든 오류 메시지 처리
검증 오류 코드는 두가지로 나눌 수 있다.

  • 개발자가 직접 설정한 오류 코드 -> rejectValue()를 직접 호출
  • 스프링이 직접 검증 오류에 추가한 경우 (타입이 맞지 않을 때)

가격에 영어 A를 입력해보자

  • 가격에 "A"를 입력 후 저장하면, Item의 price는 Integer이기 때문에, "A"를 받을 수 없다. 따라서 BindingResult에 검증 오류 정보가 들어간다.
  • 로그를 확인해보면 BindingResult에 FieldError가 담겨있고, 메시지 코드가 생성된 것이다.
    --> rejecValue()를 호출하면서 오류 코드로 typeMismatch라고 넣어준 것이다.

위 예시를 보면, 다음과 같이 4가지 메시지 코드가 입력되어 있다.

  • typeMismatch.item.price
  • typeMismatch.price
  • typeMismatch.java.lang.Integer
  • typeMismatch

--> 스프링은 타입 오류가 발생하면 typeMismatch 라는 오류 코드를 사용한다.

errors.properties에 코드 추가
typeMismatch.java.lang.Integer=숫자를 입력해주세요.
typeMismatch=타입 오류입니다.
--> 레벨3, 레벨4 두가지만 추가해보자

  • 스프링이 생성한 기본 메시지가 아닌, errors.properties에 추가한 메시지가 출력됨을 확인할 수 있다.
  • 결과 적으로 소스코드를 하나도 건들이지 않고, 원하는 메시지를 단계별로 설정할 수 있다.(스프링이 제공하는 오류 코드에 대해서도 처리가 가능하다.)

Validator 분리1

컨트롤러에서 검증 로직이 차지하는 부분이 매우 크다
별도의 클래스로 역할을 분리하는 것이 좋다

ItemValidator 생성

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 인터페이스를 제공한다
  • validate 메서드는 첫 번째 파라미터로 target을 받고, 두번째 파라미터에 Errors를 받는다.
  • target은 Object로 받기 때문에 캐스팅 작업을 해주어야 한다.
  • Errors는 BindingResult의 부모 클래스이다.
  • 참고로 해당 Validator를 효율적으로 가져다 사용할 수 있도록 스프링 빈으로 등록하자. ( @Component )

addItemV5

@PostMapping("/add")
    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(item,bindingResult); 만 추가하면 사용 가능하다.

스프링은 검증을 체계적으로 제공하기 위해 Validator를 제공한다

  • supports(Class<?> clazz) : 해당 검증기를 지원하는 여부 확인
  • validate(Object target, Errors errors): 검증 대상 객체와 BindingResult --> 실제 검증하는 로직을 적용한다.

Validator 분리2

스프링이 Validator 인터페이스를 별도로 제공하는 이유는 체계적으로 검증 기능을 도입하기 위해서이다.

WebDataBinder

  • WebDataBinder는 스프링의 파라미터 바인딩의 역할을 해주고, 검증 기능도 내부에 포함한다.
@InitBinder
public void init(WebDataBinder dataBinder){
    dataBinder.addValidators(itemValidator);
}
  • 위와 같이 적용하게 되면, 이제부터는 해당 컨트롤러의 모든 요청이 들어올 때마다, @InitBinder가 적용된 method가 호출된다.위 코드에서는 WebDataBinder에 검증기( itemValidator )를 항상 넣어 두고 있다.
  • 이렇게 WebDataBinder 에 검증기를 추가하면 해당 컨트롤러에서는 검증기를 자동으로 적용할 수 있다.

addItemV6

 @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를 직접 호출하는
    itemValidator.validate(item.bindingResult) 가 사라지고,
    @Validated가 붙었다.
    --> 그러면 해당 애노테이션으로 인해, Item item에 대해서 자동으로 검증기가 수행이 된다.
    --> 그리고 검증된 결과가 BindingResult에 담기게 된다.

동작 방식

  • @Validated는 검증기를 실행시키는 애노테이션이다.
  • 이 애노테이션이 붙으면, 앞서 WebDataBinder에 등록한 검증기를 찾아서 실행한다.
  • 그런데, 이때 여러 검증기가 등록된다면, 그 중 어떤 검증기가 실행이 되어야 할지, 구분을 하기 위해 supports()가 사용된다.
 @Override
    public boolean supports(Class<?> clazz) {
        return Item.class.isAssignableFrom(clazz);
    }
  • 여기서는 supports(Item.class)가 호출이 되고, 결과가 true이기 떄문에, ItemValidator의 validate()가 호출이 된다.
profile
2024. 01. 02 ~ 백앤드 공부 시작, 2024. 04.01 ~ 프론트 공부 시작

0개의 댓글