[Spring] Bean Validation - 유효성 검증

이전 포스팅에서 유효성 검증하는 법에 대해 알아봤었습니다.

(이전 포스팅에서 다룬 내용들은 이번 포스팅에서는 생략)

 

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

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

hstory0208.tistory.com

하지만 이 전처럼 검증 기능을 매번 코드로 작성하는 것은 상당히 번거로운 작업입니다.

 

스프링은 이런 검증 로직을 모든 프로젝트에 적용할 수 있게 공통화하고, 표준화 Bean Validation을 제공합니다.

Bean Validation을 잘 활용하면, 애노테이션 하나로 검증 로직을 매우 편리하게 적용할 수 있습니다.


Bean Validation이란?

Bean Validation은 특정한 구현체가 아니라 Bean Validation 2.0(JSR-380)이라는 기술 표준으로,

쉽게 이야기해서 검증 어노테이션과 여러 인터페이스의 모음입니다.

 

Bean Validation을 사용하기 위해서는 다음과 같이 Build.grdle에 의존성을 추가해줘야합니다.

Bean Validation 의존성 추가
implementation 'org.springframework.boot:spring-boot-starter-validation'

spring-boot-starter-validation 라이브러리를 넣으면 스프링 부트가 자동으로 Bean Validator를 인지하고 스프링에 통합합니다.

 

어노테이션을 다는 것만으로 어떻게 동작하는 건가?

LocalValidatorFactoryBean(어노테이션을 보고 검증해주는 검증기)을 글로벌 Validator로 등록하고,

이 Validator는 @NotNull 같은 애노테이션을 보고 검증을 수행합니다.

이렇게 글로벌 Validator가 적용되어 있기 때문에, 검증을 적용할 곳에 @Valid ,@Validated 만 적용하면 된다.

검증 오류가 발생하면, FieldError , ObjectError 를 생성해서 BindingResult 에 담아줍니다.

 

  • @Validated와 @Valid

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

둘중 아무거나 사용해도 동일하게 작동하지만, @Validated 는 내부에 groups 라는 기능을 포함하고 있습니다.

 

  • 검증 순서

1. @ModelAttribute 각각의 필드에 타입 변환 시도

    a. 타입 변환에 성공하면 다음으로

    b. 타입 변환에 실패하면 typeMismatch 로 FieldError 추가

2. Validator 적용

 

Bean Validation에서 타입 오류 처리

BeanValidator는 바인딩에 실패한 필드는 BeanValidation을 적용하지 않습니다.

예를 들면 String인 itemName 필드에 문자 A를 입력했다고 가정했을 시 문자 A는 문자열로 타입 변환이 가능하지만

Integer인 pirce 필드에 문자 A를 입력하면 타입 변환이 불가능하게 됩니다.

이 경우 스프링이 typeMismatch FieldError를 추가하고, BeanValidation가 적용되지 않습니다.

그래서 타입 오류가 발생했을 때 원하는 메시지를 출력하도록 다음과 같이 errors.properties 파일에 에러 메시지를 작성하면 작성한 메시지가 타입 에러 발생시 출력됩니다.

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

Bean Validation 검증 어노테이션 알아보기

public class Item {
	private Long id;

	@NotBlank
	private String itemName;

	@NotNull
	@Range(min = 1000, max = 1000000)
	private Integer price;

	@NotNull
	@Max(9999)
	private Integer quantity;
}

위에서 사용한 어노테이션들을 간단하게 알아보면 다음과 같습니다. 

어노테이션 설명
@NotBlank  입력값에 공백이 있을 시 검증기를 실행
@NotNull 입력값이 비었을 시 검증기를 실행
@Range 입력가능한 숫자 범위를 지정
@Max 입력가능한 최대치를 지정

 

여기서 입력한 값이 어노테이션을 보고 검증에 걸리게 되면

아래 같은 에러 메시지를 작성해놓지 않았음에도 기본으로 아래 메시지들이 출력되게 됩니다.

그 이유는 Bean Validation을 적용하고 bindingResult 에 등록된 검증 오류 코드를 보면 오류 코드가 애노테이션 이름으로 등록되는데

