Validation

기존까지는 사용자가 입력한 값에 대한 검증을 수행하지 않았다. 하지만 실제 서비스를 제공할 때에는 사용자가 입력하는 값에 대해 철저하게 검증을 수행해야한다.

지금의 어플리케이션에 만약 숫자를 입력해야하는 칸에 문자를 입력하게 되면 어떻게 될까? 가령 나이를 입력하는데 ‘a’ 와 같은 문자를 입력한다면 오류 화면으로 넘어가게 되고, 뒤로 가기 하면 기존에 작성된 정보가 사라지게 된다.

웹 서비스의 경우 사용자가 잘못 입력한 오류에 대해 친절하게 어디서 오류가 발생했는지를 알려줘야하며, 기존에 입력한 데이터는 유지해야한다.

클라이언트 vs 서버 기반의 검증

  1. 클라이언트 기반의 검증은 간단하게 구현할 수 있고, 매우 빠르게 검증을 수행할 수 있지만, client 단에서의 조작이 가능해서 보안에 취약하다.

  2. 서버 기반의 검증은 보안에 우세하지만, 서버로의 접속을 거쳐야 한다는 점에서 즉각적인 반응이 어렵다는 점이 있다.

  3. 보통은 위 2가지 방식을 결합해서 사용하는 경우가 많다.

Validation V1

Spring, Thymeleaf가 제공하는 검증 기능을 활용하기 전에 직접 검증 기능을 구현해보자

validation_implementation

위와 같이 정상적으로 상품 데이터를 입력해서 상품 등록을 하는 경우, 상품 저장 프로세스를 진행하고 상품 등록 과정에서 잘못된 입력이 발생하게 되면 다시 상품 등록 폼으로 다시 라우팅 한다.

이때, model에 저장되어 있던 정보를 그대로 넘어가기 때문에 기존에 사용자 입력한 데이터는 유지되게 된다.

검증 로직

//검증 오류 결과 매핑
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", "가격은 1000~1000000 사이입니다.");

if(item.getQuantity() == null || item.getQuantity() >=9999)
    errors.put("quantity", "수량은 최대 9999개 입니다.");

if(item.getPrice() != null && item.getQuantity() != null){
    int resultPrice = item.getPrice() * item.getQuantity();
    log.info("price: {} * quantity: {} = {}", item.getPrice(), item.getQuantity(), resultPrice);
    if(resultPrice < 10000){
        errors.put("globalError", "가격과 수량의 곱은 10000이상 이어야합니다. 현재값: " + resultPrice);
    }
}

