검증 - Bean Validation

2022. 4. 2. 20:34Spring 기초

이전 포스팅에서 다뤘던 Validation보다 편리한 Bean Validation을 소개한다.
사용을 위해서는 먼저 라이브러리를 추가해야 한다.

build.gradle에

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

추가해 라이브러리를 등록하자.

스프링 부트는 이 라이브러리를 넣으면 자동으로 Bean Validator를 인지하고 스프링에 통합해준다.
또, 스프링 부트는 자동으로 글로벌 Validator로 등록한다. 이 등록된 Validator는 @NotNull, @Max 등의 애노테이션을 보고 검증을 수행한다. 이렇게 글로벌 Validator가 등록되어있으므로, 검증하고자 하는 파라미터 앞에 @Valid 또는 @Validated를 붙여 적용하면 된다.

해당 파라미터에 검증 오류가 발생한다면, FieldError, ObjectError를 알맞게 생성해 BindingResult에 담아준다.

**주의사항

다음과 같이 수동으로 Validator를 등록하면 스프링 부트는 Bean Validator를 글로벌 Validator로 등록하지 않는다. 따라서 애노테이션을 아무리 붙여도 검증기가 안돌아간다. 만약 수동으로 등록한 글로벌 검증기가 있다면 제거하자.

@SpringBootApplication
public class ItemServiceApplication implements WebMvcConfigurer {
 	// 글로벌 검증기 추가
    
    @Override  //수동 검증기   --> 제거합시다
    public Validator getValidator() {
        return new ItemValidator();
    }
     // ...
}

 

아까 검증하고자 하는 파라미터에 @Valid, @Validated 둘 다 사용 가능하다고 했는데, 후자는 내부에 groups라는 기능을 포함하고 있다. 뒤에 설명을 적어놓았으니 궁금하다면 CTRL+F로 groups을 검색하자!


기본적인 검증순서는 다음과 같다.

@ModelAttribute 각각의 필드에 타입 변환을 시도한다. 
                ↓  ↓  ↓

1. 타입 변환에 성공하면 다음 단계로
2. 실패하면 typeMistmatch로 FieldError를 추가한다.
                ↓  ↓  ↓
Validator를 적용

즉, 바인딩에 성공한 필드만 Bean Validation을 적용한다. 당연한 말이지만, 일단 모델 객체에 바인딩 받는 값이정상이어야 검증도 의미가 있는 것이다. 따라서, ModelAttribute에 타입 변환 시도 후 성공한 필드에 대해서만 Bean Validation을 적용한다.

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


Bean Validation의 에러코드

Bean Validation을 적용하고 bindingResult 에 등록된 검증 오류 코드를 보자.
기본으로 제공하는 오류 코드는 애노테이션 이름으로 등록된다. 예를 들어 @NotBlank의 경우는 NotBlank라는 오류 코드를 기반으로 MessageCodesResolver 를 통해 다양한 메시지 코드가 순서대로 생성된다.

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

이전 포스팅에서 설명했듯 구체적인 순서

에서 추상적인 순서로 생성된다.
메시지를 등록하려면 똑같이 errors.properties 파일에 등록하면 된다.

errors.properties

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

 

{0}은 필드명, {1},{2}...는 어노테이션마다 다르게 설정하면 된다.
아니면 아래처럼 직접 필드에 가서 메시지를 설정할 수 있다.
참고로 우선순위
1) 위처럼 프로퍼티 파일에 설정한 메시지
2) 아래처럼 직접 필드에 설정한 메시지
3) 라이브러리가 제공하는 기본 메시지 순이다. 

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

특정 필드에러가 아닌 Object 에러는 어떻게 처리할 수 있을까??
@ScriptAssert를 사용할 수 있지만,, 너무 복잡해 아래처럼 자바 코드로 해결하자 ^___^

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

Bean Validation - 한계

데이터를 등록할 때와 수정할 때는 요구사항이 다를 수 있다. 지금은 Item 클래스 위에 어노테이션으로 Bean Validation을 적용하는데, 이 어노테이션은 등록/수정 무차별적으로 동작한다.  
예를들어 등록시에는 quantity 수량을 최대 9999까지 등록할 수 있지만 수정시에는 수량을 무제한으로 변경할 수 있다. 등록시에는 id 에 값이 없어도 되지만, 수정시에는 id 값이 필수이다

