Bean Validation

Bean Validation 소개

  • 지금까지 작성했던 검증 로직 관련 코드를 하나의 어노테이션으로 끝낼 수 있는 기능
  • 대상 객체에 대해서 검증 로직을 모든 프로젝트에 적용할 수 있게 공통화하고, 표준화 한 것
  • Bean Validation 이란? → 특정한 구현체가 아니라 Bean Validation 2.0(JSR-380)이라는 기술 표준. 즉, 검증 애노테이션과 여러 인터페이스의 모음. 이를 구현한 기술 중에서 일반적으로 사용하는 구현체하이버네이트 Validator (ORM이랑은 관련 없음!)
  • Bean Validation 사용
    • Spring 통합없이, 순수 Bean Validation 사용법
    • Bean Validation 사용을 위한 의존관계 추가 (라이브러리 추가) → implementation 'org.springframework.boot:spring-boot-starter-validation'
    • Item 객체 (Bean Validation 적용 객체)

         ...
         @NotBlank // 빈 문자나 null이면 안됨
         private String itemName;
               
         @NotNull // null이면 안됨
         @Range(min = 1000, max = 1000000) // 1000~1000000 범위
         private Integer price;
               
         @NotNull // null이면 안됨
         @Max(9999) // 최댓값이 9999보다 크면 안됨
         private Integer quantity;
         ...
      
      • 검증 어노테이션으로 Validation 편리하게 적용 가능
      • @NotBlank : 빈 문자나 null이면 안됨
      • @NotNull : null이면 안됨
      • @Range(min = 1000, max = 1000000) : 범위 안의 값이어야 됨
      • @Max(9999) : 최대 범위 설정
    • 검증 Test
      • 검증기 생성

          ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
          Validator validator = factory.getValidator();
        
      • 검증 실행

          Set<ConstraintViolation<Item>> violations = validator.validate(item);
        
        • item에 검증에 벗어나는 값을 설정해서 넣어주면, 그에 맞는 검증 결과를 violations에 담아두는 것.
        • violations를 통해 어떤 검증 실패가 발생했는지 확인 가능

Bean Validation 적용 (Spring)

Bean Validation with Spring

  • Bean Validation의 Validator 등록을 위해 사용했던 @InitBinder 제거! → 검증이 중복으로 적용됨!! (Bean Validation의 Validator 랑 custom Validator(ItemValidator) 가 중복으로 실행 되는 것)
  • 그리고 다른 코드는 수정할 필요 없음! → Bean Validation이 자동으로 Global Validator로 등록 되고 이를 사용하고자 하는 객체에 @Validated 를 달아주면 알아서 Validator가 적용됨! (@Validated @ModelAttribute Item item)

      @PostMapping("/add")
      public String addItem(@Validated @ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes) {
        	 
      	 // 실패 시
      	 if (bindingResult.hasErrors()) {
      		 log.info("errors={}", bindingResult);
      		 return "validation/v3/addForm";
      	 }
      	 ... // 성공 로직
      }
    
  • 어떻게 Bean Validator가 적용 되나?
    • 스프링 부트는 spring-boot-starter-validation 라이브러리가 있으면 자동으로 Bean Validator를 인지하고 스프링에 통합시킴 (즉, Spring 내에서 사용할 수 있게 해준다는 것)
    • 그리고, LocalValidatorFactoryBean 을 Global Validator (프로젝트 내의 모든 곳에서 사용할 수 있는 Validator)로 등록. ( LocalValidatorFactoryBean@NotNull과 같은 애노테이션을 보고 검증을 수행. 추가로 Global Validator를 직접 등록하는 경우, 해당 Validator는 동작하지 않음 → Global은 직접 등록한 애들만 있다고 생각하기에)
    • 이렇게 Bean Validation 기반 Global Validator가 자동으로 등록되었기 때문에 @Valid , @Validated 만 적용해주면 해당 Validator를 원하는 객체에 적용할 수 있음!
    • 적용한 객체에서 검증 오류가 발생하면, 이전과 동일하게 FieldError, ObjectError 를 생성해서 BindingResult에 담아줌!
  • Bean Validator 검증 순서
    1. @ModelAttribute 각각의 필드에 Type 변환 (String to X) 시도
      1. 성공하면 2번
      2. 실패하면 typeMismatchFieldError 추가
    2. 어노테이션 기반 Validator 적용 (Bean Validation)

