ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • Bean Validation
    Spring/MVC2-Validation 2023. 6. 20. 01:53
    728x90

    2023.06.19 - [Spring/MVC2-Validation] - Validation

    이전글 참고

     

    Validation

    개념 내가 만든 웹페이지에 1. 반드시 필요한 데이터이므로 반드시 작성을 요구한다던지, 2. 입력데이터의 범위 값을 설정한다던지, 3. 데이터의 타입을 제대로 받도록 하는 작업 은 매우 필수적

    hongs429-blog.tistory.com

     

    개념

    Bean Validation 은 어노테이션으로 검증로직을 매우 편리하게 적용할 수 있는 기술이다.

    우리가 장왕하게 작성했던 if절들이 사실은 몇가지 규칙기반으로 쉽게 작성가능하다. 이런 것을 도와주는 것이 바로 bean Validation인데, 이는 특정한 구현체가 아니라 기술표준을 말한다. 

    즉, 검증 어노테이션과 여러 인터페이스의 모음이라는 것이다.

    Bean Validation을 구현한 기중 중에 일반적으로 사용하는 구현체는 하이버네이트 Validator이다.

    validation을 dependency에 추가하면 이렇게 알아서 등록되어있으므로 크게 상관하지 않아도 되긴하다.

    Bean Validation의 장점은 매우 직관적인 어노테이션이라는 것이다. 한번 하나씩보자.

     

    ◎ 검증 애노테이션

    • Not Blank : 말그대로 빈값을 허용하지 않는다는 것. 또한, 공백만 있는 경우도 잡아준다.
    • NotNull : null 값 허용하지 않음
    • Range(min= , max= ) : 범위 안의 값만 허용한다.
    • Max(value) : value 이하의 값만 허용

    이외에도 정말많은 애노테이션을 지원한다. 궁금하다면 공식다큐를 보자.

    https://docs.jboss.org/hibernate/validator/6.2/reference/en-US/html_single/#validator-defineconstraints-spec

     

    Hibernate Validator 6.2.5.Final - Jakarta Bean Validation Reference Implementation: Reference Guide

    Validating data is a common task that occurs throughout all application layers, from the presentation to the persistence layer. Often the same validation logic is implemented in each layer which is time consuming and error-prone. To avoid duplication of th

    docs.jboss.org

     

     

     

     

    과정이해

    간단하게 TEST코드를 작성하여 동작을 한번 알아보자.

    @Test
    void beanValidation() {
        ValidatorFactory validatorFactory = Validation.buildDefaultValidatorFactory();
        Validator validator = validatorFactory.getValidator();
    
        Item item = new Item();
        item.setItemName("  ");
        item.setPrice(0);
        item.setQuantity(10000);
    
        System.out.println("validation");
        Set<ConstraintViolation<Item>> violations = validator.validate(item);
        for (ConstraintViolation<Item> violation : violations) {
            System.out.println("violation = " + violation);
            System.out.println("violation = " + violation.getMessage());
        }
    }
    import lombok.Data;
    /* 이건 하이버네이트 validator 를 사용할 때만 사용 가능 */
    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 {
    
        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;
        }
    }

    동작의 원리는 다음과 같다.

    1. ValidatorFactory만든다.

    2.  Item 커맨드 객체를 만들어 validate(item) 메소드로 검증한다.

    3. 검증의 결과를 반복문으로 꺼낸다.

    여기서 찍힌 내용을 보면 ErrorCode를 어떻게 관리하는지 알 수 있다.

    이 부분은 결국 MessageCodeResolver에서 처리가 될 텐데, 이건 나중에 한번더 알아보자.

     

    지금처럼 BeanValidator를 사용하면, 기존의 ItemValidator는 더이상 필요가 없으므로 지워준다.

    지금부턴 애노테이션 기반으로 동작할 것이다.

     

    동작은 앞서 테스트에서 했던 것과 비슷하게 흘러간다.

    0. @Validated @ModelAttribute 가 붙은 커맨트객체의 binding 시도. 이는 각 필드마다 각각 수행됨
            타입으로 인한 바인딩이 안되면, typeMismatch 에러코드로 FieldError 추가

    1-0. 이제서야 비로소 Bean Validator 적용@!

    1. LocalValidatoFactoryBean을 글로벌 Validator로 등록한다.

           이렇게 되면 이제부턴 애노테이션을 보고 검증을 수행하게 된다. 즉, @Valid 또는 @Validated 애노테이션과
           커맨드 객체에 @NotNull 과같은 검증을 위한 애노테이션만 있으면 된다.!

    2. 검증 도중 오류가 발생하면, FieldError나 ObjectErorr 를 생성하여 BindingResult에 담아준다.

     

     

    Bean Validation 의 에러코드 관리 - FieldError 

    이전의 Validation에서는 error code를 우리가 입력해주어 사용했었다. 하지만, 지금은 에러코드를 입력하는 곳이 따로 있지 않다.

    만약 우리가 에러코드를 스프링에서 제공하는 기본이 아니라 커스텀하고 싶다면?

    위에서 본 내용!

    아까 잠깐 언급한 내용을 확인해보자.

    지금 messageTemplate이 담고 있는 정보를 보면 우리가 커맨드 객체에서 애노테이션을 입력한 NotBlank / Max / Range 등이 보인다.

    즉, Bean Validation의 Errorcode에 대한 MessageCodeResolver가 동작하는 원리는
    커맨트 객체의 애노테이션이름을 기본적으로 따라간다는 것이다.


    이후의 로직은 이전과 같다.

    1순위 : ErrorCode.TargetName.field
    2순위 : ErrorCode.field
    3순위 : ErrorCode.field type
    4순위 : ErrorCode 
    5순위 : 에노테이션의 message = "" 값 확인하여 출력

    <예시>

    더보기

    @NotBlank

    ▶NotBlank.item.itemName

    NotBlank.itemName

    NotBlank.java.lang.String

    NotBlank

     

    @Range

    Range.item.price

    Range.price

    Range.java.lang.Integer

    Range

     

    실제 errors.properties 파일에 적는 글

    ※ 참고
          - 0 : 필드명
          - 1 : 커맨드 객체의 에노테이션에 명시된 값 1
          - 2 : 커맨드 객체의 에노테이션에 명시된 값 2

     

     

    그리고 TypeMismatch 에러메세지는 여기서도 동일하게 적용되니 그대로 사용하면된다.

    https://hongs429-blog.tistory.com/62 - 블로그 내용 中

    https://hongs429-blog.tistory.com/62

     

    Validation

    개념 내가 만든 웹페이지에 1. 반드시 필요한 데이터이므로 반드시 작성을 요구한다던지, 2. 입력데이터의 범위 값을 설정한다던지, 3. 데이터의 타입을 제대로 받도록 하는 작업 은 매우 필수적

    hongs429-blog.tistory.com

     

     

    Bean Validation 의 에러코드 관리 - ObjectError 

    위에서 필드에러에 대한 내용은 알아봤는데, 그럼 복합에러를 다루는 ObejctError의 경우에는 어떻게 처리할까?

     

    @ScriptAssert 라는 에노테이션으로 처리가 가능하지만!
    ObjectError의 경우 기존의 방식대로 BindingResult에 addError(new ObjectError(...))로 등록하자.
    // ObjectError 방식
    bindingResult.addError(new ObjectError(
            "item",
            new String[]{"totalPriceMin"},
            new Object[]{10000, resultPrice},
            null));
    
    // reject() 방식
    bindingResult.reject("totalPriceMin",
            new Object[]{10000, resultPrice},
            null);

     

     

    전체 코드

    public String addItemV2(@Validated(value = {SaveCheck.class}) @ModelAttribute Item item,
                              BindingResult bindingResult,
                              RedirectAttributes redirectAttributes
        ) {
    
           /*
           특정 필드가 아닌 복합 룰 검증은 다음을 쓰는게 낫다!
              -  new ObjectError! 의 경우, reject() ✨✨
              -  new FieldError의 경우 rejectValue()
           */
            if (item.getPrice() != null && item.getQuantity() != null) {
                int resultPrice = item.getPrice() * item.getQuantity();
                if (resultPrice < 10000) {
    
                    // ObjectError 방식
                    bindingResult.addError(new ObjectError(
                            "item",
                            new String[]{"totalPriceMin"},
                            new Object[]{10000, resultPrice},
                            null));
    
                    // reject() 방식
                    bindingResult.reject("totalPriceMin",
                            new Object[]{10000, resultPrice},
                            null);
                }
            }
    
            //검증에 실패하면 다시 입력 폼으로
            if (bindingResult.hasErrors()) {
                log.info("errors={}", bindingResult);
                /* bindResult는 view 로 알아서 가지고 간다.*/
                return "validation/v3/addForm";
            }
    
            // 아래는 성공 로직!
            Item savedItem = itemRepository.save(item);
            redirectAttributes.addAttribute("itemId", savedItem.getId());
            redirectAttributes.addAttribute("status", true);
            return "redirect:/validation/v3/items/{itemId}";
        }

     

     

     

     

     

     

    Bean Validation 의 한계

    보통 우리는 등록할 때의 form과 수정할 때의 form이 다르다. 가볍게 만드는 페이지의 경우에는 큰 차이는 없지만,

    대형 서비스의 경우 등록할 때 받은 대부분의 데이터가 수정할 때 가능하지 않은 경우가 많다.

    즉, @ModelAttribute 로 Biding 하는 객체의 property가 다른 경우가 존재한다는 것이다.

     

    이런 경우 2가지 방법이 존재한다.

    • 커맨드객체에 적용된 애노테이션들의 groups옵션을 사용하여 특정 경우에 필요한 검증을 진행한다.
    • command 객체를 나누어 각각에 맞는 객체를 사용한다.

     

     

    groups옵션을 사용방법

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

    과정

    1. 그룹을 나누기 위해 2개의 인터페이스를 준비한다.

     

    2. 공통으로 사용되는 커맨드 객체의 프로퍼티마다 지정된 애노테이션에 groups 옵션을 넣는다.

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

     

    3. 커맨드 객체가 사용되는 핸들러에서 @Validated(value= {} )값을 넣는다.

     

     

    groups 옵션을 사용하면 편리하게 하는 것 같지만, Item 객체와 전반적인 복잡도가 올라간다.
    기능의 유무 정도만 아는 선으로 정리하고, 실무에서는 실질적으로 다음의 방법을 주로 사용한다.

     

     

     

     

    Form 전송 객체 분리 방법

    해당 방법이 실무에서 주로 쓰이는 이유는


    Item의 정보를 가지고 있는 커맨드객체와 실제 Item을 등록을 목적으로 만들어질 커맨드 객체와는 매우 다르기 때문이다.

    현재로선 어느정도 등록 폼과 Item 객체의 프로퍼티가 어느정도 일치하지만, 실무에선 등록을 위한 수많은 데이터를 전송받기 때문이다.

     

    그래서 보통은 다음의 과정을 거치며 데이터를 전송 받는다.

    HTML  등록 form → SaveItemForm → Controller → Item 생성 → Repository

    위와같은 과정으로 아이템 정보를 수정하는 커맨드 객체도 새로 만들어 진행하는 것이 일반적이다.

    경우에 따라선, 각각의 form 전송 객체가 데이터 베이스로부터 정보를 받아와야하는 상황이 생길 수도 있으니,

    각각의 용도에 맞게 커맨드 객체를 생성하여 Form 전송을 하는 것을 권장한다.

     

    과정

    1. 등록하는 폼 객체와 수정하는 폼 객체를 별도로 만든다.

     

        아이탬 등록 폼 객체

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

     

        아이탬 수정 폼 객체

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

     

     

    2. 이제 각각의 폼 객체를 사용하는 핸들러에서 @ModelAttribute 에 등록하는 객체를 변경한다.

     

        아이탬 등록 폼 객체

    @PostMapping("/add")
                                             /* 기본은 itemSaveForm */
        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);
                /* bindResult는 view 로 알아서 가지고 간다.*/
                return "validation/v4/addForm";
            }
    
            /*
             성공 로직!
             현재는 Item 과 ItemSaveForm이 다르다.
             ItemSaveForm -> Item 전환 과정 ✨✨
            */
    
    
            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.getQuantity() != null && form.getPrice() != null) {
                int total = form.getQuantity() * form.getPrice();
                if (total < 10000) {
                    bindingResult.reject("totalPriceMin", new Object[]{10000, total}, null);
                }
            }
    
            // 이제 validation 과정이 마치고 에러가 있엇는지 없었는지 확인하고 페이지를 다시 원래 수정페이지로 값을 가지고 간다
            if (bindingResult.hasErrors()) {
                log.info("error={}", bindingResult);
                return "validation/v4/editForm";
            }
    
    
            /*
            에러코드 로직 후 통과하였다면, 성공로직
            ItemUpdateForm -> Item 전환 과정 ✨✨
            */
            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 객체로 전환하는 과정을 거쳐 등록 / 수정 작업을 진행한다.

     

     

     

    여기까지 Bean Validation 방법에 대해서 다루어보았다.

    'Spring > MVC2-Validation' 카테고리의 다른 글

    Validation  (0) 2023.06.19
Designed by Tistory.