NotBlank 라는 오류 코드를 기반으로 MessageCodesResolver 를 통해 다양한 메시지 코드가 순서대로 생성됩니다.

NotBlank.item.itemName
NotBlank.itemName
NotBlank.java.lang.String
NotBlank

그러면 우리는 typeMismatch 처럼 메시지 우선순위를 생각해 errors.properties에서 원하는 메시지 값으로 작성하면 됩니다.

NotBlank.item.itemName="이 상품은 공백을 입력할 수 없습니다."
NotBlank="공백을 입력할 수 없습니다."

 

더 많은 어노테이션 알아보기

https://docs.jboss.org/hibernate/validator/6.2/reference/en-US/html_single/#validator-defineconstraints-spec

위 링크로 들어가면 다양한 검증 어노테이션들을 볼 수 있습니다.


프로젝트에 적용해보기

실무에서는 주로 등록과 수정의 객체를 서로 다르게 사용한다고 합니다.
간단한 경우에는 @Validated의 groups를 사용하는게 편하지만, 실무에서는 groups 를 잘 사용하지 않는다고 하는데

그 이유는, 등록시 폼에서 전달하는 데이터가 Item 도메인 객체와 딱 맞지 않기 때문입니다.

간단하게 토이프로젝트를 만드는 경우와 다르게 실무에서는 회원 등록시 회원과 관련된 데이터만 전달받는 것이 아니라,

약관 정보도 추가로 받는 등 Item 과 관계없는 수 많은 부가 데이터가 넘어와서 보통 Item 을 직접 전달받는 것이 아니라, 복잡한 폼의 데이터를 컨트롤러까지 전달할 별도의 객체를 만들어서 전달합니다.

 

예를 들면 ItemSaveForm 이라는 폼을 전달받는 전용 객체를 만들어서 @ModelAttribute 로 사용하고

이것을 통해 컨트롤러에서 폼 데이터를 전달 받고, 이후 컨트롤러에서 필요한 데이터를 사용해서 Item 을 생성합니다.

수정의 경우 등록과 수정은 완전히 다른 데이터가 넘오는데, 예를 들면 등록시에는 로그인id, 주민번호 등등을 받을 수 있지만, 수정시에는 이런 부분이 빠집니다.

그래서 ItemUpdateForm 이라는 별도의 폼을 수정하는 전용 객체를 만들어 데이터를 전달받는 것이 좋습니다.

 

 

Item 객체
@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;
    }
}

 

폼 등록을 위한 ItemSaveForm 객체

상품을 저장할 땐 Id가 필요없으므로 Id필드를 제외했습니다.

@Data
public class ItemSaveForm {
    @NotBlank
    private String itemName;

    @NotNull
    @Range(min = 1000, max = 1000000)
    private Integer price;

    @Max(value = 9999)
    @NotNull
    private Integer quantity;
}

 

폼 수정을 위한 ItemUpdateForm 객체

상품을 업데이트할 때는 Id를 참조하므로 포함시키고

quantity의 경우 Bean Validation을 제거해 수정 시에는 수량을 자유롭게 변경할 수 있도록 하였습니다.

@Data
public class ItemUpdateForm {
    @NotNull
    private Long id;

    @NotBlank
    private String itemName;

    @NotNull
    @Range(min = 1000, max = 1000000)
    private Integer price;


    // 수정에서 수량은 자유롭게 변경할 수 있도록 한다.
    private Integer quantity;
}

 

Controller의 Post요청을 하는 상품 저장 메서드와 상품 수정 메서드

아래 상품 저장을 Post요청하는 메서드와 상품 수정을 Post 요청하는 메서드를 보면

파라미터로 각각의 용도에 맞게 작성ItemSaveForm과 ItemUpdateForm을 받고 있는 것을 볼 수 있습니다.

 

여기서 @ModelAttribute("item")은 ("item")으로 지정하지 않았을 시에 넘기는 객체 클래스의 맨 앞 이름만 소문자로 바꾼 "itemSaveForm"으로 model에 데이터를 넘깁니다.