Bean Validation with Error Message(Code)

  • 더 자세히, 계층적으로 Bean Validation의 오류 메시지 설정하기
  • Bean Validation으로 인해 발생된 bindingResult의 검증 오류 코드를 확인해보면 오류 코드가 애노테이션 이름으로 등록되는 것을 확인할 수 있음. → codes=[NotBlank.item.itemName, NotBlank.itemName, NotBlank.java.lang.String, NotBlank]
  • 즉, Bean Validation도 이전과 동일하게 errors.propertes 메시지 소스에 해당 어노테이션을 이름으로 하는 오류 코드를 지정해주면 됨
  • errors.properties

      NotBlank={0} 공백X
      Range={0}, {2} ~ {1} 허용
      Max={0}, 최대 {1}
    
    • {0} : 필드 명. {1},{2}, … 는 각 애노테이션에서 설정한 값들

Bean Validation in ObjectError

  • 지금까지는 Bean Validation을 FieldError에 적용하는 것을 봤음
  • 그럼 ObjectError는 어떻게?
    • @ScriptAssert() 이용
    • 기존에 사용했던, 직접 ObjectError를 생성해서 만들기
  • @ScriptAssert()

      @Data
      @ScriptAssert(lang = "javascript", script = "_this.price * _this.quantity >= 10000", message = "10000원 이상")
      public class Item {
      	 //...
      }
    
    • item의 price, quantity의 값의 곱이 10000 이상인 검증 수행
    • message 설정 가능
    • But, script 언어로 사용하기도 하고 사용 방법이 단순하지 않음
    • 또한, 검증 과정 자체가 복잡할 경우는 사용하기 까다로움
    • 보통 많이 사용하지는 않는 방법
  • [주로 사용하는 방법] 직접 검증 후 ObjectError를 생성해서 넣어주기 (Controller 단에서)

      if (item.getPrice() != null && item.getQuantity() != null) {
      	 int resultPrice = item.getPrice() * item.getQuantity();
      	 if (resultPrice < 10000) {
      		 bindingResult.reject("totalPriceMin", new Object[]{10000, resultPrice}, null);
      	 }
      }
    
    • 직접 검증을 실행하여 검증 조건에 맞지 않다면 BindingResult의 reject를 사용하여 ObjectError를 생성하여 넣어줌
    • 코드가 길어지지만, 복잡한 검증도 가능하며 원하는 형태로 구성할 수 있음

Bean Validation - 요구사항에 따른 검증

  • 요구사항에 따른 검증 2가지 방법 (with Bean Validation)
    • BeanValidation의 groups 기능 사용
    • Item을 직접 From Data로 사용하는 것이 아닌, ItemSaveForm, ItemUpdateForm 같은 DTO를 사용 (DTO : Data Transfer Object)
  • 실무에서는 groups 보다는 DTO를 주로 사용