이 요구사항을 위해 어노테이션을 바꾸면? 등록시에도 최대 수량이 9999를 넘어도 정상 등록한다.
또, 등록을 해야 id 값이 생기는데,, id값이 필수로 설정되어 등록이 안되는 오류가 발생하고 만다.

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

Bean Validation - groups 로 해결하자.

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

1. BeanValidation groups 기능 사용


이런 문제를 해결하기 위해 Bean Validation은 groups라는 기능을 제공한다.
예를 들어서 등록시에 검증할 기능과 수정시에 검증할 기능을 각각 그룹으로 나누어 적용할 수 있다.
코드로 확인해보자

저장용 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;
 }
}

Item 클래스를 보면, 각각의 검증 어노테이션에 적용할 class가 groups로 나뉘어진 것을 볼 수 있다. 등록된 groups만 해당 어노테이션의 검증을 받는 것이다. 
다음은 등록, 수정 컨트롤러를 살펴보자 

@PostMapping("/add")  // 등록 컨트롤러
public String addItemV2(@Validated(SaveCheck.class) @ModelAttribute Item item,
BindingResult bindingResult, RedirectAttributes redirectAttributes) {
 	//...
}

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

게시글 초반부에 설명했던 @Validated의 속성 groups가 이것이다. 해당 클래스타입이 등록되었던 어노테이션의 Validation이 적용되는 것이다.

참고: @Valid 에는 groups를 적용할 수 있는 기능이 없다. 따라서 groups를 사용하려면 @Validated 를 사용해야 한다.

 

2.  폼 전송을 위한 별도의 모델 객체

위의 groups방법은 코드 복잡도가 올라가 권장되지 않는 방법이다. 또한, 등록 시 폼에서 전달하는 데이터가 Item 도메인 객체와 딱 맞지 않기 때문이다. 소위 "Hello World" 예제에서는 폼에서 전달하는 데이터와 Item 도메인 객체가 딱 맞는다. 하지만 실무에서는 회원 등록시 회원과 관련된 데이터만 전달받는 것이 아니라, 약관 정보도 추가로 받는 등 Item 과 관계없는 수 많은 부가 데이터가 넘어온다.  
그래서 보통 Item 을 직접 전달받는 것이 아니라, 복잡한 폼의 데이터를 컨트롤러까지 전달할 별도의 객체를 만들어서 전달한다. 예를 들면 ItemSaveForm 이라는 폼을 전달받는 전용 객체를 만들어서 @ModelAttribute 로 사용한다. 이것을 통해 컨트롤러에서 폼 데이터를 전달 받고, 이후 컨트롤러에서 필요한 데이터를 사용해서 Item 을 생성한다.


결론! 이번에 소개할 방법을 잘 알아두자!


이제 Item 의 검증은 사용하지 않으므로 검증 코드를 제거해도 된다.
@Data
public class Item {
     private Long id;
     private String itemName;
     private Integer price;
     private Integer quantity;
}

 

어노테이션이 사라지니 깔끔하구만..

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

ITEM 수정용 폼

@Data
public class ItemUpdateForm {
 @NotNull
 private Long id;
 @NotBlank
 private String itemName;
 @NotNull
 @Range(min = 1000, max = 1000000)
 private Integer price;
 //수정에서는 수량은 자유롭게 변경할 수 있다.
 private Integer quantity;
}

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

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

각각의 데이터를 담은 폼에서 데이터를 꺼낸다음, 컨트롤러 내부에서 Item을 새로 생성하여 데이터를 담아주는 모습이다.

 

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

'Spring 기초' 카테고리의 다른 글

@SessionAttribute와 세션정보, 세션 타임아웃 설정  (0) 2022.04.04
검증 - Bean Validation(HTTP 메시지 컨버터)  (0) 2022.04.02
검증 - Validation (2)  (0) 2022.03.31
검증 - Validation(1)  (0) 2022.03.31
REST API와 Ajax(2)  (0) 2022.03.27