스프링 MVC 2편 - 백엔드 웹 개발 활용 기술 9

Spring 정리 2022. 6. 26. 22:48

인프런 강의 47일차.

 - 스프링 MVC 2편 - 백엔드 웹 개발 활용 기술 1 (김영한 강사님)

 - 1편에서 배운 MVC를 활용할 수 있는 기술 습득

 - 타입 컨버터, 파일 업로드, 활용, 쿠키, 세션, 필터, 인터셉터, 예외 처리, 타임리프, 메시지, 국제화, 검증 등등

 

 * intelliJ 단축키

  - 사용할 클래스의 생성자 살펴보기 단축키 : Ctrl + P (Windows)

  - for문 자동 생성 : iter + tab   -> for(String msg : msgList) 가 자동 생성됨

  - for문 내에서 필드 출력 : soutv -> System.out.println("msg =  " + msg);가 자동 생성됨

 

 4.8 FieldError, ObjectError

  - 목표 : 사용자 입력 오류 메시지가 화면에 남도록 하자.

  - 예) 가격을 1000원 미만으로 설정시 입력한 값이 남아있어야 한다.

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

        //bindingResult가 v1.errors 역할을 해준다.

        //1. 검증 오류 결과를 보관
//        Map<String, String> errors = new HashMap<>();   //검증오류 정보를 보관할 변수

        //2. 검증 로직
        //검증 오류가 발생하면 errors 변수에 담아둔다. (import org.springframework.util.StringUtils 추가 필요)
        if(!StringUtils.hasText(item.getItemName())) {
//            errors.put("itemName", "상품 이름은 필수입니다.");    //어떤 필드에서 오류가 발생했는지 구분하기 위해 오류가 발생한 필드명을 key 로 사용한다
            //Ctrl + P를 통해 해당 메소드의 호출 가능 형태를 볼 수 있다,
            //필드가 존재하는 오류이므로 FieldError에 binding
//            bindingResult.addError(new FieldError("item", "itemName", "상품 이름은 필수입니다."));
            //FieldError 생성자 중 2번째 생성자를 사용해서 체크
            bindingResult.addError(new FieldError("item", "itemName", item.getItemName(), false, null, null, "상품 이름은 필수입니다."));
        }

        if (item.getPrice() == null || item.getPrice() < 1000 || item.getPrice() > 1000000) {
//            errors.put("price", "가격은 1,000 ~ 1,000,000 까지 허용합니다.");
//            bindingResult.addError(new FieldError("item", "price", "가격은 1,000 ~ 1,000,000 까지 허용합니다."));
            bindingResult.addError(new FieldError("item", "price", item.getPrice(), false, null, null, "가격은 1,000 ~ 1,000,000 까지 허용합니다."));
        }

        if (item.getQuantity() == null || item.getQuantity() >= 9999) {
//            errors.put("quantity", "수량은 최대 9,999 까지 허용합니다.");
//            bindingResult.addError(new FieldError("item", "quantity", "수량은 최대 9,999 까지 허용합니다."));
            bindingResult.addError(new FieldError("item", "quantity", item.getQuantity(), false, null, null, "수량은 최대 9,999 까지 허용합니다."));
        }

        //3. 특정 필드 값이 아닌 복합 룰 검증
        if(item.getPrice() != null && item.getQuantity() != null) {
            int resultPrice = item.getPrice() * item.getQuantity();
            if(resultPrice < 10000){
                //복합 룰에 의한 오류이므로 key를 globalError로 사용
//                errors.put("globalError", "가격*수량의 합은 10,000원 이상이어야 합니다. 현재 값 = "+resultPrice);
                //필드가 아닌 경우는 ObjectError에 binding. 대신 key를 따로 지정하지 않아도 된다.
//                bindingResult.addError(new ObjectError("item", "가격*수량의 합은 10,000원 이상이어야 합니다. 현재 값 = "+resultPrice));
                //Object의 경우 따로 bind하는 것이 아닌 bind 된 값을 조합해서 에러처리하는 것이므로 bind 관련 생성자는 없고, code와 arguments 파라미터만 추가로 받을 수 있다.
                bindingResult.addError(new ObjectError("item", null, null, "가격*수량의 합은 10,000원 이상이어야 합니다. 현재 값 = "+resultPrice));
            }
        }

        //4. 검증에 실패하면 다시 입력 폼으로 redirect
        if(bindingResult.hasErrors()){   //!errors.isEmpty() -> bindingResult.hasErrors() 로 변경
            //검증 실패 시 model에 errors를 담고 입력폼이 있는 뷰 템플릿으로 보낸다.
            log.info("errors = {} ", bindingResult);
//            model.addAttribute("errors", errors);     //bindingResult는 자동으로 View에 같이 넘어가기 때문에 model에 안담아도 된다.
            return "validation/v2/addForm";
        }

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

  - hello.itemservice.web.validation.ValidationItemControllerV2

  - bindingResult의 다른 생성자를 사용한 addItemV2 구현

 

  * FieldError 생성자 파라미터 목록

   - objectName : 오류가 발생한 객체 이름

   - field : 오류 필드

   - rejectedValue : 사용자가 입력한 값(거절된 값)

   - bindingFailure : 타입 오류 같은 바인딩 실패인지, 검증 실패인지 구분 값

   - codes : 메시지 코드

   - arguments : 메시지에서 사용하는 인자

   - defaultMessage : 기본 오류 메시지

 

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

