본문 바로가기
공부/Spring

[Spring MVC](10) Bean Validator

by 다음에바꿔야지 2024. 1. 28.

전과 같이 검증 기능을 매번 코드로 작성하는 것은 상당히 번거롭다.

특히 특정 필드에 대한 검증 로직은 대부분 빈 값, 크기와 같이 매우 일반적인 로직이다.

검증 로직을 모든 프로젝트에 적용할 수 있게 공통화하고, 표준화한 것이 바로 Bean Validation이다.

 

먼저 스프링과 통합하지 않고, 순수한 Bean Validation 사용법부터 테스트 코드로 알아보자.

 

build.gradle에 의존 관계 추가 후,

테스트 코드를 작성해본다.

 

Item 클래스 작성

package hello.itemservice.domain.item;

import lombok.Data;
import org.hibernate.validator.constraints.Range;

import javax.validation.constraints.Max;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;

@Data
public class Item {

    private Long id;

    @NotBlank
    private String itemName;

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

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

    public Item() {
    }

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

검증 애노테이션

*@NotBlank : 빈값 + 공백만 있는 경우를 허용하지 않는다.
*@NotNull : null 을 허용하지 않는다.
*@Range(min = 1000, max = 1000000) : 범위 안의 값이어야 한다.

*@Max(9999) : 최대 9999까지만 허용한다.

 

BeanValidationTest - Bean Validation 테스트 코드 작성

package hello.itemservice.validation;

import hello.itemservice.domain.item.Item;
import org.junit.jupiter.api.Test;

import javax.validation.ConstraintViolation;
import javax.validation.Validation;
import javax.validation.Validator;
import javax.validation.ValidatorFactory;
import java.util.Set;

public class BeanValidationTest {
    @Test
    void beanValidation() {
    	// 검증기 생성
        ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
        Validator validator = factory.getValidator();

        Item item = new Item();
        item.setItemName(" ");
        item.setPrice(0);
        item.setQuantity(10000);

	// 검증 실행
        Set<ConstraintViolation<Item>> violations = validator.validate(item);
        for (ConstraintViolation<Item> violation : violations) {
             System.out.println("violation=" + violation);
             System.out.println("violation.message=" + violation.getMessage());
         }
    }
}

1. 검증기 생성

2. 검증 실행

2-1. 검증 대상(item)을 직접 검증기에 넣고 결과를 받는다.

2-2. Set에는 ConstraintViolation이라는 검증 오류가 담긴다.

 

이렇게 빈 검증기를 직접 사용하는 방법을 알아보았다. 스프링은 빈 검증기를 스프링에 완전히 통합해두었다.

이제 ValidationItemControllerV3를 만들어 Bean Validation을 스프링에 적용해보자.

 

스프링 MVC가 Bean Validator를 사용하는 방법?

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

 

스프링 부트는 자동으로 글로벌 Validator로 등록한다.

우리는 @Valid, @Validated만 적용하면 된다. 검증 오류가 발생하면 BindingResult에 담아준다.

 

검증 순서

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

  1-1. 성공하면 다음으로

  1-2. 실패하면 typeMismatch로 FieldError 추가

2. Validator 적용

=> 타입이 맞는지 확인 후, 검증기에 넣는다.

 

에러 코드

오류 메시지를 좀 더 자세히 변경하고 싶으면 어떻게 해야 할까?

bindingResult에 오류 코드가 애노테이션 이름으로 등록된다. 이 오류 코드를 기반으로 MessageCodesResolver를 통해 다양한 메시지 코드가 순서대로 생성된다.

 

에러 코드별로 errors.properties에 추가하면 된다.

#Bean Validation 추가 
NotBlank={0} 공백X 
Range={0}, {2} ~ {1} 허용 
Max={0}, 최대 {1}

{0}은 필드명, {1}, {2}...은 각 애노테이션마다 다르다.

 

필드 오류가 아닌 오브젝트 오류인 경우?

@Data
@ScriptAssert(lang = "javascript", script = "_this.price * _this.quantity >= 10000")
public class Item {
	//...
}

@ScriptAssert()를 사용해도 되지만, 실무에서는 복잡하고 제약이 많기 때문에 거의 쓰지 않는다.

그래서 다음과 같이 오브젝트 오류 관련 부분만 직접 자바 코드로 작성하는 것을 권장한다.

 

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

