본문 바로가기
[ JAVA ]/JAVA Spring

[ Spring Boot ] Bean Validation

by 환이s 2023. 12. 29.


취업 후 회사 설루션 고도화 작업에 투입하면서 여러 가지 코드를 경험하고 기간 내에 끝내야 하는 상황이라서 포스팅을 신경 못쓰고 있었네요.. 오늘부터 포스팅을 다시 시작해보려고 합니다.

 

오늘은 Bean Validation에 대해 알아보겠습니다.

 

 

Bean Validation이란 ?

 

먼저 Bean Validation은 특정한 구현체가 아니라 Bean Validation 2.0(JSR-380)이라는 기술 표준입니다.

쉽게 이야기해서 검증 애노테이션과 여러 인터페이스 모음인데, 마치 JPA가 표준 기술이고 그 구현체로 하이버네이트가 있는 것과 같습니다.

 

Bean Validation을 구현한 기술 중에 일반적으로 사용하는 구현체는 하이버네이트 Validator입니다. 이름이 하이버네이트가 붙어서 그렇게 ORM과는 관련이 없습니다.

 

예시로 알아보자.

 

검증 기능을 매번 코드를 작성하는 것은 상당히 번거롭습니다.

특히 특정 필드에 대한 검증 로직은 대부분 빈 값인지 아닌지, 특정 크기를 넘는지 아닌지와 같이 매우 일반적인 로직입니다. 다음 코드를 확인해 봅니다.

 

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;
 //...
}

 

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

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

 


Beab Validation - 시작

 

Bean Validation 기능을 어떻게 사용하는지 코드로 알아봅시다.

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

 

Bean Validation을 사용하려면 다음 의존관계를 추가해야 합니다.

 

[build.gradle]

implementation 'org.springframework.boot:spring-boot-starter-validation'

 

 

의존 관계를 추가했다면 테스트 코드를 작성해 봅니다.

 

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(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까지만 허용한다.

 

TIP ) 

javax.validation.constraints.NotNull
org.hibernate.validator.constraints.Range

javax.validation으로 시작하면 특정 구현에 관계없이 제공되는 표준 인터페이스이고,
org.hibernate.validator로 시작하면 하이버네이트 validator 구현체를 사용할 때만 제공되는 검증 기능이다.
실무에서 대부분 하이버네이트 validator를 사용하므로 자유롭게 사용해도 된다.

 

 

다음으로는 검증기를 생성합니다.

해당 검증기는 스프링을 통합하면 직접 이런 코드를 작성하지 않으므로, 사용법만 참고하고 넘어갑니다.

ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
Validator validator = factory.getValidator();

 

 

[BeanValidationTest - Bean 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());
     
     }
      
   }
}

 

위 테스트 코드를 보면 검증기를 생성하고 해당 검증 대상(item)을 직접 검증기에 넣고 그 결과를 받습니다.

Set 에는 ConstrainViolation이라는 검증 오류가 담깁니다. 따라서 결과가 비어있으면 검증 오류가 없는 것입니다.

 

Set<ConstraintViolation<Item>> violations = validator.validate(item);

 

실행 결과를 확인해 봅니다.(일부 생략)

 

violation={interpolatedMessage='공백일 수 없습니다', propertyPath=itemName, 
rootBeanClass=class hello.itemservice.domain.item.Item, 
messageTemplate='{javax.validation.constraints.NotBlank.message}'}
violation.message=공백일 수 없습니다

violation={interpolatedMessage='9999 이하여야 합니다', propertyPath=quantity, 
rootBeanClass=class hello.itemservice.domain.item.Item, 
messageTemplate='{javax.validation.constraints.Max.message}'}
violation.message=9999 이하여야 합니다

violation={interpolatedMessage='1000에서 1000000 사이여야 합니다', 
propertyPath=price, rootBeanClass=class hello.itemservice.domain.item.Item, 
messageTemplate='{org.hibernate.validator.constraints.Range.message}'}
violation.message=1000에서 1000000 사이여야 합니다

 

ConstraintViolation 출력 결과를 보면, 검증 오류가 발생한 객체, 필드, 메시지 정보 등 다양한 정보를 확인할 수 있습니다.

 

지금까지 Bean Validation을 직접 사용하는 방법을 알아보았습니다.

그렇다면 위처럼 스프링 MVC에서는 빈 검증기를 어떻게 적용하면 좋을까요?

 

스프링은 개발자를 위해 빈 검증기를 스프링에 완전히 통합해 두었습니다.

 

지금부터 간단한 프로젝트를 통해서 Bean Validation을 알아볼 텐데,
해당 소스 파일은 깃허브에서 확인 바랍니다 :)
 