bindingResult.addError(new FieldError("item", "price", item.getPrice(), false, null, null, "가격은 1,000 ~ 1,000,000 까지 허용합니다."));

   - 사용자의 입력 데이터가 컨트롤러의 @ModelAttribute 에 바인딩되는 시점에 오류가 발생하면 모델 객체에 사용자 입력 값을 유지하기 어렵다.

   - 예를 들어서 가격에 숫자가 아닌 문자가 입력된다면 가격은 Integer 타입이므로 문자를 보관할 수 있는 방법이 없다.

   - 그래서 오류가 발생한 경우 사용자 입력 값을 보관하는 별도의 방법이 필요하다.

   - 그리고 이렇게 보관한 사용자 입력 값을 검증 오류 발생시 화면에 다시 출력하면 된다.

   - FieldError 는 오류 발생시 사용자 입력 값을 저장하는 기능을 제공한다.

   - 즉, 사용자 입력 값에 대해 서버단 검증이 실패할 경우 브라우저로 다시 응답을 주는데, 이미 날아간 데이터(=사용자입력값) 을 화면에 그대로 보여줄 수 있다.

   - 여기서 rejectedValue 가 바로 오류 발생시 사용자 입력 값을 저장하는 필드다. bindingFailure 는 타입 오류 같은 바인딩이 실패했는지 여부를 적어주면 된다. 예제에서는 바인딩이 실패한 것은 아니기 때문에 false 를 사용한다.

 

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

<input type="text" id="price" th:field="*{price}"
       th:errorClass="field-error"
       class="form-control" placeholder="가격을 입력하세요">

   - th:field="*{price}"

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

 

  * 스프링의 바인딩 오류 처리

    - 타입 오류로 바인딩에 실패하면 스프링은 FieldError 를 생성하면서 사용자가 입력한 값을 넣어둔다.

    - 그리고 해당 오류를 BindingResult 에 담아서 컨트롤러를 호출한다.

    - 따라서 타입 오류 같은 바인딩 실패시에도 사용자의 오류 메시지를 정상 출력할 수 있다

 

 4.9 오류 코드와 메시지 처리1

   - 목표 : 오류 메시지를 체계적으로 다루어보자

   - FieldError , ObjectError 의 생성자는 errorCode , arguments 를 제공한다. 이것은 오류 발생시 오류 코드로 메시지를 찾기 위해 사용된다.

   - 중복되는 오류&메시지를 한번에 쉽게 관리하기 위함.

 

  * 스프링 부트 메시지 설정 추가

