[Spring] 유효성 검증하기 (Validation)

웹 애플리케이션을 만들고 유효성 검증을 추가 하지 않는다면, 사용자가 폼에 잘못된 값을 입력할 시 아래와 같은 오류화면을 만나게 될 것 입니다.

이렇게 되면 클라이언트 입장에서는 상당히 당황스러울 것이고 입력한 값이 모두 날라가 처음부터 다시 작성해야 하는 일이 생깁니다.

또한 우리가 웹 사이트에서 값을 잘못입력했다고 이런 오류화면을 본적도 없죠.

 

우리는 웹 서비스를 할 때 이렇게 클라이언트가 해결하지 못하는 오류 화면을 보여줄 것이 아니라

클라이언트가 잘 못 입력했을 시 무엇을 잘못 입력했는지, 어떻게 입력해야하는 지를 알려줘야합니다.

그래서 Validation 검증 기능을 추가어떤 값을 잘못 입력했는지 알 수 있도록 해봅시다.

 

결과물과 주의점

이 포스팅은 검증 설명에 중점을 뒀기 때문에

예제에 사용된 코드는 이 결과물을 만들기 위한 과정만을 위한 설명으로 설명에 필요없는 몇몇 코드가 빠져있기 때문에 똑같이 코드를 사용해도 위 결과물을 얻을 수 없습니다.

 


오류 메시지 관리

errors.properties 오류 메시지

errors.properties 프로퍼티 파일을 만들고 다음과 같이 내용을 추가합니다.

검증 로직 클래스에서 MessageCodesResolver에 대해 설명하겠지만 간단하게 설명하고 넘어가자면

 "메시지이름.객체이름.객체필드" (required.item.itemName) 로 작성한 메시지는 메시지이름으로만 작성 (required) 보다 구체화된 메시지같은 메시지 이름일 경우 우선순위로 사용됩니다.

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

required = 필수 값 입니다.
min= {0} 이상이어야 합니다.
range= {0} ~ {1} 범위를 허용합니다.
max= {0} 까지 허용합니다.

 

타임리프에서 사용할 일반 메시지
hello=안녕
hello.name=안녕 {0}

label.item=상품
label.item.id=상품 ID
label.item.itemName=상품명
label.item.price=가격
label.item.quantity=수량

page.items=상품 목록
page.item=상품 상세
page.addItem=상품 등록
page.updateItem=상품 수정

button.save=저장
button.cancel=취소

 

그리고 application.properties 파일에 두 메시지를 읽어 올 수 있도록 다음과 같이 옵션을 추가해줍니다.

spring.messages.basename=messages, errors

 


Item 객체와 Repository

Item
import lombok.Data;

@Data
public class Item {

    private Long id;
    private String itemName;
    private Integer price;
    private Integer quantity;

    public Item() {
    }

    public Item(String itemName, Integer price, Integer quantity) {
        this.itemName = itemName;
        this.price = price;
        this.quantity = quantity;
    }
}

 

Repository
import java.util.HashMap;
import java.util.Map;

@Repository
public class ItemRepository {

    private static final Map<Long, Item> store = new HashMap<>();
    private static long sequence = 0L;

    public Item save(Item item) {
        item.setId(++sequence);
        store.put(item.getId(), item);
        return item;
    }
}

 


검증 로직을 담당하는 클래스 생성

 

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

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

해당 검증기를 지원하는 여부 확인 ( @Validated 애노테이션과 상호작용, 아래 컨트롤러에 설명 )

결과가 True이면 validate 메서드 실행

 

  • validate(Object target, Errors errors)

Object target은 검증할 객체를 넘깁니다. (캐스팅 해서 사용)

Errors는 BindingResult의 부모로 errors에 Controller의 파라미터인 BindingResult를 넘겨줍니다.

(BindingResult는 아래 COntroller에 설명)

 

검증 로직 클래스
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;

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

 

Item.class.isAssignableFrom(clazz); 

파라미터로 넘어오는 "clazz"가 Item.class에 지원이 되느냐 확인합니다.

isAssignableFrom()는 파라미터로 받은 "clazz"의 자식 클래스도 같이 확인해줍니다.

 

