둘셋 개발!

[spring mvc 2편-웹 개발 활용 기술 - 4 (2) ] 검증2 BeanValidation 본문

SPRING/MVC

[spring mvc 2편-웹 개발 활용 기술 - 4 (2) ] 검증2 BeanValidation

23 2022. 1. 30. 21:38

Bean Validation이란?

: 검증 애노테이션과 여러 인터페이스의 모음이다.

 


Item 클래스에 Bean Validation을 적용해보자

 

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

검증 기능을 매번 코드로 작성하지 않고 Bean Validation을 잘 활용하면 애노테이션 하나로 검증로직을 편리하게 적용할 수 있다.

 

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

 


스프링 MVC는 어떻게 Bean Validator를 사용할까?

 

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

: 스프링부트가 Bean Validator를 자동으로 글로벌 Validator로 등록해준다. 그래서 @Valid, @Validated만 적용하면 된다.

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

 

( + @Valid와 @Validated의 차이 )

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

그리고 @Validated는 내부에 groups라는 기능을 포함한다(뒤에 설명)

 

 

 

 


Bean Validation을 사용했을 시 검증 순서

 

1. @ModelAttribute 각각의 필드에 타입 변환 시도
              -성공하면 다음으로
              -실패하면 typeMismatch로 FieldError추가

2. Bean Validation 적용

 

 


Bean Validation 에러코드

 

 

오류코드는 애노테이션 이름으로 등록된다.

만약에 @NotBlank의 검증을 통과하지 못하면 NotBlank라는 오류코드 기반으로 MessageCodesResolver를 메세지 코드가생성된다.

 

예시) @NotBlank오류

-NotBlank.item.itemName

-NotBlank.itemName

-NotBlank.java.lang.String

-NotBlank

 

Bean Validation이 메세지 찾는 순서

 

1. 생성된 메세지 코드 순서대로 MessageSource에서 메세지 찾기
2. 애노테이션 message속성 사용 -> @NotBlank(message = "공백입니다! {0})
3. 라이브러리가 제공하는 기본 값 사용

 

 


Bean Validation 오브젝트 오류

 

: 특정필드의 오류가 아닌 특정필드를 넘어선 오류인 해당 오브젝트 관련 오류는 

@ScriptAssert()를 사용하면 된다.

 

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

가격과 수량의 곱했을 때 10000을 넘어야 한다는 뜻이다.

이를 실행해보면 메세지 코드는

ScriptAssert.item

ScriptAssert

가 생성된다.

 

하지만!! 실제로 사용해보면 제약이 많고 복잡하다. 그리고 실무에서는 검증 기능이 해당 객체의 범위를 넘어서는 경우들이 등장하는데, 그런 경우 대응이 어렵다.

 

따라서 @ScriptAssert를 억지로 사용하는 것 보다 오브젝트 오류 관련 부분만 직접 자바코드로 작성하는 것을 권장한다고 한다.


BeanValidation의 한계

 

만약 Item을 생성할 때 처음 생성할 때와 나중에 수정할 때의 검증 요청 사항이 다르면 어떻게 해야할까?

이럴때 방법이 2가지가 있다.

 

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

먼저 groups기능을 사용해보겠다.

 

1. BeanValidation groups기능 사용

등록과 수정 요구사항이 다른 예시:

 

(등록시 요구사항)

-타입 검증

가격수량에 문자가 들어가면 검증 오류 처리

-필드 검증
상품명
: 필수, 

가격: 1000원 이상, 1백만원 이하,

수량: 최대 9999

 

-특정 필드의 범위를 넘어서는 검증

가격 * 수량의 합은 10,000원 이상

 

 

(수정시 요구사항)

 

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

 

-등록시 groups 생성

public interface SaveCheck {}

-수정시 groups생성

public interface UpdateCheck {}

-item groups 적용

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

 

-Controller에 groups적용

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

 

 

2. Form전송 객체 분리

 

폼전송 객체를 분리해야 하는 이유: 
실무에서는 1방법 보다 form전송 객체 분리 방법을 더 많이 쓴다.
왜냐하면 등록시 폼에서 전달하는 데이터가 Item도메인 객체와 딱 맞지 않다.
예를 들어 회원등록시 회원과 관련된 데이터만 전달받는 것이 아니라, 약관 정보도 추가로 받는 등 Item과 관계없는 수많은 다른 데이터가 넘어온다.
그래서 복잡한 폼의 데이터를 컨트롤러까지 전달한 별도의 객체를 만들어서 전달한다.

ex) HTML form -> ItemSaveForm -> Controller -> Item 생성 -> Repository

 

item을 등록할 때의 폼 객체와 수정할 때의 폼 객체를 따로 만들 것이다. 우선 item클래스에 있는 검증코드를 제거한다.

 

-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 {
  //수정에서는 id값이 널이면 안된다.
      @NotNull
      private Long id;
      @NotBlank
      private String itemName;
      @NotNull
      @Range(min = 1000, max = 1000000)
      private Integer price;
      
//수정에서는 수량은 자유롭게 변경할 수 있다. 
      private Integer quantity;
}

 

 

이제 컨트롤러에서는 등록 시,

1. 등록 폼 객체를 바인딩 해줘야 한다.

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

@ModelAttribute로 form 객체를 받아온다.

이때 주의할 점은 @ModelAttribute는 바인딩 하는 객체의 타입에서 앞글자만 소문자로 바꾼 itemSaveForm을 바인딩 해오기 때문에

 ("item")이라고 꼭 써줘야 한다.

 

2. 폼 객체를 item으로 변환

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

 

수정도 마찬가지 이다.


HTTP 메세지 컨버터

 

@Valid, @Validated는 HttpMessageConverter(@RequestBody) 에도 적용할 수 있다.

(@RequestBody는 HTTP Body의 데이터를 객체로 변환할 때 사용)

 

-컨트롤러에서 @RequestBody 사용

@PostMapping("/add")
        public Object addItem(@RequestBody @Validated ItemSaveForm form,
				BindingResult bindingResult) { 
	//...
        }

 

api의 경우 3가지 경우를 나눠서 생각해야한다.

- 성공요청 : 성공
- 실패 요청 : json을 객체로 생성하는 것 자체가 실패
- 검증 오류 요청 : json을 객체로 생성하는 것은 성공, 검증에서 실패

실패 요청에서 json을 객체로 생성하는 것을 실패하는 이유는 특정 필드에 타입이 맞지 않는 오류가 발생했기 때문이다. 따라서 객체를 만들 수 없는 것이다. 이를 해결하는 방법은 이후 포스팅에서 설명한다.

 

 

여기서 @ModelAttribute와 @RequestBody의 차이가 발생한다.

 

- @ModelAttribute : 각각 필드 단위로 세밀하게 적용되기 때문에 특정필드에도 타입오류가 나더라도 나머지 필드는 정상처리 가능
- HttpMessageConverter :  각각 필드 단위로 적용되는 것이 아니라, 전체 객체 단위로 적용

(참고 : 인프런 김영한 강사님 - 스프링 MVC 2편 - 백엔드 웹 개발 활용 기술 )