spring.messages.basename=messages,errors

  - resources/application.properties

  - messages.properties 를 사용해도 되지만, 오류 메시지를 구분하기 쉽게 errors.properties 라는 별도의 파일로 관리

  - 먼저 스프링 부트가 해당 메시지 파일을 인식할 수 있게 설정을 추가한다.

  - 이렇게하면 messages.properties , errors.properties 두 파일을 모두 인식한다. (생략 시 messages.properties가 기본)

 

  * errors 메시지 파일 생성

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

  - resources/erros.properties

  - errors_en.properties 파일을 생성하면 오류 메시지도 국제화 처리를 할 수 있다.

 

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

        //2. 검증 로직
        if(!StringUtils.hasText(item.getItemName())) {
            //default 메시지는 삭제하고, errors.properties에 정의한 code 값으로 에러를 세팅해준다.
            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) {
            //errors.properties에 파라미터를 받도록 정의한 경우 new Object[]{} 로 파라미터값을 넘겨야한다.
            bindingResult.addError(new FieldError("item", "price", item.getPrice(), false, new String[]{"range.item.price"}, new Object[]{1000,1000000}, 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));
        }

        //3. 특정 필드 값이 아닌 복합 룰 검증
        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));
            }
        }

        //4. 검증에 실패하면 다시 입력 폼으로 redirect
        if(bindingResult.hasErrors()){   //!errors.isEmpty() -> bindingResult.hasErrors() 로 변경
            //검증 실패 시 model에 errors를 담고 입력폼이 있는 뷰 템플릿으로 보낸다.
            log.info("errors = {} ", bindingResult);
//            model.addAttribute("errors", errors);     //bindingResult는 자동으로 View에 같이 넘어가기 때문에 model에 안담아도 된다.
            return "validation/v2/addForm";
        }

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

  - hello.itemservice.web.validation.ValidationItemControllerV2

  - errors.properties를 사용해 메시지 처리화 시킨 addItemV3 구현

  - codes : "required.item.itemName" 를 사용해서 메시지 코드를 지정한다. 메시지 코드는 하나가 아니라 배열로 여러 값을 전달할 수 있는데, 순서대로 매칭해서 처음 매칭되는 메시지가 사용된다.

   > new String[]{"required.item.itemName", "required.default"} 로 사용 후 required.item.itemName 못찾을 시 required.default 메시지를 출력한다.

  - arguments : Object[]{1000, 1000000} 를 사용해서 코드의 {0} , {1} 로 치환할 값을 전달한다.

 

 

 4.10 오류 코드와 메시지 처리2

   - 목표 : FieldError , ObjectError 는 다루기 너무 번거롭다. 오류 코드를 좀 더 자동화 해보자.

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

//ValidationItemControllerV2.addItemV3 에서 출력
log.info("objectName={}", bindingResult.getObjectName());
log.info("target={}", bindingResult.getTarget());

//출력 결과
objectName=item //@ModelAttribute name
target=Item(id=null, itemName=상품, price=100, quantity=1234

   - rejectValue() , reject() : BindingResult 가 제공하는 함수.

      > FieldError , ObjectError 를 직접 생성하지 않고, 깔끔하게 검증 오류를 다룰 수 있다.

 

@PostMapping("/add")
    public String addItemV4(@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, null));
            //new FieldError로 매번 생성해줄 필요 없이 bindingResult가 제공하는 rejectValue로 코드를 줄일 수 있다.
            //여기서 두번째 인자인 errorCode는 required.item.itemName에서 맨 첫번째 값인 required로만 적으면 된다.
            bindingResult.rejectValue("itemName", "required");
        }

        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}, null));
            //argument가 있는 경우 new Object로 넘겨주고, defaultMsg는 null로 사용하면 된다.
            bindingResult.rejectValue("price", "range", new Object[]{1000, 1000000}, 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));
            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.addError(new ObjectError("item", new String[]{"totalPriceMin"}, new Object[]{10000, resultPrice}, null));
                //new ObjectError의 경우 rejectValue가 아닌 reject 메소드를 써야한다.
                //default 메소드 없이 errorCode만 써주면 된다.
                bindingResult.reject("totalPriceMin", new Object[]{10000, resultPrice}, null);
            }
        }

        // 검증에 실패하면 다시 입력 폼으로 redirect
        if(bindingResult.hasErrors()){   //!errors.isEmpty() -> bindingResult.hasErrors() 로 변경
            //검증 실패 시 model에 errors를 담고 입력폼이 있는 뷰 템플릿으로 보낸다.
            log.info("errors = {} ", bindingResult);
//            model.addAttribute("errors", errors);     //bindingResult는 자동으로 View에 같이 넘어가기 때문에 model에 안담아도 된다.
            return "validation/v2/addForm";
        }

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

  - hello.itemservice.web.validation.ValidationItemControllerV2

  - bindingResult.reject, rejectValue를 사용해 코드를 간소화 시킨 addItemV4 구현

  - errors.properties에 있는 errorcode를 전부 입력하지 않았는데 오류 메시지를 찾아온 이유?

 

  * rejectValue() 메소드 동작 방식