여기서는 model에 넘길때 데이터 이름을 "itemSaveForm"이 아닌 "item"으로 넘기기위해 지정해주었습니다.

    @PostMapping("/add")
    public String addItem(@Validated @ModelAttribute("item") ItemSaveForm form, BindingResult bindingResult, RedirectAttributes redirectAttributes) {

        // 글로벌 예외 처리 (글로벌 검증은 여기서 처리)
        if (form.getPrice() != null && form.getQuantity() != null) {
            int resultPrice = form.getPrice() * form.getQuantity();
            if (resultPrice < 10000) {
                bindingResult.reject("totalPriceMin", new Object[]{10000, resultPrice}, null);
            }
        }

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

        // 성공 로직 (성공 시에는 ItemSaveForm 객체가 아닌 Item 객체를 담는다.)
        Item item = new Item();
        item.setItemName(form.getItemName());
        item.setPrice(form.getPrice());
        item.setQuantity(form.getQuantity());

        Item savedItem = itemRepository.save(item);
        redirectAttributes.addAttribute("itemId", savedItem.getId());
        return "redirect:/validation/items/{itemId}";
    }

    @PostMapping("/{itemId}/edit")
    public String edit(@PathVariable Long itemId, @Validated @ModelAttribute("item") ItemUpdateForm itemUpdateForm, BindingResult bindingResult) {

		// 글로벌 예외 처리 (글로벌 검증은 여기서 처리)
        if (itemUpdateForm.getPrice() != null && itemUpdateForm.getQuantity() != null) {
            int resultPrice = itemUpdateForm.getPrice() * itemUpdateForm.getQuantity();
            if (resultPrice < 10000) {
                bindingResult.reject("totalPriceMin", new Object[]{10000, resultPrice}, null);
            }
        }

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

        Item itemParam = new Item();
        itemParam.setItemName(itemUpdateForm.getItemName());
        itemParam.setPrice(itemUpdateForm.getPrice());
        itemParam.setQuantity(itemUpdateForm.getQuantity());

        itemRepository.update(itemId, itemParam);
        return "redirect:/validation/items/{itemId}";
    }

 

View템플릿 일부 코드 (Thymeleaf 사용)
  • addForm.html
    <!-- addForm.html -->
<form action="item.html" th:action th:object="${item}" method="post">

    <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/v4/items}'|"
                    type="button" th:text="#{button.cancel}">취소</button>
        </div>
    </div>

</form>

 

  • editForm.html
	<!-- editForm.html -->
    <form action="item.html" th:action th:object="${item}" method="post">
    <div th:if="${#fields.hasGlobalErrors()}">
        <p class="field-error" th:each="err : ${#fields.globalErrors()}" th:text="${err}">전체 오류 메시지</p>
    </div>

    <div>
        <label for="id" th:text="#{label.item.id}">상품 ID</label>
        <input type="text" id="id" th:field="*{id}" class="form-control" readonly>
    </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">
        <div class="field-error" th:errors="*{itemName}"></div>
    </div>
    <div>
        <label for="price" th:text="#{label.item.price}">가격</label>
        <input type="text" id="price" th:field="*{price}" th:errorclass="field-error" class="form-control">
        <div class="field-error" th:errors="*{price}"></div>
    </div>
    <div>
        <label for="quantity" th:text="#{label.item.quantity}">수량</label>
        <input type="text" id="quantity" th:field="*{quantity}" th:errorclass="field-error" class="form-control">
        <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='item.html'"
                    th:onclick="|location.href='@{/validation/v4/items/{itemId}(itemId=${item.id})}'|"
                    type="button" th:text="#{button.cancel}">취소</button>
        </div>
    </div>

</form>

 


여기 까지 Bean Validation을 사용하고 등록, 수정에 Form 데이터를 전송하는 객체를 분리해봤습니다.

포스팅에 올린 코드는 일부의 코드지만 실제 코드들로 실행하면

상품 등록시 ItemSaveForm 객체에서 지정한 Bean Validation이 동작하고

상품 수정시에는 ItemUpdateForm 객체에서  Bean Validation이 동작합니다.