GitHub - KiHwanY/Spring_MVC_Middle_Validation: 김영한님의 스프링 MVC 2편 강의 - Spring_MVC_Middle_Validation

김영한님의 스프링 MVC 2편 강의 - Spring_MVC_Middle_Validation - GitHub - KiHwanY/Spring_MVC_Middle_Validation: 김영한님의 스프링 MVC 2편 강의 - Spring_MVC_Middle_Validation

github.com

 


Bean Validation - Spring 적용

 

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 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 javax.validation.Valid;
import java.util.List;

@Slf4j
@Controller
@RequestMapping("/validation/v3/items")
@RequiredArgsConstructor
public class ValidationItemControllerV3 {

    private final ItemRepository itemRepository;

    @GetMapping
    public String items(Model model) {
        List<Item> items = itemRepository.findAll();
        model.addAttribute("items", items);
        return "validation/v3/items";
    }

    @GetMapping("/{itemId}")
    public String item(@PathVariable long itemId, Model model) {
        Item item = itemRepository.findById(itemId);
        model.addAttribute("item", item);
        return "validation/v3/item";
    }

    @GetMapping("/add")
    public String addForm(Model model) {
        model.addAttribute("item", new Item());
        return "validation/v3/addForm";
    }
//    @ModelAttribute -> 각각의 필드 타입 변환시도 -> 변환에 성공한 필드만 BeanValidation 적용
//    @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}";
    }

    @PostMapping("/add")
    public String addItem2(@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}";
    }

    @GetMapping("/{itemId}/edit")
    public String editForm(@PathVariable Long itemId, Model model) {
        Item item = itemRepository.findById(itemId);
        model.addAttribute("item", item);
        return "validation/v3/editForm";
    }

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

        //특정 필드가 아닌 복합 룰 검증
        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/editForm";
        }

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

    @PostMapping("/{itemId}/edit")
    public String editV2(@PathVariable Long itemId, @Validated(UpdateCheck.class) @ModelAttribute Item item, BindingResult bindingResult) {

        //특정 필드가 아닌 복합 룰 검증
        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/editForm";
        }

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

}

 

위 Controller는 간단한 Product CRUD 기능을 갖고 있는 파일입니다.

 

실행해 보면 애노테이션 기반의 Bean Validation이 정상 동작하기 때문에 실행은 생략하겠습니다.

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

 

[Spring Boot는 자동으로 글로벌 Validator로 등록한다.]

LocalValidatorFactoryBean을 글로벌 Validator로 등록한다.
이 Validator는 @NotNull 같은 애노테이션을 보고 검증을 수행한다.
이렇게 글로벌 Validator가 적용되어 있기 때문에, @Valid, @Validated만 적용하면 된다.

 

 

위 코드에서 검증 순서는 먼저 @ModelAttribut에서 각각의 필드에 타입 변환을 시도합니다.

성공하면 다음으로 넘어가고 만약 실패하면 typeMismatch로 FieldError가 추가됩니다. 그리고 바인딩에 성공한 필드에 Validator가 적용됩니다.

 

Bean Validation은 바인딩에 실패한 필드는 적용되지 않습니다.

생각해 보면 타입 변환에 성공해서 바인딩에 성공한 필드여야 Bean Validation 적용이 의미 있습니다.

(일단 모델 객체에 바인딩받는 값이 정상으로 들어와야 검증도 의미가 있습니다.)

EX)

itemName에 문자 "A" 입력 -> 타입 변환 성공 -> itemName 필드에 BeanValidation 적용

price에 문자 "A" 입력 ->  "A"를 숫자 타입 변환 시도 실패 -> typeMismatch FieldError 추가 ->
price 필드는 BeanValidation 적용 X

 

 

그렇다면 Bean Validation이 기본으로 제공하는 오류 메시지를 좀 더 자세히 변경하고 싶으면 어떻게 하면 될까요??

 

Bean Validation을 적용하고 bindingResult에 등록된 검증 오류코드를 확인해 보면 애노테이션 이름으로 등록됩니다.

 

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

@Range
Range.item.price
Range.price
Range.java.lang.Integer
Range

 

마치 typeMismatch와 유사한데, NotBlank라는 오류 코드를 기반으로 MessageCodesResolver를 통해 다양한 메시지 코드가 순서대로 생성됩니다.

 

글쓴이는 오류 메시지를 변경하기 위해  errors.properties에  메시지를 등록했습니다.

#Bean Validation 추가

#NotBlank.item.itemName=상품 이름을 적어주세요.

NotBlank={0} 공백X
Range={0}, {2} ~ {1} 허용
Max={0}, 최대 {1}