void rejectValue(@Nullable String field, String errorCode, @Nullable Object[] errorArgs, @Nullable String defaultMessage)

  - field : 오류 필드명

  - errorCode : 오류 코드(이 오류 코드는 메시지에 등록된 코드가 아니다. messageResolver를 위한 오류 코드이다.)

  - errorArgs : 오류 메시지에서 {0} 을 치환하기 위한 값

  - defaultMessage : 오류 메시지를 찾을 수 없을 때 사용하는 기본 메시지

  - BindingResult 는 어떤 객체를 대상으로 검증하는지 target을 이미 알고 있으므로 target(item)에 대한 정보는 없어도 된다. 오류 필드명은 동일하게 price 를 사용했다.

 

  * 축약된 오류 코드

   - FieldError() 를 직접 다룰 때는 오류 코드를 range.item.price 와 같이 모두 입력했다.

   - 그런데 rejectValue() 를 사용하고 부터는 오류 코드를 range 로 간단하게 입력했다.

   - MessageCodesResolver 동작방식 덕분에 가능하며, 왜 이런식으로 코드를 구성하는지 자세히 알아보자.

 

 

 4.11 오류 코드와 메시지 처리3

   - 오류 코드는 다양한 방식으로 생성이 가능하다.

    > required.item.itemName : 상품 이름은 필수 입니다.

    > range.item.price : 상품의 가격 범위 오류 입니다.

    > required : 필수 값 입니다.

    > range : 범위 오류 입니다.

   - 단순하게 만들면 범용성이 좋아서 여러곳에서 사용할 수 있지만, 메시지를 세밀하게 작성하기 어렵다.

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

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

#Level 2
required=필수 값 입니다.
range=범위는 {0} ~ {1} 까지 허용합니다.
max=최대 {0} 까지 허용합니다.

  - errors.properties에 에러 코드 작성 후 실제 코드에서 상세 오류 코드를 먼저 호출하고, 없을 시 디폴트로 범용성 메시지를 호출하도록 구현하면 된다.

  - new String[]{"required.item.itemName", "required"} : 상세 메시지 먼저 호출 후 없으면 범용성 메시지 호출

  - 개발 소스 수정 없이 메시지 소스 수정 만으로 전체 코드에 적용이 가능하다.

    > 해당 기능은 스프링에서 MessageCodesResolver 클래스로 제공하고 있다.

 

 4.12 오류 코드와 메시지 처리4

   - 오류 코드는 다양한 방식으로 생성이 가능하다.

    > required.item.itemName : 상품 이름은 필수 입니다.

    > range.item.price : 상품의 가격 범위 오류 입니다.

package hello.itemservice.validation;

import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.Test;
import org.springframework.validation.DefaultMessageCodesResolver;
import org.springframework.validation.MessageCodesResolver;
import org.springframework.validation.ObjectError;

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

    @Test
    void messageCodesResolverObject() {
        String[] messageCodes = codesResolver.resolveMessageCodes("required", "item");
        for(String message : messageCodes) {
            //Detail 코드 먼저 표시됨
            System.out.println("messageCode = "+message);
        }

        //bindingResult.rejectValue가 아래 로직을 수행시켜주는 역할이다.
        //new ObjectError("item", new String[]{"required.item", "required"})

        Assertions.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);
        }
        Assertions.assertThat(messageCodes).containsExactly(
                "required.item.itemName",
                "required.itemName",
                "required.java.lang.String",
                "required"
        );
    }
}

  - hello.itemservice.validation.MessageCodesResolverTest.java

  - MessageCodesResolver 테스트를 위한 MessageCodesResolverTest 클래스 생성

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

  - MessageCodesResolver 인터페이스이고 DefaultMessageCodesResolver 는 기본 구현체이다.

  - 주로 ObjectError, FieldError와 함께 사용 된다.

 

  * DefaultMessageCodesResolver의 기본 메시지 생성 규칙

   - 자세한 순서에서 범용적인 순서로 생성된다.

   >  객체 오류

