Programming/Spring

Spring - 검증 1 - Validation

잇(IT) 2023. 6. 22. 11:48

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

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

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


- FieldError 생성자 요약

public FieldError(String objectName, String field, String defaultMessage) {}

- 필드에 오류가 있으면 FieldError 객체를 생성해서 bindingResult 에 담아두면 된다.

1. objectName : @ModelAttribute 이름

2. field : 오류가 발생한 필드 이름

3. defaultMessage : 오류 기본 메시지

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)

- 파라미터 목록

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

2. field : 오류 필드

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

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

5. codes : 메시지 코드

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

7. defaultMessage : 기본 오류 메시지

 

- ObjectError 생성자 요약

public ObjectError(String objectName, String defaultMessage) {}

- 특정 필드를 넘어서는 오류가 있으면 ObjectError 객체를 생성해서 bindingResult 에 담아두면 된다.

1. objectName : @ModelAttribute 의 이름

2. defaultMessage : 오류 기본 메시지


- 글로벌 오류 처리

<div th:if="${#fields.hasGlobalErrors()}">
    <p class="field-error" th:each="err : ${#fields.globalErrors()}" th:text="${err}">글로벌 에러 메시지</p>
</div>

 

- 필드 오류 처리

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

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

- 타임리프는 스프링의 BindingResult 를 활용해서 편리하게 검증 오류를 표현하는 기능을 제공한다.

1. #fields : #fields 로 BindingResult 가 제공하는 검증 오류에 접근할 수 있다.

2. th:errors : 해당 필드에 오류가 있는 경우에 태그를 출력한다. th:if 의 편의 버전이다.

3. th:errorclass : th:field 에서 지정한 필드에 오류가 있으면 class 정보를 추가한다.


- BindingResult에 검증 오류를 적용하는 3가지 방법

1. @ModelAttribute 의 객체에 타입 오류 등으로 바인딩이 실패하는 경우 스프링이 FieldError 생성해서 BindingResult 에 넣어준다.

2. 개발자가 직접 넣어준다.

3. Validator 사용 이것은 뒤에서 설명

 

- 타입 오류 확인

1. 숫자가 입력되어야 할 곳에 문자를 입력해서 타입을 다르게 해서 BindingResult 를 호출하고 bindingResult 의 값을 확인해보자.

 

- 주의

1. BindingResult 는 검증할 대상 바로 다음에 와야한다. 순서가 중요하다. 예를 들어서 @ModelAttribute Item item , 바로 다음에 BindingResult 가 와야 한다.

2. BindingResult 는 Model에 자동으로 포함된다.

 

- FieldsError 생성자

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)

- 파라미터 목록

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

2. field : 오류 필드

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

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

5. codes : 메시지 코드

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

7. defaultMessage : 기본 오류 메시지

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

 

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

- th:field="*{price}"

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

 

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

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


- 오류 코드 메시지 처리

 

- errors 메시지 파일 생성

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

2. 먼저 스프링 부트가 해당 메시지 파일을 인식할 수 있게 다음 설정을 추가한다. 이렇게하면 messages.properties , errors.properties 두 파일을 모두 인식한다. (생략하면 messages.properties 를 기본으로 인식한다.)

 

- application.properties

spring.messages.basename=messages,errors

- src/main/resources/errors.properties

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

- range.item.price의 메시지의 경우 뒤에 인자가 2개가 있기 때문에 new object[]를 통해 인자에 대한 값을 넣어준다.


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

 

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

if (item.getPrice() == null || item.getPrice() < 1000 || item.getPrice() > 1000000) {
            bindingResult.rejectValue("price","range",new Object[]{1000,1000000},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()

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

1. field : 오류 필드명

2. errorCode : 오류 코드(이 오류 코드는 메시지에 등록된 코드가 아니다. 뒤에서 설명할 messageResolver를 위한 오류 코드이다.)

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

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

 

- reject()

void reject(String errorCode, @Nullable Object[] errorArgs, @Nullable String
defaultMessage);

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

 

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

- 동작 방식

1. rejectValue() , reject() 는 내부에서 MessageCodesResolver 를 사용한다. 여기에서 메시지 코드들을 생성한다.

2. FieldError , ObjectError 의 생성자를 보면, 오류 코드를 하나가 아니라 여러 오류 코드를 가질 수 있다.

3. MessageCodesResolver 를 통해서 생성된 순서대로 오류 코드를 보관한다.

4. 이 부분을 BindingResult 의 로그를 통해서 확인해보자. codes [range.item.price, range.price, range.java.lang.Integer, range]

 

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

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

1. required.item.itemName

2. required.itemName

3. required.java.lang.String

4. required

 

- ObjectError reject("totalPriceMin")

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

1. totalPriceMin.item

2. totalPriceMin


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

 

- 핵심은 구체적인 것에서! 덜 구체적인 것으로!

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


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

 

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

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

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

 

- 타입이 맞지 않는 상황에 BindingResult에 FieldError가 담기고 해당 내용은 아래와 같다.

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

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

1. typeMismatch.item.price

2. typeMismatch.price

3. typeMismatch.java.lang.Integer

4. typeMismatch

 

- error.properties 에 다음 내용을 추가하자

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

- Validator 분리1

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

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

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

public boolean supports(Class<?> clazz) {
        return Item.class.isAssignableFrom(clazz);
        //item == clazz
        //item == subItem
    }

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

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

private final ItemValidator itemValidator;
...

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 분리2

private final ItemValidator itemValidator;

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

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

 

@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를 직접 호출하는 부분이 사라지고, 대신에 검증 대상 앞에 @Validated 가 붙었다.

 

- 동작 방식

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

2. 이 애노테이션이 붙으면 앞서 WebDataBinder 에 등록한 검증기를 찾아서 실행한다. 그런데 여러 검증기를 등록한다면 그 중에 어떤 검증기가 실행되어야 할지 구분이 필요하다. 이때 supports() 가 사용된다.

3. 여기서는 supports(Item.class) 호출되고, 결과가 true 이므로 ItemValidator 의 validate() 가 호출된다.

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

출처 : 인프런 - 우아한 형제들 기술이사 김영한의 스프링 완전 정복 (스프링 핵심원리 - 기본 편)

728x90

'Programming > Spring' 카테고리의 다른 글

Spring - 로그인 처리 1 - 쿠키, 세션  (0) 2023.06.22
Spring - Bean Validation  (0) 2023.06.22
Spring - 메시지, 국제화  (0) 2023.06.22
Spring - 타임리프 - 기본 기능  (0) 2023.06.20
Spring - 스프링 MVC - 기본 기능  (0) 2023.06.18