# 참고 : errors_en.properties 파일을 생성하면 오류 메시지도 국제화 처리를 할 수 있다.

 

메시지 등록 내용에 {0}은 필드명이고, {1}, {2}... 은 각 애노테이션마다 다릅니다.

(깃 소스 참고하셔서 실행해 보시면 정상 적용되는 것을 확인할 수 있습니다.)

 

  • BeanValidation 메시지 찾는 순서
    • 생성된 메시지 코드 순서대로 messageSource에서 메시지 찾기
    • 애노테이션의 message 속성 사용 -> @NotBlank(message = "공백! {0}")
    • 라이브러리가 제공하는 기본 값 사용 -> 공백일 수 없습니다.

 

[애노테이션의 message 사용 예제]

@NotBlank(message = "공백은 입력할 수 없습니다.")
private String itemName;

 

지금까지 BenaValidation에서 특정 필드 관련 오류 처리에 대해 알아봤습니다.

그렇다면 오브젝트 관련 오류는 어떻게 처리할 수 있을까요??

 

방법으로는 @ScriptAssert()를 사용하거나 , 수동 자바 코드 추가 하는 방법이 있습니다.

먼저 @ScriptAssert() 사용법은 다음과 같습니다.

 

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

 

하지만 @ScriptAssert()는 실제 사용해 보면 제약이 많고 복잡합니다.

그리고 실무에서는 검증 기능이 해당 객체의 범위를 넘어서는 경우들도 종종 등장하는데, 그런 경우에는 대응하기 어렵습니다.

 

따라서 오브젝트 오류(글로벌 오류)의 경우 @ScriptAssert을 억지로 사용하는 것보다는 다음과 같이 오브젝트 오류 관련 부분만 직접 자바 코드로 작성하는 것을 권장합니다.

 

@PostMapping("/add")
public String addItem(@Validated @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}";
}

 

순수 자바 코드로 처리할 땐 @ScriptAssert 제거하셔야 합니다.

 

Bean Validation - 수정에 적용 

 

다음 테스트를 위해 상품 수정 페이지에 Bean Validation을 적용하겠습니다.

 

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

        //특정 필드가 아닌 복합 룰 검증
        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/editForm";
        }

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

 

  • edit() : Item 모델 객체에 @Validated를 추가합니다.
  • 검증 오류가 발생하면 editForm으로 이동하는 코드 추가

 

[editForm.html]

<!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.updateItem}">상품 수정</h2>
    </div>

    <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/v3/items/{itemId}(itemId=${item.id})}'|"
                        type="button" th:text="#{button.cancel}">취소</button>
            </div>
        </div>

    </form>

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

 

  • .field-error : CSS 추가
  • 글로벌 오류 메시지
  • 상품명, 가격, 수량 필드에 검증 기능 추가

Bean Validation - 한계

 

실무에서는 수정 시 검증 요구사항이 데이터를 등록할 때와 수정할 때 요구사항이 다를 수 있습니다.

간단한 예시를 들자면, 

 

  • 등록 시 기존 요구사항
    • 타입 검증
      1. 가격, 수량에 문자가 들어가면 검증 오류 처리
    • 필드 검증
      1. 상품명: 필수, 공백 X
      2. 가격 : 1000원 이상, 1백만 원 이하
      3. 수량 :  최대 9999
    • 특정 필드의 범위를 넘어서는 검증
      1. 가격*수량의 합은 10,000원 이상

 

  • 수정 시 요구사항
    • 등록 시에는 quantity 수량을 최대 9999까지 등록할 수 있지만 수정 시에는 수량을 무제한으로 변경할 수 있다.
    • 등록 시에는 id에 값이 없어도 되지만, 수정 시에는 id값이 필수이다.

 

이렇게 요구사항을 요청받았다고 예시를 들고 적용해 보겠습니다.

 

@Data
public class Item {
     @NotNull //수정 요구사항 추가
     private Long id;
     
     @NotBlank
     private String itemName;
     
     @NotNull
     @Range(min = 1000, max = 1000000)
     private Integer price;
     
     @NotNull
     //@Max(9999) //수정 요구사항 추가
     private Integer quantity;
     //...
}

 

 

수정 요구사항을 적용하기 위해 다음을 적용했습니다.

 

  • id : @NotNull 추가
  • quantity : @MAX(9999) 제거

 

참고)

현재 구조에서는 수정 시 item의 id 값은 항상 들어있도록 로직이 구성되어 있습니다.
그래서 검증하지 않아도 된다고 생각할 수 있는데, HTTP 요청은 언제든지 악의적으로 변경해서 요청할 수 있으므로 서버에서 항상 검증해야 합니다.