* 객체 오류
객체 오류의 경우 다음 순서로 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 를 통해서 생성된 순서대로 오류 코드를 보관한다.

   - 이 부분을 BindingResult 의 로그를 통해서 확인해보자.

     > codes [range.item.price, range.price, range.java.lang.Integer, range]

 

  * FieldError rejectValue("itemName", "required")

   - 다음 4가지 오류 코드를 자동으로 생성한다.

     > required.item.itemName

     > required.itemName

     > required.java.lang.String

     > required

 

  * ObjectError reject("totalPriceMin")

   - 다음 2가지 오류 코드를 자동으로 생성한다.

     > totalPriceMin.item

     > totalPriceMin

 

  * 오류 메시지 출력

   - 타임리프 화면을 렌더링 할 때 th:errors 가 실행된다.

   - 만약 이때 오류가 있다면 생성된 오류 메시지 코드를 순서대로 돌아가면서 메시지를 찾는다.

   - 그리고 없으면 디폴트 메시지를 출력한다.

 

 4.13 오류 코드와 메시지 처리5

   - 오류 코드 관리 전략

  * 핵심은 구체적인 것 -> 덜 구체적인 것으로

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

   - 이렇게 하면 앞서 말한 것 처럼 메시지와 관련된 공통 전략을 편리하게 도입할 수 있다

 

  * 왜 이렇게 복잡하게 사용하는가?

   - 모든 오류 코드에 대해서 메시지를 각각 다 정의하면 개발자 입장에서 관리하기 너무 힘들다.

   - 크게 중요하지 않은 메시지는 범용성 있는 requried 같은 메시지로 끝내고, 정말 중요한 메시지는 꼭 필요할 때 구체적으로 적어서 사용하는 방식이 더 효과적이다.

 

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

  - resources/erros.properties

  - 객체 오류와 필드 오류로 구분하였고, 범용성에 따라 레벨을 나누었다.

  - itemName 의 경우 required 검증 오류 메시지가 발생하면 다음 코드 순서대로 메시지가 생성된다.

    > 1. required.item.itemName

    > 2. required.itemName

    > 3. required.java.lang.String

    > 4. required

  - 위에서 생성된 메시지 코드를 기반으로 순서대로 MessageSource 에서 메시지에서 순서대로 찾는다.

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

 

 

  * ValidationUtils 사용

// ValidationUtils를 사용한 검증 로직
ValidationUtils.rejectIfEmptyOrWhitespace(bindingResult, "itemName", "required");

// 검증 로직
if(!StringUtils.hasText(item.getItemName())) {
    bindingResult.rejectValue("itemName", "required");
}

  - 위의 검증로직 2개는 실행 결과가 동일하다.

  - ValidationUtils는 값이 empty 혹은 공백 같은 단순한 기능에 대한 밸리데이션만 제공한다.

 

  * bindingResult.hasErrors() 메소드