rejectValue() , reject()

컨트롤러에서 BindingResult 는 검증해야 할 객체 바로 다음에 온다고 했습니다.

따라서 BindingResult 는 이미 본인이 검증해야 할 객체인 target 을 알고 있습니다.

BindingResult 가 제공하는 rejectValue() , reject() 를 사용하면 FieldError , ObjectError 를 직접 생성하지 않고, 깔끔하게 검증 오류를 다룰 수 있습니다.

 

rejectValue()

특정 필드 오류를 검증할 때 사용합니다.

void rejectValue(@Nullable String field, String errorCode, @Nullable Object[] errorArgs, @Nullable String defaultMessage);
  • field : 오류 필드명
  • errorCode : 작성한 오류 메시지에 등록된 코드를 사용합니다. MessageResolver를 위한 오류 코드 (아래 설명)
  • errorArgs : 오류 메시지에서 {0}, {1} ... 등 파라미터를 치환하기 위한 값 ( new Object[] {...} 배열로 작성 )
  • defaultMessage : 오류 메시지를 찾을 수 없을 때 사용하는 기본 메시지

 

위 예제 코드를 보면 오류 코드를 range 로 간단하게 입력한 것을 볼 수 있습니다.

그래도 오류 메시지를 잘 찾아서 출력하는데 그 이유는 아래에 MessageCodesResolver 설명에 있습니다.

 

reject()

특정 필드 오류가 아닌 복합 오류를 검증할 때 사용합니다. 

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

각 파라미터는 rejectValue() 설명과 같습니다.

 

MessageCodesResolver

오류 메시지에 객체명과 필드명을 조합한 디테일한 코드가 있으면 이 메시지를 높은 우선순위로 사용합니다.

 

itemName 의 경우 위에서 만든 에러메시지 중 required 검증 오류 메시지가 발생하면 다음 코드 순서대로 메시지가 생성됩니다.

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

 

만약 아래 처럼 두 종류의 에러메시지를 만들어 놨을 때 검증 오류가 발생하면, 아래 에서 더 구체적인 Level1의 required.item.itemName를 사용합니다.

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

#Level2
required: 필수 값 입니다.

 


Controller 생성

package hello.itemservice.web.validation;

import hello.itemservice.domain.item.Item;
import hello.itemservice.domain.item.ItemRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.util.StringUtils;
import org.springframework.validation.BindingResult;
import org.springframework.validation.FieldError;
import org.springframework.validation.ObjectError;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.WebDataBinder;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;

import java.util.HashMap;
import java.util.List;
import java.util.Map;

@Controller
@RequestMapping("/validation/items")
@RequiredArgsConstructor
public class ValidationItemController {

    private final ItemRepository itemRepository;
    private final ItemValidator itemValidator;

    @InitBinder // 이 컨트롤러가 요청될 때 마다 모든 메서드에 검증이 적용
    public void init(WebDataBinder dataBinder) {
        dataBinder.addValidators(itemValidator);
    }

    @GetMapping("/add")
    public String addForm(Model model) {
        model.addAttribute("item", new Item());
        return "validation/addForm";
    }

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

        // 검증에 실패하면 다시 입력 폼으로
        if (bindingResult.hasErrors()) {
            // bindingResult는 model에 안담아도 스프링이 자동으로 View에 넘긴다.
            return "validation/addForm";
        }

        Item savedItem = itemRepository.save(item);
        redirectAttributes.addAttribute("itemId", savedItem.getId());
        redirectAttributes.addAttribute("status", true);
        
        // PRG 방식을 위해 사용했지만 분량이 많아서 다른 코드는 생략
        return "redirect:/validation/items/{itemId}"; 
    }
}

 

BindingResult

스프링이 제공하는 검증 오류를 보관하는 객체로, 검증 오류가 발생하면 여기에 보관합니다.

BindingResult 가 있으면 @ModelAttribute 에 데이터 바인딩 시 오류가 발생해도 오류 정보( FieldError )를 BindingResult 에 담아서 컨트롤러를 정상 호출합니다. (입력값 유지 됨)