예를 들어서 HTTP 요청을 변경해서 item의 id 값을 삭제하고 요청할 수도 있습니다.
따라서 최종 검증은 서버에서 진행하는 것이 안전합니다.

 

 

수정 파일을 실행시키면 정상적으로 동작하는 것을 확인할 수 있는데, 두 가지의 문제점이 있습니다.

 

  • 수정은 잘 동작하지만 등록에서 문제가 발생한다.
    • 등록 시에는 id에 값도 없고, quantity 수량 제한 최대 값인 9999도 적용되지 않는 문제가 발생한다.
  • 등록시 화면이 넘어가지 않으면서 다음과 같은 오류를 볼 수 있다.
    • 'id' : rejected value [null]; -> 왜냐하면 등록시에는 id에 값이 없다.
    • 따라서 @Notnull id를 적용한 것 때문에 검증에 실패하고 다시 폼 화면으로 넘어온다.
    • 결국 등록 자체도 불가능하고, 수량 제한도 걸지 못한다.

 

결과적으로 item은 등록과 수정에서 검증 조건의 충돌이 발생하고, 등로고가 수정은 같은 BeanValidation을 적용할 수 없습니다. 이 문제를 어떻게 해결할 수 있을까요?

 

해결하기 위한 방법 2가지를 알아보겠습니다.

 


Bean Validation - Groups

 

동일한 모델 객체를 등록할 때와 수정할 때 각각 다르게 검증하는 방법을 알아보겠습니다. 위에서 언급한 2가지 방법은 다음과 같습니다.

 

  • BeanValidation의 Groups 기능을 사용합니다.
  • Item을 직접 사용하지 않고, ItemSaveForm, ItemUpdateForm 같은 폼 전송을 위한 별도의 모델 객체를 만들어서 사용합니다.

이런 문제를 해결하기 위해 Bean Validation은 groups라는 기능을 제공합니다.

 

예를 들어서 등록시에 검증할 기능과 수정 시에 검증할 기능을 각각 그룹으로 나누어 적용할 수 있습니다.

 

코드로 확인해 보겠습니다.

 

groups 적용

 

 

  • 저장용 groups 생성
public interface SaveCheck {
}

 

 

  • 수정용 groups 생성
public interface UpdateCheck {
}

 

  • item - groups 적용
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;
     }
}

 

  • ValidationItemController - 저장 로직에 SaveCheck Groups 적용
 @PostMapping("/add")
    public String addItem2(@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}";
    }

 

기존 addItem() 메서드를 주석처리하고 addItemV2 메서드를 생성해서 SaveCheck.class를 적용했습니다.

 

  • ValidationItemController - 수정 로직에 UpdateCheck Groups 적용
 @PostMapping("/{itemId}/edit")
    public String editV2(@PathVariable Long itemId, @Validated(UpdateCheck.class) @ModelAttribute Item item, BindingResult bindingResult) {

        //특정 필드가 아닌 복합 룰 검증
        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/editForm";
        }

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

}

 

기존 edit() 메서드를 주석처리하고 editV2 메서드를 생성해서 UpdateCheck.class를 적용했습니다.

 

참고)

@Valid 에는 groups를 적용할 수 있는 기능이 없습니다.
따라서 groups를 사용하려면 @Validated를 사용해야 합니다.

마치며

 

groups 기능을 사용해서 등록과 수정 시에 각각 다르게 검증을 할 수 있습니다. 그런데 groups 기능을 사용하니 Item은 물론이고, 전반적으로 복잡도가 올라갔습니다.

 

사실 groups 기능은 실제 잘 사용되지는 않는데, 그 이유는 실무에서는 주로 폼 객체와 수정용 폼 객체를 분리해서 사용하기 때문입니다.

 

Form 전송 객체 분리 방식은 다음 포스팅에서 알아보겠습니다.

위 포스팅은 김영한님의 Spring MVC 2편 - 백엔드 웹 개발 활용  강의를 참고했습니다.

 

스프링 MVC 2편 - 백엔드 웹 개발 활용 기술 강의 - 인프런

웹 애플리케이션 개발에 필요한 모든 웹 기술을 기초부터 이해하고, 완성할 수 있습니다. MVC 2편에서는 MVC 1편의 핵심 원리와 구조 위에 실무 웹 개발에 필요한 모든 활용 기술들을 학습할 수 있

www.inflearn.com

 

실무에서 프로젝트를 끝내고 느낀 점으로는 기초를 더욱더 단단하게 만들고 싶어서 복습해 봤습니다 :)

다음 포스팅에서 뵙겠습니다.

 

728x90