//오류가 하나라도 있는 경우 바로 addForm으로 돌려 버리는 로직
if(bindingResult.hasErrors()) {
    log.info("errors={} ", bindingResult);
    return "validation/v2/addForm";
}

  - bindingResult에 에러가 존재할 경우 바로 addForm을 호출하는 로직

  - 입력값에 대한 모든 검증 후 전체 에러를 보여줄지, 에러가 존재하는 건 바이 건으로 화면에 바로 보여줄지 UX에 따라 선택하면 됨.

 

  * 정리

   - 1. rejectValue() 호출

   - 2. MessageCodesResolver 를 사용해서 검증 오류 코드로 메시지 코드들을 생성

   - 3. new FieldError() 를 생성하면서 메시지 코드들을 보관

   - 4. th:erros 에서 메시지 코드들로 메시지를 순서대로 메시지에서 찾고, 노출

 

 

 4.14 오류 코드와 메시지 처리6

   - 스프링이 직접 만든 오류 메시지 처리

   - 검증 오류 코드는 다음과 같이 2가지로 나눌 수 있다.

     > 개발자가 직접 설정한 오류 코드 rejectValue() 를 직접 호출

     > 스프링이 직접 검증 오류에 추가한 경우(주로 타입 정보가 맞지 않음)

 

  * 지금까지 학습한 메시지 코드 전략의 강점을 지금부터 확인해보자.

   - price 필드에 문자 "A"를 입력해보자.

   - 로그를 확인해보면 BindingResult 에 FieldError 가 담겨있고, 다음과 같은 메시지 코드들이 생성된 것을 확인할 수 있다.

   - codes[typeMismatch.item.price,typeMismatch.price,typeMismatch.java.lang.Integer,typ eMismatch]

   - 다음과 같이 4가지 메시지 코드가 입력되어 있다.

     > typeMismatch.item.price

     > typeMismatch.price

     > typeMismatch.java.lang.Integer

     > typeMismatch

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

   - 이 오류 코드가 MessageCodesResolver 를 통하면서 4가지 메시지 코드가 생성된 것이다.

 

  * 실행 결과

   - errors.properties 에 typeMismatch 코드가 없기 때문에 스프링이 생성한 기본 메시지가 출력된다.

    > Failed to convert property value of type java.lang.String to required type

    > java.lang.Integer for property price; nested exception is java.lang.NumberFormatException: For input string: "A"

 

  * errors.properties에 typeMismatch 내용 추가

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

  - 메시지 추가 후 재실행 시 추가한 메시지가 표시되는 것을 확인할 수 있다.

  - 즉, 소스코드 수정 없이 원하는 메시지를 단계별로 설정할 수 있다.

 

 

 4.15 Validator 분리1

  - 목표 : 복잡한 검증 로직을 별도로 분리하자

  - 스프링이 직접 만든 오류 메시지 처리

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

  - 이렇게 분리한 검증 로직은 재사용 할 수 있다.

 

package hello.itemservice.web.validation;

import hello.itemservice.domain.item.Item;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.validation.Errors;
import org.springframework.validation.Validator;

//Spring이 제공하는 Validator를 사용하여 구현. Component 스캔 처리하여 Spring Bean에 등록
@Component
public class ItemValidator implements Validator {

    @Override
    public boolean supports(Class<?> clazz) {
        return Item.class.isAssignableFrom(clazz);
        //item == clazz (클래스로 넘어오는 타입과 동일한지)
        //item == subItem (자식클래스에도 타입 비교가 가능)
    }

    @Override
    public void validate(Object target, Errors errors) {
        Item item = (Item) target;  //Interface가 Object로 구현되어있으므로 캐스팅해서 사용
        //Errors는 bindingResult의 부모 클래스이다.

        // 검증 로직
        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);
            }
        }

    }
}

  - hello.itemservice.web.validation.ItemValidator.java

  - 검증 로직 클래스를 따로 구현한 ItemValidator 클래스

 

  * 스프링은 검증을 체계적으로 제공하기 위해 다음 인터페이스를 제공한다

public interface Validator {
    boolean supports(Class<?> clazz);
    void validate(Object target, Errors errors);
}

  - supports() {} : 해당 검증기를 지원하는 여부 확인(뒤에서 설명)

  - validate(Object target, Errors errors) : 검증 대상 객체와 BindingResult

 

  * ItemValidator 직접 호출하기

//@RequiredArgsConstructor를 사용하면 생성자가 하나일 경우 단순 선언만으로도 Autowired 주입이 된다.
    private final ItemValidator itemValidator;

