ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • SpringMVC 2 - 검증
    Web/Spring 2023. 10. 24. 22:17

     

    - 고객이 입력한 데이터를 유지한 채 어떤 오류가 발생했는지 친절하게 알려주자

    - 컨트롤러의 중요한 역할 중 하나는 HTTP 요청이 정상인지 검증하는 것 

     

     

    1. 검증 V1 (직접검증)

     

    - 사용자의 데이터가 정상이면, PRG

    - 정상이 아니면, 모델에 사용자 입력 값과 오류 결과를 담아서 다시 상품등록폼으로 가자 

     

     

    스프링MVC 2 (인프런) - 김영한

    Map<String, String> errors = new HashMap<>(); // 오류 담을 것

     

    1.1 특정 필드 검증로직 

     

    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 까지 허용합니다.");
     
     }

    1.2 복합 필드 검증 

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

    * StringUtils는 Spring꺼 쓰자 

     

     //검증에 실패하면 다시 입력 폼으로
     if (!errors.isEmpty()) {
     model.addAttribute("errors", errors);
     return "validation/v1/addForm";
     }

    - 이때 validation/v1/addForm으로 forward! -> request,response가지고감 + model가지고감 (내부호출)

    - @ModelAttribute 사용해서 Intem item -> item으로 저장되어 있음 

     

    * /add + GetMapping에도 빈 item객체가 넘어가도록 설정해둠!! -> 오류처리 등의 재사용성을 높이기 위함

     

    1.3 addform with 타임리프

     

    - 글로벌 오류 

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

     - if errors에 globalError이 있을 때 -> div태그와 내용 출력 

     - errors?는 errors이 null이면 실행하지 않도록 하는 타임리프 문법 (NPE 대신 null반환) 

     

    * key값을 확인하는 것과 값을 꺼내는 것 다름주의 

    //필드오류처리
    th:class="${errors?.containsKey('itemName')} ? 'form-control field-error' : 'form-control'"
    <div class="field-error" th:if="${errors?.containsKey('itemName')}" th:text="${errors['itemName']}">
     상품명 오류
     </div>
    <input type="text" th:classappend="${errors?.containsKey('itemName')} ? 'fielderror' : _"
     class="form-control">

    - 주요 로직은 그냥 errors?.containsKey 있다면 조건으로 

       th:classappend나 th:text, th:if절 사용했다는 것 

     

    -> 직접 검증처리시 문제점은 뷰 템플릿에 중복처리가 많다.

    -> 타입 오류처리가 안됨!! (애초에 Integer에 String이 들어오는 등) 컨트롤러로 들어오기 전 발생한 오류임

    -> 이러한 문제를 해결한 Spring의 오류처리를 보자 

     

    2. 검증V2 (BindingResult)

     

    - 스프링이 제공하는 검증 오류 처리의 핵심 BindingResult를 알아보자 

    - BindingResult : 에러코드를 담아서 뷰로 같이 넘어감 (따로 Model에 담아 줄 필요 없음)

    - BindingResult는 검증이 필요한 파라미터 바로 다음에 와야함

     

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

    > 매개변수로, 객체명(@ModelAttribute 이름), 필드명, 기본오류메시지를 받는다.

    > 필드오류는 FieldError

    //글로벌오류
    bindingResult.addError(new ObjectError("item", "가격 * 수량의 합은 10,000원 이상이어야 합니다. 현재 값 = " + resultPrice));

    > 매개변수로 객체명(@ModelAttribute), 기본 오류 메시지를 받음 

    > globalError은 ObjectError

     

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

     

    타임리프에서 BindingResult를 활용할 수 있는 편의 기능 제공한다.

    #fields로 BindingResult가 제공하는 검증 오류에 접근 가능 (view로 넘어온 BindingResult 활용)

    th:errors: 오류가 있는 경우 해당 태그 출력 -> if 상위버전 

    th:errorclass: 필드에 오류가 있다면, class정보 추가 -> if상위버전 

     

    *th:object를 지정하면 th:field = *{필드} 혹은 *{필드} 사용할 수 있음!! object로 지정한 객체의 특정 속성 꺼내줌

    *은 선택지정자 (특정)

    *타임리프가 위와 같은 문법을 사용하므로, Field나 Object Error에서 객체 혹은 필드명 잘 지정해줘야 출력됨

     

    2.2 글로벌 오류처리 

    
    <div th:if="${#fields.hasGlobalErrors()}">
     <p class="field-error" th:each="err : ${#fields.globalErrors()}" th:text="$
    {err}">전체 오류 메시지</p>
    </div>

     

    2.3 필드 오류 처리 

    <input type="text" id="itemName" th:field="*{itemName}"
     th:errorclass="field-error" class="form-control" placeholder="이름을
    입력하세요">
    <div class="field-error" th:errors="*{itemName}">
     상품명 오류
    </div>

     

    3. BindingResult 더 알아보기 

     

    - BindingResult

    - 스프링이 제공하는 검증 오류 보관 객체 -> BindingResult가 있으면, ModelAttribute에 데이터 바인딩 시 오류가 발생해도

    컨트롤러가 호출됨!

    - 오류정보(FieldError)을 알아서 만들어서 BindingResult에 담아서 컨트롤러 정상 호출

     

    * BindingResult에 검증오류 작성 3가지 방법 

       > 스프링이 타입오류 넣어줌

       > 직접 Field,Object error을 만들어서 넣기

       > Validator를 사용 

     

    *BindingResult와 Errors 

     -> BindingResult는 인터페이스, Errors인터페이스 상속받음 : 구현객체 BeanPropertyBindigResult임

     -> 인터페이스로 둘 중 아무거나 사용해도됨 단, BindingResult가 더 다양한 기능을 제공함

     

    4. 검증V3

     

    -> 현재 V2까지 문제는 오류메시지는 출력되는데, 사용자가 입력한 오류 값은 출력이 안됨 해결하자

    -> bindingResult는 사용자 오류의 입력값을 저장할 수 있는 매개변수 제공함 (공간?) 

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

    - ObjectName: @ModelAttribute 이름

    - field : 오류필드 

    - rejectedValue : 사용자 입력값

    - bindingFailure: 타입 오류인지, 검증 오류인지 구분 값 

    - codes: 메시지 코드

    - argumetns : 메시지에 인자 

    - defaultMessage: 기본 오류 메시지 

     

    *주의 이때  bindingError를 false라고 하면, rejectedValue와 객체의 필드 사이의 타입이 맞아야한다. 

      > 타입오류 아니라고 했는데.. 거절값이 타입이 안맞는건 말이 안된다.

      > 필드명 잘못적으면 저장은 되지만 아마 타임리프에서 출력은 안될 것이다.

     

    * 만약 타입 오류라면, 스프링이 알아서 

      new FieldError(item,price,"qqq",true..)식으로 넣어준다! 

    * 향후 간편 오류처리들이 위의 메서드 호출하므로 잘 봐두자 

     

    4.1 타임리프 field="*{필드}"

     

    아주 똑똑하게 작용함 정상 상황에서는 모델 객체의 값을 사용하지만, 오류가 발생하면, FieldError에서 보관한 값을 사용해서 출력한다.

     

    5. 오류 코드와 메시지 처리 (1) [그냥 그렇구나]

     

    - 생성자에 codes와 메시지 arguments를 넘길 수 있다! 메시지를 관리해보자 

     

    5.1 errors 메시지 파일 생성

     

    - errors.properties파일을 생성해서 오류 메시지를 관리하자 

    - 메시지 파일을 인식할 수 있도록 아래와 같이 설정 

    - MessageSource 설정

    spring.messages.basename=messages,errors

     

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

    5.2 매개변수 전달 

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

    - 메시지 코드를 매개변수로 전달해서 해당 코드의 메시지를 출력하도록 할 수 있다. (String[])

    - 메시지 매개변수는 object[]로 전달할 수 있다.

     

     

    6. 오류 코드와 메시지 처리 (2)

     

    - 이제 FieldError,ObjectError보다 쉽게 오류코드를 처리하는 BindingResult에 검증 메서드를 사용하자

    - 어차피 BindingResult는 검증할 객체 바로 다음에 온다. (target을 알고 있다)

    log.info("objectName={}", bindingResult.getObjectName());
    log.info("target={}", bindingResult.getTarget());
    
    //출력결과
    objectName=item //@ModelAttribute name
    target=Item(id=null, itemName=상품, price=100, quantity=1234)

     

    6.1 rejectValue(), reject()

     

    - FieldError과 ObjectError를 생성하지 않고, 쉽게 다룰 수 있게 해준다.

    void rejectValue(@Nullable String field, String errorCode,
    @Nullable Object[] errorArgs, @Nullable String defaultMessage);
    
    //실제 사용
    bindingResult.rejectValue("price", "range", new Object[]{1000, 1000000}, null)
    
    
    void reject(String errorCode, @Nullable Object[] errorArgs, @Nullable String 
    defaultMessage);

    filed : 오류 필드명

    errorCode: 오류코드 -> messageResolver를 위한 오류 코드

    errorArgs: 오류 메시지 치환 값

    defaultMessage:기본 메시지 

     

    > rejectValue는 필드명,오류 메시지 코드,메시지매개변수,기본 메시지 받으면 

       - 내부적으로 new Field만들어주는데, 거절값, 객체명, 오류메시지코드를 자동으로 생성해준다.

     

     

    7. 오류 코드와 메시지 처리(3) [매우중요]

     

    - 오류 코드를 만들 때 범용성 있게 만들기 위해서는 추상적인 것부터 만들어두고, 조금씩 구체적으로 내려가는 것이 최고다.

    - 오류 메시지 코드의 level을 나누어 사용하는 것이다! 

    -  스프링의 MessageCodeResolver가 이러한 범용성 있는 오류 메시지 만드는 것을 도와준다.

     

    7.1 MessageCodeResolver

     

    - 검증 오류 코드로 메시지 코드를 생성한다.

    - 구현체로 DefaultMessageCodesResolver를 가지는 인터페이스 

    - 주로 ObjectError과 FieldError이랑 함께 사용된다.

     

    7.2 기본 메시지 생성 규칙 

    객체 오류의 경우 다음 순서로 2가지 생성
    1.: code + "." + object name
    2.: code
    예) 오류 코드: required, object name: item
    1.: required.item
    2.: required
    
    
    필드 오류의 경우 다음 순서로 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를 사용하여, 메시지 코드를 생산해서 주입함

    - 보통 가장 구체적인 것부터 저장됨! 

     

    *기본 메시지 지정 x시 메시지 코드들로 메시지 못찾으면 Exception발생 

     

    7.3 오류 메시지 출력 

     

    타임리프 화면을 렌더링시 th:errors가 실행됨 이때 오류가 있다면, 생성된 오류 메시지 코드를 순서대로 돌아가면서 

    메시지를 찾음! 없으면 디폴트 메시지 출력 

     

    7.4 오류 메시지 관리 전략

     

    - 핵심은 구체적인 것에서 덜 구체적인 것으로 (MessageCodesResolver)

    - 따라서 공통전략 (가장 낮은 레벨)은 한번에 관리할 수 있다.

    - 대부분 공통전략을 따르고 필요한 경우에만 구체적으로 적어서 메시지를 출력하자 

    #required.item.itemName=상품 이름은 필수입니다.
    #range.item.price=가격은 {0} ~ {1} 까지 허용합니다.
    #max.item.quantity=수량은 최대 {0} 까지 허용합니다.
    #totalPriceMin=가격 * 수량의 합은 {0}원 이상이어야 합니다. 현재 값 = {1}
    #==ObjectError==
    #Level1
    totalPriceMin.item=상품의 가격 * 수량의 합은 {0}원 이상이어야 합니다. 현재 값 = {1}
    #Level2 - 생략
    totalPriceMin=전체 가격은 {0}원 이상이어야 합니다. 현재 값 = {1}
    #==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} 까지 허용합니다.

     

    - 대강 위와 같은 너낌 

     

    * ValidationUtils라는게 있는데 걍 참고 

     

    7.5 스프링이 만든 오류 메시지

     

    - 타입 오류의 경우 스프링이 직접 오류 메시지 코드를 정의해 두었다.

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

     

    8. Validator (1)

     

    - 컨트롤러에서 검증하는 로직이 너무 크다. 별도의 역할로 분리하여 검증 로직을 재사용하자

    - 스프링은 검증 로직을 체계적으로 제공하기 위해 인터페이스 제공함 

    public interface Validator {
    boolean supports(Class<?> clazz);
    void validate(Object target, Errors 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;
     ValidationUtils.rejectIfEmptyOrWhitespace(errors, "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() > 10000) {
     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);
     
     }
     }
     }
    }

    -> 컨트롤러 호출 코드 

    @PostMapping("/add")
    public String addItemV5(@ModelAttribute Item item, BindingResult bindingResult,RedirectAttributes redirectAttributes) {
     
     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}";
    }

     

    9 Validator 분리 (2)

     

    Validator 인터페이스를 사용하여 검증기를 만들면, 스프링의 추가적인 도움을 받을 수 있다.

     

    9.1 WebDataBinder 

     

    -  스프링의 파라미터 바인딩 역할 + 검증 기능도 내부에 포함 

    @InitBinder
    public void init(WebDataBinder dataBinder) {
     log.info("init binder {}", dataBinder);
     dataBinder.addValidators(itemValidator);
    }

    - 위와 같이 검증기를 추가하면, 해당 컨트롤러에서는 검증기를 자동으로 적용할 수 있다.

    @InitBinder는 해당 컨트롤러에만 영향을 준다 -> 글로벌 설정은 별도 

     

    9.2 Validated 적용

    public String addItemV6(@Validated @ModelAttribute Item item, BindingResult 
    bindingResult, RedirectAttributes redirectAttributes) {
     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는 WebDatabinder에 등록된 검증기로 가서 검증기를 실행하라는 어노테이션이다.

    이때 WebDatabinder에 많은 검증기가 등록되어 있다면, 지원가능한 검증기 찾아야해서 

    supports()가 있던 것이었다리 

    - 여기서는 supports가 true니까 ItemValidator의 validate에 BindingResult와 item객체가 넘어가서 실행됨

     

    *WebDatabinder 글로벌 설정은 PDF참고

Designed by Tistory.