//오류 정보가 있는 경우 상품 등록폼으로 다시 보낸다.
if(!errors.isEmpty()){
    System.out.println(errors);
    model.addAttribute("errors", errors);
    return "validation/v2/addForm";

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

위와 같은 검증 로직을 수행하면서 어떠한 부분에 오류가 발생했는지를 사용자에게 알려주기 위해 Map에 필드값과 오류 내용을 추가해서 model로 넘겨준다.

오류 정보 출력

addForm.html

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

만약 정상적인 addForm 호출로 인해, error객체가 없는 경우 위의 객체 접근은 NullPointerException이 발생하게 되는데, 이를 방지하기 위해 ?를 사용한다. 만약 객체가 존재하지 않는 경우 전체 표현식이 null 값이 되는 것이다. th:if 에 null이 들어가게 되므로 해당 th 태그는 실행되지 않는다.

검증 로직을 수행해서 오류 정보가 생성된 경우 위와 같이 에러 정보를 표시하기 위해서 위와 같은 태그를 추가하게 된다. 위의 th 태그가 정상적으로 랜더링되면 아래와 같다

```html
<div>
    <p class="field-error">가격 * 수량의 합은 10,000원 이상이어야 합니다. 현재 값 = 1000</p>
</div>

개선점

  1. 뷰 템플릿에 중복되는 코드가 많다.
  2. 타입에 대한 오류 처리가 없다. Integer를 받아야하는 곳에 문자를 입력하는 타입 에러를 처리할 수 없다. 애초에 이러한 오류가 발생하면 Controller로 넘어오기 전에 문제가 발생해서 400 에러를 발생시킨다. 또한, 넘어 왔다 하더라도 고객이 잘못 입력한 데이터를 다시 폼으로 넘겨줘야하는데, price 필드에는 문자열을 저장할 수 없어 @ModelAttribute를 통한 model을 넘겨주는 방식에서는 사용할 수 없다. 이와 같이 바인딩 불가능하므로 고객이 입력한 데이터를 다른 곳에 보관할 필요가 있다.

Binding Result

BindingResult 객체를 이용해서 오류 정보를 전달한다, 이때 주의해야 할것은 BindingResult 객체에 대한 파라미터 위치는 항상 검증 대상 바로 오른쪽에 위치해야한다. 이는, 추후에 있을 Message 처리와 관련이 있다.

Controller V1

@PostMapping("/add")
    public String addItemV1(@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", "가격은 1000~1000000 사이입니다."));

        }
        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();
            log.info("price: {} * quantity: {} = {}", item.getPrice(), item.getQuantity(), resultPrice);
            if(resultPrice < 10000){
                bindingResult.addError(new ObjectError("item", "가격과 수량의 곱은 10000이상 이어야합니다. 현재값: " + resultPrice));
            }
        }

        if(bindingResult.hasErrors()){
            log.info("errors={}", bindingResult);
            return "validation/v2/addForm";
        }

BindingResult을 보면 위와 같이 FieldError와 Object Error을 담아서 전달할 수 있다.

FieldError

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

FieldError 객체를 이용해서 필드오류 정보를 담는다.

  • objectName은 검증 대상 객체
  • field는 오류가 발생한 필드
  • defaultMessage는 오류에 대한 메세지

defautlMessage가 있는 것을 보면 알겠지만, 나중에 message와 연관되어 있다.

ObjectError

public ObjectError(String objectName, String defaultMessage) {}

ObjectError 객체는 글로벌 에러를 담기 위한 객체이다.

다음과 BindingResult을 만들어서 오류 정보를 저장하게되면 Thymeleaf를 이용해서 BindingResult 객체에 접근해서 오류 정보를 가져올 수 있다.

global error

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

#fields로 BindingResult에 접근할 수 있다. 이때 hasGlobalErrors()를 이용하면 글로벌 에러(ObjectError)가 있는지 여부를 반환하며, globalErrors()를 이용해서 에러 객체를 대상으로 반복문을 수행해서 글로벌 오류를 출력한다.

field error

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

th:errors 을 이용해서 에러가 발생하는 필드명을 작성한다. 이때, th:object에 설정된 객체를 대상으로 *{} 타입으로 명시한다. 이렇게 하면 자동적으로 th:if, th:text를 랜더링하게 된다.

th:errorclass를 사용하게 되면 지정한 필드에 오류가 발생했을 경우 errorclass에 설정한 class을 class 태그에 추가하게 된다.

BindingResult를 이용하게 되면 이 처럼 복잡한 th 로직에 대해서도 단순화 할 수 있다.

Controller V2

BindingResult를 사용하기 전에는 타입 오류는 처리 하지 못했다. @ModelAttribute만을 사용하는 검증 방식에서는 타입이 잘못되는 경우에 대해서는 model에 저장되지 않고 바로 400 에러를 발생시킨다.

하지만, BindingResult을 이용하게 되면 해당 오류 정보가 BindingResult에 담기게 된다.

즉, Spring에서 타입 오류에 대한 에러(FieldError)을 만들어서 저장시킨다.

BindingResult에 저장하는 방법

  1. @ModelAttribute에 바인딩 에러(타입 오류)와 같은 문제가 발생해서 Spring에서 직접 FieldError을 생성해서 넣어준다.

  2. 개발자가 직접 넣어준다(위의 Controller V1)

  3. Validator 방식

BindingResult 인터페이스이며, Error 객체를 상속받고 있고, 실체 구현체는 BeanPropertyBindingResult이다. 그렇기 때문에 Errors 인터페이스를 이용해서 오류를 저장해도 되지만, 단순 오류 저장 및 조회만을 지원하기 때문에 추가적인 기능을 제공하는 BindingResult을 사용한다.

위의 Controller V1,V2 까지 설정하게 되면, 타입오류 까지는 잡을 수 있지만, 고객이 입력한 데이터가 유지되지 않고 사라지게 된다.

Controller V3

Field Error

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

이때는, FieldError의 두번째 생성자를 활용한다.

여기서 추가되는 argument는 rejectedValue, bindingFailure, codes, arguments이다.

rejectedValue에 바로 사용자가 입력한 값을 인자로 전달한다. bindingFailure는 바인딩 오류 여부를 나타내는데, 이는 타입 오류 발생여부를 의미한다.(Spring이 BindingResult에 넣어줄 때 해당 인자를 true로 해서 만든다)

codes, arguments는 오류 메세지와 관련된 인자이다.

스프링이 타입오류를 BindingResult로 처리할 수 있는 방식이 바로 위와 같이 FieldError에 rejectedValue와 bindingError parameter 때문이다.

Object Error

public ObjectError(String objectName, @Nullable String[] codes, @Nullable Object[] arguments, @Nullable String defaultMessage)

Object Error에 대해서도 위의 FieldError와 비슷한 생성자를 제공한다.

thymeleaf printing error

<input type="text" id="itemName" th:field="*{itemName}" th:errorclass="field-error" class="form-control" placeholder="이름을 입력하세요">

th 태그에는 변화를 주지 않았는데도 사용자의 입력값이 유지된다.

이는 th:field가 제공해주는 기능 중에 하나이다.

th:field는 @ModelAttribute에서 model이 정상적으로 넘어오는 경우 model객체를 이용해서 값을 출력하지만 해당 필드에 오류가 발생하는 경우, FieldError에 있는 rejectedValue 값을 활용한다.

Controller V4

이제는 오류에 대한 message 처리를 다뤄보자

오류 코드는 error.properties에 저장하고 Spring Boot에서 해당 메세지 파일을 인식할 수 있도록 설정을 추가한다.

application.properties

spring.messages.basename=messages,errors

error.properties

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

이제는 codes를 명시하게 되면 message.properties 와 error.properties를 확인해서 code에 대응되는 메세지가 출력되게 된다.

Field Error에 code, argument 적용

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,100000}, null));
}

위와 같이 String[]{“range.item.price”},new Object[]{1000,100000} 으로 code 와 argument을 전달한다.

Controller V5

위를 보면 FieldError에 너무 많은 인자가 있어 쓰기에 매우 번거로운 것을 확인할 수 있다. 이를 reject, rejectValue를 활용한다.

기본적으로, BindingResult에는 검증해야하는 대상 바로 옆에 위치해서, 검증해야하는 객체를 이미 내부적으로 가지고 있다. 따라서, objectName이나 rejectedValue를 인자로 전달할 필요가 없다.

rejectValue()

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

rejectValue 메소드 사용

bindingResult.rejectValue("price", "range", new Object[]{1000, 1000000}, null)

위의 rejectValue 메소드를 이용하면, 필드 정보, 메세지 관련 인자들로만 간편한게 에러 메세지 처리 및 검증 과정을 수행할 수 있다.

근데, 위의 사용예제를 보면 메세지 코드가 error.properties에 명시된 메세지 코드와 다른 것을 확인할 수 있다. 그런데도 오류 메세지가 제대로 출력되는 것을 확인할 수 있다.

이는 내부적으로 MessageCodesResolver가 동작하기 때문에 가능한 일이다.

Controller V6

메세지 범용성/상세성

error.properties

#Level1
required.item.itemName: 상품 이름은 필수 입니다.
#Level2
required: 필수 값 입니다.

위의 메시지 코드를 살펴보면 Level 1 의 경우 매우 상세한 정보를 출력하고 있고, Level 2를 보면 축약해서 정보를 출력하고 있다. Level 2의 경우 다른 도메인, 뷰에서 해당 에러를 출력할 수 있는 경우가 많아 범용성이 좋지만, Level 1의 경우 상품 관련 도메인에서만 사용 가능하다. 이처럼 메세지에도 단계를 나눠서 처리할 수 있다.

그래서 메세지 코드를 조회하게 되면 우선 상세한 메세지 코드부터 접근 되며, 만약에 없으면 그 다음 코드가 출력된다.

이러한 단계별로 메세지를 처리를 위해 MessageCodesResolver 라는 것이 존재한다.

DefaultMessageCodesResolver
void resolveObject(){
    String[] messageCodes = codesResolver.resolveMessageCodes("required", "item");

    Assertions.assertThat(messageCodes).containsExactly("required.item", "required");
}

void resolveField(){
    String[] messageCodes = codesResolver.resolveMessageCodes("required", "item","itemName",String.class);

    Assertions.assertThat(messageCodes).containsExactly("required.item.itemName","required.itemName","required.java.lang.String", "required");
}

Object Error

객체 오류의 경우 다음 순서로 2가지 생성 1.: code + “.” + object name 2.: code

예) 오류 코드: required, object name: item 1.: required.item 2.: required

Field Error

필드 오류의 경우 다음 순서로 4가지 메시지 코드 생성 1.: code + “.” + object name + “.” + field 2.: code + “.” + field 3.: code + “.” + field type 4.: code

예) 오류 코드: required, object name: item, field: itemName, field type: String

  1. required.item.itemName
  2. required.itemName
  3. required.java.lang.String
  4. required

reject, rejectValue 내부적으로 MessageCodesResolver가 동작해서, 한번에 여러 개의 코드를 String[] 으로 전달하게 되는 것이다.

만약에 전달된 메세지 코드와 일치하는 코드가 없는 경우, default message 가 출력되게 된다.

그래서 핵심은 이제 메세지 코드를 구성하는 것이다.

errors.properties

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

메세지 코드를 구성할 때는 구체적인 것은 앞에 작성하고, 덜 구체적인 것은 나중에 사용하게 되는 것이다. 이렇게 메세지에 단계를 설정해 작성해서 다양한 오류 코드에 대한 처리가 가능하다.

모든 오류 코드에 대한 처리를 다 작성하게 되면 개발자 입장에서 메세지 코드 작성에 시간을 많이 사용하게 된다. 따라서 이와 같이 메세지 구체성에 단계를 둬서 처리하도록 한다.

타입 오류 처리

기존에 숫자를 입력해야하는 나이 필드에 문자를 입력하게 되면 BindingResult에 FieldError을 추가해줬다. 이때 해당 에러에 대한 기본 메세지는 다음과 같다

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"

이와 같이 메세지를 출력하게 되면 고객은 이러한 메세지를 이해하기 어렵다. 그렇기 때문에 메세지를 수정해야하는데, 타입 오류의 경우 Spring에서 BindingResult에 직접 넣어주기 때문에 codes, arguments, default message 를 직접 수정할 수는 없다. 하지만 spring에서도 내부적으로 FieldError을 추가할때 codes를 인자로 전달하는데 이는 아래와 같다

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

따라서, errors.properties 에 해당 메세지 코드를 추가해주면, 이에 맞는 에러 메세지를 출력하는 것이 가능하다.

errors.properties

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

Validator 분리

현재 Controller에 검증 로직이 혼합되어 있는데, 이를 클래스로 역할을 분리하는 것이 좋다.

Validator

Validator Interface

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

ItemValidator 클래스

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

        //검증 로직
        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,100000}, 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();
            log.info("price: {} * quantity: {} = {}", item.getPrice(), item.getQuantity(), resultPrice);
            if(resultPrice < 10000){
                errors.reject("totalPriceMin",new Object[]{10000,resultPrice}, null);
                ObjectError()
            }
        }
    }

}

Spring에서는 검증 로직을 위해 위의 Validator 인터페이스를 제공한다. supports() 메소드를 이용해서 해당 객체를 처리할 수 있는 검증기인지 여부를 반환하고, validate 메소드를 이용해서 검증 로직을 수행한다. 이때, target는 검증 대상이고, errors는 BindingResult이다(BindingResult는 Errors 클래스를 상속하기 때문에 가능하다.)

Applying Validator

validator 직접 호출

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

위와 같이 직접 validator 객체를 호출해서 검증로직을 수행하도록 할 수 있다. 하지만, Spring 에서 validator와 같은 인터페이스를 제공하는 이유는 자동으로 이를 호출하도록 할 수 있도록 하기 때문이다.

Item Controller에 WebDataBinder 추가

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

위와 같이 WebDataBinder에 검증기를 추가해서 해당 컨트롤러에서 검증기가 자동으로 수행되도록 할 수 있다. (@InitBinder)을 활용하면 해당 controller에 대해서만 영향을 준다.

@Validated 추가

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

@Validated 표시를 추가해서 해당 객체에 대해 검증이 자동으로 수행되도록 한다.

WebDataBinder에 검증기를 여러 개 등록할 수 있는데, 이때 해당 객체를 검증하기 위해 어떤 검증기를 사용해야하는지 여부를 판단해주는 것이 supports 메소드이다. 현재 등록된 ItemValidator 는 Item class을 처리할 수 있기 때문에 true를 반환하게 된다. 이후에 validate 메소드를 통해 검증이 수행된다.

글로벌 설정

@SpringBootApplication
public class ItemServiceApplication implements WebMvcConfigurer {
    public static void main(String[] args) {
        SpringApplication.run(ItemServiceApplication.class, args);
    }
    
    @Override
    public Validator getValidator() {
        return new ItemValidator();
    }
}

위와 같이 WebMvcConfigurer에 대해 getValidator() 메소드를 오버라이드 하면 모든 컨트롤러에서 ItemValidator을 활용하도록 설정할 수도 있다.

References

link: inflearn

link:springmvc

댓글남기기