BeanValidation의 groups

  • 등록시에 검증할 기능과 수정시에 검증할 기능을 각각 그룹으로 나누어 적용
  • group은 interface로 지정만 해주면 됨
    • 등록용 groups interface 생성 → public interface SaveCheck {}
    • 수정용 groups interface 생성 → public interface UpdateCheck {}
    • 등록용은 SaveCheck.class로 지정, 수정용은 UpdateCheck.class로 group을 지정해주면 됨
  • Item 객체에 group 지정하기
    • 등록만 적용 : @Max ->@Max(value = 9999, groups = SaveCheck.class)
    • 수정만 적용 : @NotNull@NotNull(groups = UpdateCheck.class)
    • 둘 다 적용 : @NotNull@NotNull(groups = {SaveCheck.class, UpdateCheck.class})
  • Controller의 @Validated에 group 지정
    • @Validated() 안에 해당 group interface를 지정해주면 됨
    • 등록 method (@PostMapping(”/add”)) → public String addItemV2(@Validated(SaveCheck.class) @ModelAttribute Item item ...
    • 수정 method (@PostMapping(”/{itemId}/edit”)) → public String editV2(@PathVariable Long itemId, @Validated(UpdateCheck.class) @ModelAttribute Item item, …
  • BUT, 실무에서는 groups 보다는 DTO 형식을 이용!!!

참고 : @Valid 에서는 groups 사용 못함. 즉, groups를 사용하려면 @Validated 를 사용해야 됨

Bean Validation with DTO (Form 전송 객체 분리)

  • 실무에서 groups 를 잘 사용하지 않는 이유 → 에서 전달하는 데이터가 서비스 도메인 객체와 정확히 딱 드러맞지 않기 때문
  • 즉, 사용자가 입력한 데이터와 실제로 저장하려 하는 도메인 객체가 딱 맞지 않음! (추가적은 정보를 넣어야할 때도 있고, 부가적으로 작업 후에 새로운 값으로 넣어주는 경우도 있기 때문)
  • 그래서 보통 도메인으로 사용되는 객체를 Form으로 보내 직접 받아 오는 것이 아니라, 폼의 데이터를 컨트롤러까지 전달할 별도의 객체(DTO)를 만들어서 전달함. 즉, Form을 전달받는 전용 객체를 만들어 @ModelAttribute 로 사용하는 것. 이후 해당 데이터를 받아 컨트롤러에서 필요한 데이터만을 사용하여 서비스 도메인을 생성하는 것!
  • DTO 사용
    • 기존 Item 도메인 객체의 Bean Validation은 모두 제거 (Form DTO를 통해 검증을 마친 후 사용되므로!)
    • 등록용 DTO (ItemSaveForm)public class ItemSaveForm
      • 등록 스펙에 맞는 property들(id는 등록시 넘어오지 않으므로 id를 제외한 item의 모든 proerty)만을 설정
      • 등록 시 요구사항에 맞는 Bean Validation 진행
    • 수정용 DTO (ItemUpdateForm)public class ItemUpdateForm
      • 수정 스펙에 맞는 property들 (수정 시 어떤 item을 수정하는 지 알아야 하기 떄문에 id 도 포함) 만을 설정
      • 수정 시 요구사항에 맞는 Bean Validation 진행
    • 이제 각 등록, 수정 Controller에서 해당 하는 Form 객체(DTO)를 사용하여 데이터를 받아 온 후 도메인 객체로 값을 이전(변환)하여 저장하면 됨
      • 등록 Controller (@PostMapping("/add")) → public String addItem(@Validated @ModelAttribute("item") ItemSaveForm form ...
        • ItemSaveForm 등록 DTO 사용
        • @ModelAttribute("item") 를 통해 model 에 itemSaveForm의 이름이 아닌 item의 이름으로 View template에 넘겨줌 (view template에서 item의 이름으로 값에 접근하기 위함)
        • 도메인 객체로 값 이전 후 repository에 저장

            Item item = new Item();
            item.setItemName(form.getItemName());
            item.setPrice(form.getPrice());
            item.setQuantity(form.getQuantity());
          
      • 수정 Controller(@PostMapping("/{itemId}/edit")) → public String edit(@PathVariable Long itemId, @Validated @ModelAttribute("item") ItemUpdateForm form, …
        • ItemUpdateForm 수정 DTO 사용
        • 나머지는 등록 Controlle과 유사
    • Form 전송 객체 분리해서 등록과 수정에 딱 맞는 기능을 구성하고, 검증도 명확히 분리함

Bean Validation in API (@RequestBody, HttpMessageConverter)

참고 : @ModelAttribute 는 HTTP 요청 파라미터(URL 쿼리 스트링, POST Form)를 다룰 때 사용, @RequestBodyHTTP Body의 데이터를 객체로 변환할 때 사용(API JSON 요청)

  • @Validated 를 통한 검증은 @RequestBody(HttpMessageConverter) 에도 적용 가능
  • API 3가지 경우
    • 성공
    • 실패 : JSON 객체로 생성하는 것 자체가 실패
    • 검증 오류 : JSON 객체로 생성 후 검증에서 실패
  • 여기서 중요한 부분은 JSON 객체로 생성하는 것 자체가 실패한 경우
  • JSON 객체로 생성하는 것 자체가 실패한 경우는 오류 코드(메세지)들이 반환되는 것이 아닌 “400 Bad Request” 가 반환됨
  • 즉, price에 Integer가 아닌 String으로 값을 넣는 경우 JSON 객체 생성 자체가 안되며 Controller가 호출 자체가 안되고 검증 자체가 실행되지 않고 오류가 발생해버림!!