        //특정 필드 예외가 아닌 전체 예외
        if (item.getPrice() != null && item.getQuantity() != null) {
            int resultPrice = item.getPrice() * item.getQuantity();
            if (resultPrice < 10000) {
                bindingResult.reject("totalPriceMin", new Object[]{10000, resultPrice}, null);
            }
        }

        // 검증에 실패하면 다시 입력 폼으로
        if (bindingResult.hasErrors()) {
            log.info("errors = {}", bindingResult);
            return "validation/v3/addForm";
        }

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

@ScriptAssert 삭제 후 new Object[]를 추가해주었다.

 

이제 검증 로직을 수정 폼에도 적용한다.

 

수정시 요구사항으로는,

1. 수량을 무제한으로 변경 가능

2. id값 필수

가 있다.

수정 요구사항을 적용하기 위해,

1. quantity: @Max(9999) 제거,

2. id: @NotNull 추가

로 수정했다.

 

문제점

현재 구조에서는 수정시 item의 id값은 항상 들어있도록 로직이 구성되어있다. 애플리케이션을 실행해보면 수정은 잘 동작하나, 등록에서는 문제가 발생한다. 등록시에는 id에 값도 없고, quantity 수량 제한 값인 9999도 적용되지 않는 문제가 발생한다.

결과적으로 item은 등록과 수정에서 검증 조건의 충돌이 발생해 같은 Bean Validation을 적용할 수 없다.

 

동일한 모델 객체를 등록, 수정할 때 다르게 검증하는 방법

1. BeanValidation의 groups 기능 사용

2. Item을 사용하지 않고, ItemSaveForm, ItemUpdateForm 같은 폼 전송을 위한 별도의 모델 객체 생성 후 사용

 

먼저, '1. BeanValidation의 groups 기능 사용' 에 대해 알아보자.

등록에 검증할지, 수정에 검증할지 group을 적용할 수 있는 기능이다.

 

등록용 groups 생성

package hello.itemservice.domain.item;

public interface SaveCheck {
}

 

수정용 groups 생성

package hello.itemservice.domain.item;

public interface UpdateCheck {
}

 

Item - groups 적용

package hello.itemservice.domain.item;

import lombok.Data;
import org.hibernate.validator.constraints.Range;

import javax.validation.constraints.Max;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;

@Data
public class Item {

    @NotNull(groups = UpdateCheck.class)
    private Long id;

    @NotBlank(groups = {SaveCheck.class, UpdateCheck.class})
    private String itemName;

    @NotNull(groups = {SaveCheck.class, UpdateCheck.class})
    @Range(min = 1000, max = 1000000, groups = {SaveCheck.class, UpdateCheck.class})
    private Integer price;

    @NotNull(groups = {SaveCheck.class, UpdateCheck.class})
    @Max(value = 9999, groups = SaveCheck.class)
    private Integer quantity;

