-
[Spring Boot] Bean Validation , 한계와 객체 분리Back-End/Spring Boot 2022. 11. 1. 12:16
📑 Bean Validation이란?
이전 포스팅의 검증 로직을 살펴보면 아래와 같이 직접 코드로 작성해야했다. 특정 필드에 대한 검증 로직은 대부분 빈 값인지 아닌지, 특정 크기를 넘는지 아닌지와 같이 매우 일반적인 로직이다.
public String addItemV4(@ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model) { //검증에 실패하면 다시 입력 폼으로 if(bindingResult.hasErrors()){ log.info("errors ={}", bindingResult); return "validation/v2/addForm"; } log.info("objectName ={}", bindingResult.getObjectName()); log.info("target={}",bindingResult.getTarget()); //검증 로직 if(!StringUtils.hasText(item.getItemName())) { bindingResult.rejectValue("itemName","required"); } if(item.getPrice() ==null || item.getPrice()<1000 || item.getPrice()>1000000){ bindingResult.rejectValue("price","range",new Object[]{1000,1000000},null); } if(item.getQuantity() ==null || item.getQuantity()>=9999){ bindingResult.rejectValue("quantity","max",new Object[]{9999},null); } //특정 필드가 아닌 복합 룰 검증 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/v2/addForm"; } //성공 로직 Item savedItem = itemRepository.save(item); redirectAttributes.addAttribute("itemId", savedItem.getId()); redirectAttributes.addAttribute("status", true); return "redirect:/validation/v2/items/{itemId}"; }
따라서 이런 검증 로직을 모든 프로젝트에 적용할 수 있게 공통화하고, 표준화 한 것이 바로 Bean Validation이다.
Bean Validation은 특정한 구현체가 아닌, Bean Validation 2.0 이라는 기술 표준이다. 쉽게 이야기해서 검증 애노테이션과 여러 인터페이스의 모음이다.
아래 링크는 하이버네이트 Validator 관련된 공식 메뉴얼이니 참고하면 좋을 듯 하다.
📑 Bean Validator 시작하기
Bean Validator를 시작하려면 의존 관계를 추가해줘야한다. build.gradle에 아래 코드를 추가하자.
implementation 'org.springframework.boot:spring-boot-starter-validation'
그 후 Domain의 Item 클래스에 Bean Validation 애노테이션을 적용해준다.
package hello.itemservice.domain.item; import lombok.Data; import org.hibernate.validator.constraints.Range; import org.hibernate.validator.constraints.ScriptAssert; import javax.validation.constraints.Max; import javax.validation.constraints.NotBlank; import javax.validation.constraints.NotNull; @Data public class Item { @NotNull 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=100, max =1000000) : 범위 안의 값이어야 한다.
📌 @Max(9999) : 최대 9999까지만 허용한다.
이렇게 적용해두면 스프링 부트는 자동으로 Bean Validator를 인지하고 스프링에 통합한다. 스프링 부트는 자동으로 글로벌 Validator로 등록하는데 LocalValidatorFactoryBean을 글로벌 Validator로 등록한다. 이 Validator는 @NotNull같은 애너테이션을 보고 검증을 수행한다. 이렇게 Validator가 적용되어 있기 때문에 @Valid, @Validated만 적용하면 된다. 검증 오류가 발생하면 FieldError, ObjectError를 생성해 BindingResult에 담아준다.
@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}"; }
📑 검증 순서 알아보기
1. @ModelAttribute 각각의 필드에 타입 변화 시도
( requestParam 역할로 스프링프레임워크가 Item 필드에 값을 넣어준다) 성공하면 다음으로, 실패하면 typeMismatch로 FieldError 추가
2. 변환에 성공한 필드만 Bean Validation 적용
바인딩에 성공한 필드만 Bean Validation 적용, 이말은 즉슨 BeanValidator는 바인딩에 실패한 필드는 BeanValidation을 적용하지 않는데, 생각을 해보면 타입 변환에 성공해야 검증을 적용하는 의미가 있다. 타입 변환부터 안돼서 걸러지는데 여기에 무슨 검증을 또 적용하나! 따라서 일단 모델 객체에 바인딩 받는 값이 정상으로 들어와야 검증도 의미가 있다.
📑 Bean Validation 에러코드
Bean Validation이 기본으로 제공하는 오류 메시지를 좀 더 자세히 변경하고 싶으면 어떻게 하면 될까? Bean Validation을 적용하고 bindingResult에 등록된 검증 오류 코드를 보면 마치 typeMismatch와 유사하다. NotBlank라는 코드오류를 기반으로 MessageCodesReslover를 통해 다양한 메시지 코드가 순서대로 생성된다.
📌 @NotBlank
Field error in object 'item' on field 'itemName': rejected value [];
codes [NotBlank.item.itemName,NotBlank.itemName,NotBlank.java.lang.String,NotBlank];
arguments [org.springframework.context.support.DefaultMessageSourceResolvable:
codes [item.itemName,itemName]; arguments []; default message [itemName]]; default message [공백일 수 없습니다]- NotBlank.item.itemName
- NotBlank.itemName
- NotBlank.java.lang.String
- NotBlank
위와 같은 우선순위로 해당하는 properties에 속성 값을 출력한다. 만약 errors.properties에 아무 값도 입력하지 않았다면 NotBlank의 값인 공백일 수 없습니다가 출력된다.
📌 @Range
Field error in object 'item' on field 'price': rejected value [1];
codes [Range.item.price,Range.price,Range.java.lang.Integer,Range];
arguments [org.springframework.context.support.DefaultMessageSourceResolvable:
codes [item.price,price]; arguments []; default message [price],1000000,1000]; default message [1000에서 1000000 사이여야 합니다]- Range.item.price
- Range.price
- Range.java.lang.Integer
- Range
Range도 위의 NotBlank와 마찬가지로 위의 순서대로 우선순위를 가진다.
더 자세한 메시지를 출력하기 위해 errors.properties에 메시지를 등록해보자.
#Bean Validation 추가
NotBlank={0} 공백X
Range={0}, {2} ~ {1} 허용
Max={0},최대 {1}
NotBlank.item.itemName = 상품 이름을 적어주세요.{0}은 필드명이고, {1}, {2}은 애너테이션마다 다르다.
이렇게 properties 속성에 값을 저장하는 거 말고 Item 클래스에서 직접 애노테이션에도 사용할 수 있다.
@NotBlank(message = "공백은 입력할 수 없습니다.")
private String itemName;📑 Bean Validation 한계
데이터를 등록할 때와 수정할 때는 요구사항이 다를 수 있다. 예를들어 등록시에 quantity 수량을 최대 9999까지 등록할 수 있지만 수정시에는 수량을 무제한으로 변경할 수 있으며, 등록시에는 id 값이 없어도 되지만, 수정시에는 id 값이 필수이다.
이를 해결하기 위한 방법이 2가지가 있다.
1. BeanValidation의 groups 기능 사용
2. Item을 직접 사용하지 않고, ItemSavaForm, ItemUpdateForm과 같은 폼 전송을 위한 별도의 모델 객체를 만들어 사용한다.실무에선 groups를 잘 사용하지 않는데, 바로 등록 시 폼에서 전달하는 데이터가 Item 도메인 객체와 맞지 않기 때문이다. 실무에서는 회원 등록시 회원과 관련된 정보만 받는 게 아니라, 약관 정보와 같은 Item과 관계없는 수 많은 부가 데이터가 넘어온다. 따라서 Item을 직접 전달받는 게 아닌, 복잡한 폼의 데이터 컨트롤러까지 전달할 별도의 객체를 만들어서 전달한다. 예를 들어 ItemSaveForm 이라는 폼을 전달받는 전용 객체를 만들어 @ModelAttribute로 사용한다. 이것을 통해 컨트롤러에 폼 데이터를 전달 받고, 이후 컨트롤렁서 필요한 데이터를 사용해 Item을 생성한다.
📑 Form 전송 객체 분리
폼 데이터 전달을 위한 별도의 객체를 사용하면 데이터 전달 과정은 다음과 같다.
HTML Form -> ItemSaveForm -> Controller -> Item 생성 -> Repository
이렇게 전송하면 폼 데이터가 복잡해도 거기에 맞춘 별도의 폼 객체를 사용해서 데이터를 전달 받을 수 있다. 보통 등록과, 수정용으로 별도의 폼 객체를 만들기 때문에 검증이 중복되지 않는다.
수정의 경우 등록과는 완전히 다른 데이터가 넘어온다. 예를들어 등록 시 로그인 id, 주민번호 등등 받을 수 있지만, 수정시에는 이런 부분이 빠지게 된다. 따라서 ItemUpdateForm과 같이 별도의 객체로 데이터를 전달받는 것이 좋다.
<ItemSaveForm>
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>
package hello.itemservice.web.validation.form; import lombok.Data; import org.hibernate.validator.constraints.Range; 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; }
<컨트롤러>
//실제 데이터 저장이 일어나는 컨트롤러 //@Validated가 있으면 BeanValidation이 작동되면서 결과를 bindingResult에 넣어준다. @PostMapping("/add") public String addItem(@Validated @ModelAttribute("item") ItemSaveForm itemSaveForm, BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model) { //특정 필드 예외가 아닌 전체 예외 if (itemSaveForm.getPrice() != null && itemSaveForm.getQuantity() != null) { int resultPrice = itemSaveForm.getPrice() * itemSaveForm.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(itemSaveForm.getItemName()); item.setPrice(itemSaveForm.getPrice()); item.setQuantity(itemSaveForm.getQuantity()); Item savedItem = itemRepository.save(item); redirectAttributes.addAttribute("itemId", savedItem.getId()); redirectAttributes.addAttribute("status", true); return "redirect:/validation/v4/items/{itemId}"; }
여기서 집중해서 봐야될 부분은 폼 객체의 바인딩이다. @ModelAttribute("item")을 ItemSaveForm form에 저장해서 사용하겠다. 즉 파라미터로 받은 데이터들을 객체로 생성한 후 자동으로 Item이 아닌 ItemSaveForm에 저장하겠다. 이렇게 저장된 객체의 값들을 Item 객체를 로 생성해 거기에 item.setItemName(itemSaveForm.getItemName())과 같은 방식으로 다시 저장하면 된다.
반응형'Back-End > Spring Boot' 카테고리의 다른 글
[Spring Boot] 서버 재시작 없이 html 변경하기 (thymeleaf) (0) 2023.03.29 [Spring Boot] 필터와 인터셉터 차이, 로그인 인증 체크하기 (0) 2022.11.02 [Spring Boot] 검증 요구사항 ( fieldError, rejectValue() ..) (0) 2022.10.28 [Spring Boot] 메시지, 국제화 파일 생성 후 타임리프에 적용해보기 (0) 2022.10.26 [Spring Boot] 타임리프가 지원하는 form 속성 기능 (check box, radio button, select box) (0) 2022.10.25