....

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

        //1줄로 검증 로직 수행 가능
        itemValidator.validate(item, bindingResult);

        // 검증에 실패하면 다시 입력 폼으로 redirect
        if(bindingResult.hasErrors()){   //!errors.isEmpty() -> bindingResult.hasErrors() 로 변경
            //검증 실패 시 model에 errors를 담고 입력폼이 있는 뷰 템플릿으로 보낸다.
            log.info("errors = {} ", bindingResult);
//            model.addAttribute("errors", errors);     //bindingResult는 자동으로 View에 같이 넘어가기 때문에 model에 안담아도 된다.
            return "validation/v2/addForm";
        }

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

  - hello.itemservice.web.validation.ValidationItemControllerV2

  - ItemValidator를 사용해 검증로직을 분리한 addItemV5 구현

  - ItemValidator 를 스프링 빈으로 주입 받아서 직접 호출했다. 

 

 4.16 Validator 분리2

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

  - 그런데 앞에서는 검증기를 직접 불러서 사용했는데, Validator 인터페이스를 사용해서 검증기를 만들면 스프링의 추가적인 도움을 받을 수 있다.

 

  * WebDataBinder를 통해서 사용하기

   - WebDataBinder 는 스프링의 파라미터 바인딩의 역할을 해주고 검증 기능도 내부에 포함한다

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

  - hello.itemservice.web.validation.ValidationItemControllerV2

  - itemValidator 검증기 추가

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

  - @InitBinder 해당 컨트롤러에만 영향을 주기 때문에 글로벌 설정은 별도로 해야한다. 

 

  * @Validated 적용

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

        //@Validated 애노테이션을 추가하고, 메소드 내에 검증기 호출 로직은 주석처리한다. -> Item 에 대한 검증기가 자동으로 수행이 된다.
//        itemValidator.validate(item, bindingResult);

        // 검증에 실패하면 다시 입력 폼으로 redirect
        if(bindingResult.hasErrors()){   //!errors.isEmpty() -> bindingResult.hasErrors() 로 변경
            //검증 실패 시 model에 errors를 담고 입력폼이 있는 뷰 템플릿으로 보낸다.
            log.info("errors = {} ", bindingResult);
//            model.addAttribute("errors", errors);     //bindingResult는 자동으로 View에 같이 넘어가기 때문에 model에 안담아도 된다.
            return "validation/v2/addForm";
        }

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

  - hello.itemservice.web.validation.ValidationItemControllerV2

  - 메소드 호출 때 자동으로 아이템 검증기를 수행하고 오는 addItemV6 메소드 구현

  - databinder에 있는 validator를 수행하므로 미리 추가되어있어야 한다.

  - 실제 검증을 원하는 객체에 @Validated를 붙이면 된다.

 

  * 동작 방식

   - @Validated 는 검증기를 실행하라는 애노테이션이다.

   - 이 애노테이션이 붙으면 앞서 WebDataBinder 에 등록한 검증기를 찾아서 실행한다.

   - 그런데 여러 검증기를 등록한다면 그 중에 어떤 검증기가 실행되어야 할지 구분이 필요하다.

   - 이때 supports() 가 사용되어 supports(Item.class) 호출되고, 결과가 true 이므로 ItemValidator 의 validate() 가 호출된다.

 

  * 글로벌 설정 - 모든 컨트롤러에 다 적용

package hello.itemservice;

import hello.itemservice.web.validation.ItemValidator;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.validation.Validator;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@SpringBootApplication
public class ItemServiceApplication implements WebMvcConfigurer {

   public static void main(String[] args) {
      SpringApplication.run(ItemServiceApplication.class, args);
   }

   @Override
   public Validator getValidator() {
      return new ItemValidator();
   }
}

  - hello.itemservice.ItemServiceApplication.java

  - 글로벌 설정을 적용하기 위해서는 WebMvcConfiguer를 추가하고, 적용하고 싶은 Validator를 추가하면 된다.

  - 선언한 Validator를 적용하고 싶은 메소드에서 @Validated 를 호출해보면 @InitBinder를 제거해도 정상적으로 Validator가 동작되는 것을 확인할 수 있다.

  - 글로벌 설정을 하면 BeanValidator가 자동 등록되지 않는다.

 

  * 참고

  - 글로벌 설정을 직접 사용하는 경우는 드물다.

  - 검증시 @Validated @Valid 둘다 사용가능하다.

  - javax.validation.@Valid 를 사용하려면 build.gradle 의존관계 추가가 필요하다.

  - implementation 'org.springframework.boot:spring-boot-starter-validation'

  - @Validated 는 스프링 전용 검증 애노테이션이고, @Valid 는 자바 표준 검증 애노테이션이다.

  - 자세한 내용은 다음 Bean Validation에서 설명.