    public Item() {
    }

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

 

ValidationItemControllerV3 - 저장 로직에 SaveCheck Groups 적용

@PostMapping("/add")
    public String addItemV2(@Validated(SaveCheck.class) @ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes) {

        //특정 필드 예외가 아닌 전체 예외
        if (item.getPrice() != null && item.getQuantity() != null) {
            int resultPrice = item.getPrice() * item.getQuantity();
            if (resultPrice < 10000) {
                bindingResult.reject("totalPriceMin", new Object[]{10000, resultPrice}, null);
            }
        }

        // 검증에 실패하면 다시 입력 폼으로
        if (bindingResult.hasErrors()) {
            log.info("errors = {}", bindingResult);
            return "validation/v3/addForm";
        }

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

@Validated(SaveCheck.class) 적용

 

수정 로직도 마찬가지로 적용해준다.

 

다 적용해보니, 복잡도가 올라갔다.

사실 groups 기능은 실제로 잘 사용하지는 않는데, 주로 '2. 폼 전송을 위한 객체를 분리해서 사용'하기 때문이다.

 

 

<Form 전송 객체 분리>

Item 원복

이제 Item의 검증은 사용하지 않는다.

@Data
 public class Item {
     private Long id;
     private String itemName;
     private Integer price;
     private Integer quantity;
}

 

ItemSaveForm - ITEM 저장용 폼

package hello.itemservice.web.validation.form;

import lombok.Data;
import org.hibernate.validator.constraints.Range;

import javax.validation.constraints.Max;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;

@Data
public class ItemSaveForm {
    @NotBlank
    private String itemName;
    @NotNull
    @Range(min = 1000, max = 1000000)
    private Integer price;
    @NotNull
    @Max(value = 9999)
    private Integer quantity;
}

 

ItemUpdateForm - ITEM 수정용 폼

package hello.itemservice.web.validation.form;

import lombok.Data;
import org.hibernate.validator.constraints.Range;

import javax.validation.constraints.Max;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;

@Data
public class ItemUpdateForm {
    @NotNull
    private Long id;
    @NotBlank
    private String itemName;
    @NotNull
    @Range(min = 1000, max = 1000000)
    private Integer price;
    private Integer quantity;
}

 

이제 등록, 수정 폼 객체를 사용하도록 컨트롤러를 수정해보자.

 

ValidationItemControllerV4

package hello.itemservice.web.validation;

import hello.itemservice.domain.item.Item;
import hello.itemservice.domain.item.ItemRepository;
import hello.itemservice.domain.item.SaveCheck;
import hello.itemservice.domain.item.UpdateCheck;
import hello.itemservice.web.validation.form.ItemSaveForm;
import hello.itemservice.web.validation.form.ItemUpdateForm;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;

import java.util.List;

@Slf4j
@Controller
@RequestMapping("/validation/v4/items")
@RequiredArgsConstructor
public class ValidationItemControllerV4 {

    private final ItemRepository itemRepository;

    //...

    @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()) {
            log.info("errors = {}", bindingResult);
            return "validation/v4/addForm";
        }

        // 성공 로직
        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());
        redirectAttributes.addAttribute("status", true);
        return "redirect:/validation/v4/items/{itemId}";
    }

    @PostMapping("/{itemId}/edit")
    public String edit(@PathVariable Long itemId, @Validated @ModelAttribute("item") ItemUpdateForm form, BindingResult bindingResult) {
        //특정 필드 예외가 아닌 전체 예외
        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()) {
            log.info("errors = {}", bindingResult);
            return "validation/v4/editForm";
        }

        //성공 로직
        Item itemParam = new Item();
        itemParam.setItemName(form.getItemName());
        itemParam.setPrice(form.getPrice());
        itemParam.setQuantity(form.getQuantity());
        itemRepository.update(itemId, itemParam);
        return "redirect:/validation/v4/items/{itemId}";
    }
}

 

1. 폼 객체 바인딩

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

Item 대신 ItemSaveForm을 전달받는다. @Validated로 검증도 수행하고, BindingResult로 검증 결과도 받는다.

 

**주의**
`@ModelAttribute("item")` 에 `item` 이름을 넣어준 부분을 주의하자. 이것을 넣지 않으면 `ItemSaveForm` 의 경우, 규칙에 의해 `itemSaveForm` 이라는 이름으로 MVC Model에 담기게 된다. 이렇게 되면 뷰 템플릿에서 접근하는 `th:object` 이름도 함께 변경해주어야 한다.

 

2. 폼 객체를 Item으로 변환

//성공 로직
Item item = new Item(); 
item.setItemName(form.getItemName()); 
item.setPrice(form.getPrice()); 
item.setQuantity(form.getQuantity());

Item savedItem = itemRepository.save(item);

폼 객체를 이용해 Item 객체를 생성한다.

 

수정(edit())도 같은 방식으로 수정해주었다.

 

이렇게 Form 객체를 따로 만들어 검증하는 방법이 가장 편한 것 같다.