BindingResult는 파라미터 순서가 중요한데, BindingResult 는 검증할 대상 바로 다음에 와야합니다.

(Ex: 위 예제에서는 검증할 대상인 Item 객체 바로 다음에 BindingResult가 왔다.)

BindingResult 는 Model에 자동으로 포함되기 때문에 model에 담아 View에 보내지 않아도 스프링이 자동으로 View에 넘겨줍니다.

        if (bindingResult.hasErrors()) {
            return "validation/addForm";
        }

bindingResult.hasErrors() : 이름 그대로 bindingResult에 검증 오류가 있다면 실행합니다.

return "validation/addForm"; : 검증에 실패하면 다시 입력폼 View 템플릿 addForm을 보여줍니다.

 

WebDataBinder

해당 컨트롤러가 요청될 때 항상 먼저 @InitBinder가 호출되고  WebDataBinder가 내부적으로 생성 후, 인자로 넘긴 검증기를 자동으로 적용해줍니다.

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

모든 컨트롤러에 다 적용되도록 글로벌 적용도 할 수 있습니다. (잘 사용하지 않으니 생략)

 

@Validated

검증기를 실행하라는 애노테이션

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

그런데 여러 검증기를 등록한다면 그 중에 어떤 검증기가 실행되어야 할지 구분이 필요한데, 이때 Validator 인터페이스의 supports() 가 사용됩니다. (Validator는 아래 검증 로직 클래스에서 설명)

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

 


View 템플릿 (타임리프 사용)

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

  • #fields : #fields 로 BindingResult 가 제공하는 검증 오류에 접근
  • th:errors : 해당 필드에 오류가 있는 경우에 태그를 출력 ( th:if 의 편의 버전 )
  • th:errorclass : th:field 에서 지정한 필드에 오류가 있으면 class 정보를 추가
<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="utf-8">
    <link th:href="@{/css/bootstrap.min.css}"
          href="../css/bootstrap.min.css" rel="stylesheet">
    <style>
        .container {
            max-width: 560px;
        }
        .field-error {
            border-color: #dc3545;
            color: #dc3545;
        }
    </style>
</head>
<body>

<div class="container">

    <div class="py-5 text-center">
        <h2 th:text="#{page.addItem}">상품 등록</h2>
    </div>

    <form action="item.html" th:action th:object="${item}" method="post">
		
        <!-- 글로벌 오류는 여러 오류가 있을 수 있기 때문에 each 반복문 사용-->
        <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" th:field="*{itemName}"
                   th:errorclass="field-error" class="form-control" placeholder="이름을 입력하세요">

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

        <hr class="my-4">

        <div class="row">
            <div class="col">
                <button class="w-100 btn btn-primary btn-lg" type="submit" th:text="#{button.save}">상품 등록</button>
            </div>
            <div class="col">
                <button class="w-100 btn btn-secondary btn-lg"
                        onclick="location.href='items.html'"
                        th:onclick="|location.href='@{/validation/items}'|"
                        type="button" th:text="#{button.cancel}">취소</button>
            </div>
        </div>

    </form>

</div> <!-- /container -->
</body>
</html>

 

 

 


스프링의 타입 오류 메시지 해결

앞의 내용들을 다 적용 한 후에는 검증 로직 대로 잘못 입력하면 어느 필드가 오류가 발생했는지 빨간색 칸과 설명으로 알려주게 되었습니다.

 

하지만 아직 한 가지 문제가 있습니다.

바로 잘못된 타입을 입력하면 스프링에서 자체적으로 만든 타입오류 메시지를 출력하게 되어버립니다.

로그를 확인해보면 BindingResult에 스프링에서 만든  다음과 같은 4가지 메시지 코드가 담겨있는 것을 볼 수 있습니다.

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

위에서 만든 errors.properties에 위 메시지 코드의 내용이 없기 때문스프링이 생성한 기본 메시지가 출력되는 것입니다.

 

errors.properties에 아래처럼 내용을 추가하면 스프링 기본 메시지 대신 직접 작성한 메시지가 출력되게 됩니다.

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