Spring MVC : Bean Validation 활용하기

    이 포스팅은 인프런의 김영한님의 강의를 듣고 정리한 내용입니다.

    Spring MVC : Validation

     

    BeanValidation 도입이 필요한 이유

    • 앞의 글에서 Validation을 하는 방법에 대해 확인을 해보았다.
    • 직접 Validation 로직을 짜고, Validator를 등록하는 과정은 조금은 번거로울 수 있다. 
    • Validator의 모든 로직을 내가 직접 만들어야 한다.
    • Validator의 조건문을 직접 작성해야하고, 등록하고, @InitBinder를 또 실행해줘야한다. 

    위의 번거로운 과정이 어려운 로직에 대한 내용이라면 직접 하는 것이 맞다. 그렇지만 앞서 실행했던 검증 과정들은 모두 일반적인 검증 과정이다. 예를 들어 특정 크기가 너무 크거나 작진 않은지, 값이 없지는 않은지에 대한 일반적인 내용이다. 따라서 이런 일반적인 검증 과정까지 개발자가 모두 로직을 짜기 보다는 공통화된 방법으로 처리하면 좋을 것 같다.

    그런 생각에서 일반화되고 편리한 방법이 BeanValidation이다. 

     

    Bean Validation이란? 

    Bean Validation은 특정한 구현체가 아니라 Bean Validation 2.0이라는 기술 표준이다. 쉽게 이야기해서 검증 어노테이션 + 여러 인터페이스의 모음이다. Bean Validation을 구현한 기술 중 일반적으로 사용하는 구현체는 Hibernate Validator이다. 앞으로 이 포스팅에서는 hibernate Validator를 사용해서, 일반적인 Validation을 간소화하고자 한다. 

     

    Bean Validation을 설정 셋팅

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

    Bean Validation을 사용하기 위해서는 위 의존 관계가 추가되어야 한다. 위 의존 관계를 build.gradle에 추가해준다.

     

     

     

    스프링 MVC는 어떻게 Bean Validator를 사용하는가?

    스프링 부트는 'spring-boot-starter-validation' 라이브러리를 build.gradle에 의존관계를 설정해주면 자동으로 Bean Validator를 인지하고 스프링에 통합해준다.

     

    스프링 부트는 자동으로 글로벌 Validator를 등록한다.

    • localValidatorFactoryBean을 글로벌 Validator로 등록한다. 
    • 이 Validator는 @Notnull, @Range 같은 어노테이션을 바라보고 검증을 수행한다. 
    • 이렇게 글로벌 Validator가 이미 설정되어있기 때문에 검증하고자 하는 변수 앞에 @Validated, @Valid만 붙이면 된다.
    • 검증 오류가 발생하면 BindingResult에 Field Error, Object Error 등을 담아준다. 
    • 주의 : 다른 글로벌 Validator가 등록되어있다면, Bean Validator는 동작하지 않는다. 

     

     

    Bean Validator의 검증 순서

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

    Binding이 성공한 필드에만 Bean Validation이 적용된다.

    BeanValidator는 Binding이 성공한 필드에만 Bean Validation을 적용한다. 당연한건데, 제대로 된 타입의 값이 들어오지 않은 필드라면 정상값인지 더 검증할 필요가 없다! 

    예시) 

    itemName에 문자 A가 입력 → 타입 변환 성공 → itemName Bean Validation 적용

    price에 문자 A가 입력 → 타입 변환 시도 실패 → typeMismatch FieldError 추가됨. → price 필드는 bean Validatino 적용 X 

     

     

    Bean Validation 어노테이션 적용해서 맛보기

    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;
     }
    1. 먼저 검증을 하고자 하는 객체로 가서 필드에 Bean Validation과 관련된 어노테이션을 달아준다. 
    2. 객체에서는 더 할 것이 없다. 그리고 놀라운 것은 앞으로 더 할 것이 별로 없다는 것이다! 

    주로 사용하는 Hibernate Validator 공식 사이트는 아래 링크로 들어가면 된다. 

    The Bean Validation reference implementation. - Hibernate Validator

     

     

    Bean Validation의 테스트 코드 작성하기

    먼저 Bean Validation은 앞서 이야기 했듯이, 스프링이 글로벌 빈으로 Validator를 등록해주고 개발자들이 이걸 사용하는 형태로 되어있다. 따라서 테스트 코드에서 사용할 때는 직접 등록하고 사용을 해야하는 번거로움이 있다. 

    ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
    Validator validator = factory.getValidator();

    위 코드를 통해서 스프링이 자동으로 등록해주는 Validator를 가져와서 사용할 수 있다. 전체 테스트 코드는 아래와 같다. 

    @Test
    void beanValidationTest() {
    
        ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
        Validator validator = factory.getValidator();
    
    
        Item item = new Item();
        item.setItemName(" ");
        item.setPrice(0);
        item.setQuantity(10000);
    
    
        Set<ConstraintViolation<Item>> violations = validator.validate(item);
        for (ConstraintViolation<Item> violation : violations) {
            System.out.println("violation = " + violation);
            System.out.println("violation.getMessage() = " + violation.getMessage());
        }
    }
    • 스프링이 등록해주는 Validator는 Validate 후 반환값으로 Set<ConstriaintViolation>이라는 연속된 값을 반환해준다.
    • 이 ConstratintViolation은 검증 오류가 있으면 그곳에 담아준다. BindingResult의 Errors와 비슷하다고 볼 수 있다. 
    • 위의 테스트 코드를 실행해주면, 어떤 오류가 발생하는지를 볼 수 있다. 

    테스트 코드 실행 결과는 위와 같다. 아직까지 감은 잡을 수 없지만, 에러 메세지를 보면 앞서 공부했었던 RejectValue, Reject와 거의 유사한 형태로 동작하는 것을 유추해볼 수 있다. 

     

    Bean Validation 스프링에 직접 적용하기 

    @PostMapping("/add")
    public String addItem(@Validated @ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model) {
    
        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}";
    }
    • 앞서 Item 클래스에 @NotBlank, @Range 등의 어노테이션을 달아주었다면 이미 대부분의 준비가 끝났다.
    • BindingResult의 RejectValue + 조건문으로 RejectValue를 설정하는 것까지 끝났고, 이미 스프링은 시작할 때 글로벌 Validator로 자동 등록해준다.
    • Validator를 사용하기만 하면 되는데, 검증하고자 하는 매개변수 앞에 @Validate, @Valid 어노테이션을 붙여주기만 하면 끝이다. 이렇게 되면 WebDataBinder가 파라메터를 묶을 때, 값까지 함께 검증해준다. 

     

    오류 동작 확인 여부

    위의 코드를 실행했을 때, 전체 오류 값을 넣어보았다. 넣은 결과 화면에서 위와 같이 표현이 되었고, 콘솔 창에서도 기존에 우리가 알던 형태로 Field Error가 발생하는 것을 알게 되었다. 

    그런데 위의 콘솔을 보면 한 가지 알 수 있는 것이 있다. 먼저 에러 메세지는 기본적으로 우리가 설정한 것과는 다르게 나오는 것을 알 수 있다. 그리고 콘솔에서 보여지는 에러 메세지 코드는 어딘가 익숙한 형태로 보인다. 정리해보면 이렇다

    • NotBlank.item.itemName
    • NotBlank.itemName
    • NotBlank..
    • NotBlank

    이런 메세지 형태는 우리가 잘 아는 형태다. 바로 RejectValue에서 사용했던 MessageCodesResolver가 동작한다는 것을 볼 수 있다! 

     

     

    Bean Validation - 에러 코드 설정

    앞서 코드의 실행결과에서 확인할 수 있듯이 Bean Validation의 에러 코드는 메시지 기능을 사용하고, 이 때 MessageCodesResolver가 동작한다는 것을 알 수 있다. 대표적인 것을 하나만 정리해보면 아래와 같다

    @NotBlank

    • NotBlank.item.itemName
    • NotBlank.itemName
    • NotBlank.java.lang.String
    • NotBlank

    그렇다면 우리는 errrors.properties에 있는 위 변수들에 대한 메세지만 추가해주면 된다는 것으로 이해를 할 수 있다. 

    Bean Validation의 에러코드는 조금 다르다! → Arguments

    그렇지만 Bean Validation의 에러코드는 조금 다른 점이 있다. RejectValue, Reject에서는 Codes와 Arguments를 함게 전달했다. 그래서 Arguments를 내가 원하는 값으로 지정해서 사용할 수 있었다. 그렇지만 Bean Validation에서는 조금 다르다. Bean Validation에서는 Arguments가 나타내는 값들이 이미 다 정해져있고, 그건 각 어노테이션마다 다르기 때문에 메뉴얼을 보고 수정이 필요하다. 

    NotBlank = {0} 공백 X
    Range = {0}, {2} ~ {1}만 허용
    Max = {0}, 최대{1}
    Notnull = {0}, 값을 넣어주세요
    • {0} : 이것은 기본적으로 필드명을 이야기한다.
    • 이후에 나오는 {1}, {2}들은 해당 어노테이션에 달려있는 값들에 의존한다. 
    • @Max에서 {1}은 최대값을 의미한다
    • @Range에서 {2}는 min 옵션을, {1}는 max 옵션을 의미한다. 

    여튼 위 메세지를 errors 코드에 넣고 실행해보자!

    실행해보면 다음과 같이 에러 메세지가 나오는 것을 확인할 수 있다.

    • {0}은 필드를 가리킨다고 했으니 itemName, price, Quantity 등이 들어간다.
    • 이후에는 하드코딩했던 값들이 들어간다. 

     

    Bean Validation - 에러 메세지의 사용 순서

    @NotBlank(message = "공백일 수 없습니다.아아아아")
    private String itemName;

    Bean Validation 기능을 적용할 때, 어노테이션의 message 옵션에 간단히 필요한 에러 메세지를 적용할 수가 있다. 이런 경우 에러 메세지 출력 순서는 어떻게 되는 것일까? 

    에러 메세지의 출력 순서는 다음과 같다.

    Level1 → Level2 → Level3 → Level4 → meesage 옵션 메세지 → 스프링빈 default 메세지

    앞에서 공부했던 RejectValue, Reject와 거의 유사하게 동작하는 것을 알 수 있다. 

     

     

    Bean Validation의 Object 오류 → Local 검증기가 추천됨.

    Bean Validation의 Object 오류는 객체 단위에서 처리할 수가 있긴 하다. 바로 @ScriptAssert()라는 것을 사용해주면 된다. 그렇지만 이 방법은 권장되지 않는다. 왜냐하면 ObjectError는 기본적으로 Field Error가 아니라 Global Error를 의미한다. 따라서 현재 Item 객체가 Binding 될 때 가지는 값만으로는 모든 것을 표현할 수 없을 수가 있다.

     

    예를 들어 필요한 값을 다른 객체에서 가져오고, 혹은 DB에서 값을 가져와서 검증할 수도 있다. 이럴 경우 @ScriptAssert()의 기능은 아주 명백하게 한계가 있는 것을 알 수 있다. 

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

    사용은 위와 같이 자바 스크립트의 언어를 빌어와서 사용한다. 그런데 추천하지 않는다. 그렇다면 GlobalError는 어떻게 검증해야하는 것일까? 

     

    Bean Validation 사용 시, Global Error 해결 전략

    앞서 Class에서 @ScriptAssert 같은 것들로 Global Error를 검증할 수 있다고 했다. 그렇지만 실제로는 추천하지 않는다고 했다. 이유는 GlobalError는 그 객체 뿐만 아니라 다른 여러 곳에서 값을 가져올 수 있기 때문이다. 따라서 확장성을 고려한다면 이 때는 앞에서 배운 수동으로 Validation을 등록해주는 것이 권장된다.

    @PostMapping("/add")
    public String addItemV2(@Validated @ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model) {
    
        if (item.getQuantity() != null && item.getPrice() != null){
            int resultPrice = item.getQuantity() * item.getPrice();
            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}";
    }

    위처럼 GlobalError만 Controller의 성공 로직에 넣는 방법으로 접근할 수 있다. 이것은 앞의 Validation 글에서 봤던 것과 똑같은 형태이다. 

     

    Bean Validation 사용 시, Global Error 해결 전략 → WebDataBinder를 사용하기

    앞서 배웠던 것처럼 WebDataBinder를 이용해서 Validator를 등록해서 사용하는 방법도 있다. 이 경우, Global Error에 대한 판별 로직이 많을 경우에 유용하게 사용될 것이다. 

    @Component
    public class ItemValidatorV2 implements Validator {
    
    
        @Override
        public boolean supports(Class<?> clazz) {
            return Item.class.isAssignableFrom(clazz);
        }
    
        @Override
        public void validate(Object target, Errors errors) {
    
            BindingResult bindingResult = (BindingResult) errors;
            Item item = (Item) target;
    
    
            System.out.println("ItemValidatorV2.validate");
            if (item.getQuantity() != null && item.getPrice() != null){
                int resultPrice = item.getQuantity() * item.getPrice();
                if(resultPrice < 10000){
                    bindingResult.reject("totalPriceMin", new Object[]{10000, resultPrice}, "잘못된 값입니다.");
    
                }
            }
    
        }
    }

    위의 코드는 Item의 글로벌 오류를 검증하기 위한 ItemValidator 클래스이다.

    public class ValidationItemControllerV3 {
    
        private final ItemValidatorV2 itemValidatorV2;
    
    
        @InitBinder
        public void init_(WebDataBinder dataBinder) {
            dataBinder.addValidators(itemValidatorV2);
    
        }
    
    	@PostMapping("/add")
        public String addItemV3(@Validated @ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model) {
    
            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}";
        }
    
    }
    • 위의 코드는 @InitBinder + WebDataBinder를 활용해서 해당 컨트롤러의 로컬 Validator를 등록한다.
    • 그리고 검증이 필요한 핸들러가 불러질 때 마다 @Validated를 통해서 값을 검증해준다. 

     

    Bean Validation의 한계

    Bean Validation의 한계를 살펴보기 위해서 다음과 같은 요구 조건이 들어왔다고 가정을 해보자.

    등록 시 요구 사항 (addForm)

    • 타입 검증 
      • 가격, 수량에 문자가 들어가면 검증 오류 처리
    • 필드 검증
      • 상품명 : 필수, 공백X
      • 가격 : 1000원 이상, 1백만원 이하
      • 수량 : 최대 9999
    • 특정 필드의 범위를 넘어서는 검증
      • 가격 * 수량의 합은 10,000원 이상

    수정 시 요구 사항 (EditForm)

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

     

    위의 요구 조건을 한 마디로 정의해보자. 등록할 때와 수정할 때의 요구하는 검증 값이 다르다는 것이다. 이럴 경우에는 딱봐도 약간 곤란할 것이다. 우선은 수정 시, 요구 사항을 등록하기 위해서 한번 Item 클래스를 변경해보고자 한다.

    Item Class 변경 : 수정 시 요구사항 반영

    public class Item {
    
    	@NotNull // 수정 요구사항 반영
        private Long id;
    
        @NotBlank(message = "공백일 수 없습니다.아아아아")
        private String itemName;
    
        @NotNull//(message = "빈값일 수 없습니다 아아아")
        @Range(min = 1000, max = 1000000, message = "초과할 수 없습니다 아아아아")
        private Integer price;
    
        @NotNull
    //    @Max(9999) // 수정 요구사항 반영
        private Integer quantity;

    수정 시 요구 사항을 반영해서 Item 객체의 어노테이션을 조절했다.

    그런데 위처럼 클래스를 수정할 경우 문제가 될 수 밖에 없다. 왜냐하면 현재 Item 클래스에 대한 각종 어노테이션이 Global Validator로 등작해서 모든 컨트롤러가 요청하면 동일하게 작용하기 때문이다. 즉, 분리가 되어있지 않다. 따라서 위의 코드로 검증을 실행할 경우 아래와 같은 상황이 나타난다.

     

    위의 이미지를 보면 바로 알 수 있다. 상품 등록은 최대 9999개까지만 등록 가능한 것이 우리의 처음 값 검증이었다. 그렇지만 상품 수정의 요구 사항을 만족하기 위해 어노테이션을 수정할 수 밖에 없었다. 그 결과 Edit에는 원하는 형식으로 수량이 등록되지만, 상품 등록에서는 초기 요구사항을 만족하지 않는다. 그렇다면 어떻게 해야할까?

     

    Bean Validation의 한계 극복

    Bean Validation의 한계는 동일 객체가 어떤 컨트롤러에서 호출되는지에 대해서 검증 조건을 각각 사용할 수 없다는 점에 있다. 이런 한계를 극복하는데는 두 가지 방법이 있다. 

    • 어노테이션의 'groups' 옵션 사용하기
    • 특정 객체를 사용하지 않고, 검증을 위한 Form 도입해서 분리해서 사용하기.

     

    Bean Validation - Groups 사용하기

    • Groups는 말그대로 우리가 검증에 필요한 것을 그룹을 나눠서 보겠다는 의미이다. 
    • 이 기능은 @NotNull, @Range 같은 어노테이션에 Groups 옵션에서 적용할 수 있다.
    • Groups 옵션을 적용하기 위해서는 각 그룹을 나눌 객체, Interface 등이 필요하다. 객체를 사용할 것이 아니기 때문에  Interface를 추천한다.
    • Group을 만들고 Groups 옵션에 설정해준다. 
    • @Validated(그룹명.class)를 넣어준다. 
    // 그룹 분리를 위한 인터페이스 도입 
    public interface SaveCheck {
    }
    public interface UpdateCheck {
    }

    먼저 그룹을 분리해서 검증하겠다는 의미로 두 개의 클래스를 만들었다. 

    @Data
    public class Item {
    
    	@NotNull(groups = UpdateCheck.class)
        private Long id;
        
        @NotNull(groups = {UpdateCheck.class, SaveCheck.class})
        @Max(value = 9999, groups = SaveCheck.class)
        private Integer quantity;
    
    
    }
    • ItemClass에는 다음과 같이 각 어노테이션 안에 Groups 옵션을 표기해주었다.
    • 여러 컨트롤러에 적용하고자 하면, Groups = {A, B, C, D} 형태로 적용이 가능하다.
    • 각 어노테이션은 Groups에 표기된 값이 있는 컨트롤러(Validated에 표기됨)만 Validation한다.
      • 이 때, Groups 옵션을 사용하지 않으면 모든 컨트롤러에 적용된다.
    @PostMapping("/add")
        public String addItemV3(@Validated(SaveCheck.class) @ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model) {
         ...
        }
    
    @PostMapping("/{itemId}/edit")
    public String editV2(@PathVariable Long itemId, @Validated(UpdateCheck.class) @ModelAttribute Item item, BindingResult bindingResult) {
    	...
    }
    • 다음과 같이 @Validated 어노테이션을 사용하고, 값에 평가될 그룹을 넣어준다.
    • @Validated에 들어온 그룹, 그리고 Item의 어노테이션에 있는 Groups에 기입된 값이 똑같은 것들에 대해서만 Validation이 일어난다. 

     

    위처럼 코드를 수정하게 되면 다음과 같이 필요한 경계조건이 필요한 컨트롤러에만 적용된 것을 볼 수 있다. 앞서 있었던 문제는 Validation이 필요한 그룹이 분리되지 않았었고, 이 Validation을 Interface를 하나 도입해주고 이 Interface를 구분자로 삼아서 @NotBlank의 Groups와 @Validated(그룹)의 이름을 매칭시켜서 Validation을 실행해주면서 해결했다.

    그렇지만 최선의 방법은 아니다. 왜냐하면 Groups를 넣으면서 기본적으로 코드가 좀 더 복잡해지게 되었다. 그리고 가장 큰 문제는 각 검증 폼에 맞는 형태의 딱 맞는 값이 아니라는 것이다. 예를 들어 어떤 검증 클래스에스는 수량이 항상 필요한 값이지만, 어떤 검증 클래스에서는 수량의 값이 필요 없을 수도 있다. 즉, 서로가 필요한 옷은 다른데 엑스라지를 사서 함께 공유해서 입고 있는 꼴이다. 이걸 개선하기 위한 방법이 있다. 

     

    FORM 전송 객체 분리

    실무에서는 Groups 기능을 많이 사용하지 않는다고 한다. 등록 시, HTML FORM으로 전달하는 데이터가 Item 도메인 객체와 딱 맞지 않기 때문이다. 

    • HTML FORM 전달 데이터 : itemName, Price, Quantity
    • Item 객체의 데이터 : id, itemName, Price, Quantity

    Hello World!같은 수준에서는 맞을 수도 있지만, 실무에서는 다르다. 예를 들어 실무에서 넘겨주는 데이터는 회원 등록 시 회원과 관련된 데이터만 전달받는 것이 아니라, 약관 정보도 추가로 받는 등 Item과 관계 없는 수 많은 부가 데이터가 넘어온다. 

    그렇기 때문에 Item 객체와 Item 등록을 할 때 필요한 Validation용 객체를 분리해주는 것이 좋다. 예를 들어 ItemSaveForm이라는 폼을 전달받는 전용 객체를 만들어서 @ModelAttribute로 사용한다. 이것을 통해 컨트롤러에서 Form 데이터를 전달 받고, 이후 컨트롤러에서 필요한 데이터를 사용해서 Item을 생성해주고 저장해주는 형태로 접근할 수 있다! 

    실제 방법을 비교해보면 다음과 같다. 

     

     

    폼 데이터 전달에 Item 도메인 객체 사용

    HTML Form → Item(Binding) → Controller → Item → Repository

    • 장점 : item 도메인 객체를 컨트롤러, 리포지토리까지 직접 전달한다. 중간에 Item을 만드는 과정이 없어 간단하다
    • 단점 : 간단한 경우에만 적용할 수 있다. Edit Form 등에서 검증이 중복될 수 있고, 이 때 Groups를 사용해주어야 한다. 

     

    폼 데이터 전달을 위한 별도의 객체 사용

    HTML Form → ItemSaveForm(Binding) → Controller → Item 생성 → Repository

    • 장점 : 전송하는 폼 데이터가 복잡해도 거기에 맞춘 별도의 폼 객체를 사용해서 데이터를 전달 받을 수 있다. 또한 검증을 분리해서 사용할 수 있다.
    • 단점 : Form 데이터를 기반으로 컨트롤러에서 Item 객체를 생성하는 변환 과정이 추가된다. 

     

    수정을 할 때와 등록을 할 때를 비교해보자. 예를 들어 로그인할 때는 로그인 ID + PASSWORD가 넘어올텐데, 수정 시에는 굳이 이런 값들이 필요가 없다. 이렇게 값이 필요없어지게 되면서 Validation 로직도 만들어진다. 따라서, ItemSaveForm이 모든 것을 하지 않고 역할을 분리해줄 필요가 있다.

    이렇게 데이터 전달을 위한 Form을 별도로 만들어서 별도의 객체를 사용하게 되면, 등록과 수정이 완전히 분리되기 때문에 Groups를 적용할 일이 거의 없어진다. 

     

    HTML Form → ItemSaveForm(Binding) → Controller → View Templated(Item이란 이름의 ItemSaveForm)

    Repository로는 Item이 전달되지만, View Template으로는 ItemSaveForm이 전달되어야 한다. 그런데 최대한 코드를 유지하기 위해서는 @ModelAttribute(name = "item")을 사용해야한다. 이러면 비록 ItemSaveForm으로 되어있지만, 이 바인딩 된 값이 item이라는 이름으로 View Template에 전달되게 된다. 

     

    Form 전달 분리를 통한 코드 리팩토링

    ItemSaveForm 클래스 개발

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

    ItemUpdateForm 클래스 개발

    @Data
    public class ItemUpdateForm {
    
        @NotNull
        private Long id;
    
        @NotBlank
        private String itemName;
    
        @NotNull
        @Range(min = 1000, max = 1000000)
        private Integer price;
    
        @NotNull
        private Integer quantity;
    
    
    }
    

    AddForm, EditForm 컨트롤러 변경

    public String addItem(@Validated @ModelAttribute(name = "item") ItemSaveForm form, BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model) 
    public String edit(@PathVariable Long itemId, @Validated @ModelAttribute(name = "item") ItemUpdateForm form, BindingResult bindingResult)
    • AddForm, EditForm은 기존의 컨트롤러에서 매개변수를 다음과 같이 받도록 변경이 필요하다.
    • 앞서 이야기한 것처럼 기존의 View Template에는 "item"이라는 이름으로 BindingResult 및 Model의 값이 전달되어야한다. 
    • 따라서 @ModelAttribute(name = "item")을 사용해서 Form 형태의 값을 "item"이라는 이름으로 바인딩한 후 전달한다. 

    Item 객체 생성 필요

    Item item = new Item();
    item.setItemName(form.getItemName());
    item.setPrice(form.getPrice());
    item.setQuantity(form.getQuantity());
    • 컨트롤러에는 Item 객체가 하나 생성되어야한다.
    • 왜냐하면 save 메서드에 필요한 것은 Item이고, 우리는 ItemSaveForm만 가지고 있기 때문이다.
    • 따라서 Item 객체를 하나 생성해서 저장해주면 된다. 

     

    HTTP Message Converter, @RequestBody Validation

    우리가 앞서 등록했던 Global Validator를 @RequestBody에도 동일하게 사용할 수 있다. 단, 세 가지 상황으로 나눠서 볼 수 있다.

    • 성공 요청 : 성공 → 컨트롤러 정상 호출 성공
    • 실패 요청 : JSON을 객체로 생성하는 것 자체가 실패함 → 컨트롤러 호출 실패
    • 검증 오류 요청 : JSON을 객체로 생성 완료. 검증에서 실패  → 컨트롤러 호출 성공

     

    JSON 객체가 생성되지 않는 경우는 아래 두 가지로 나눌 수 있다.

    • Null 값이 들어올 때 
    • 다른 타입이 들어올 때

    위에서 이야기한 것처럼 JSON 객체가 어떤 이유로 생성되지 않으면, 컨트롤러 자체가 호출되지 않는다. 그리고 Validator도 호출되지 않는다.

    HTTP API 컨트롤러 코드 

    @Slf4j
    @RestController
    @RequestMapping("/validation/api/items")
    public class ValidationItemApiController {
    
        // itemSaveForm을 JSON 방식으로 받는다.
        @PostMapping("/add")
        public Object addItem(@RequestBody @Validated ItemSaveForm form, BindingResult bindingResult) {
            log.info("API 컨트롤러 호출");
    
            if (bindingResult.hasErrors()) {
                log.info("검증 오류 발생 errors = {}", bindingResult);
                return bindingResult.getAllErrors(); // object Error + Field Error를 list로 반환. 그리고 그걸 JSON으로 반홚
            }
    
            log.info("성공 로직 실행");
            return form;
        }
    }
    

     

    JSON 객체 바인딩 실패할 경우

    위처럼 잘못 입력된 값을 넣을 경우, Binding이 이루어지지 않고 JSON 객체 생성에 실패한다. 이후 Controller까지 넘어가지 못하고, 사용자에게 Bad Request를 보내주게 된다. 

     

    JSON 객체 바인딩 성공했으나, 검증 오류 발생 시

    • POST MAN으로 값을 던진 후, 검증 오류 발생하면 다음과 같은 에러가 돌아온다.
    • 실제로는 더 많은 메세지가 있으나 아래 내용만 확인한다. 

     

     

     

    @ModelAttribute vs @RequestBody

    @RequestBody는 JSON 객체 생성 실패 시, 컨트롤러 X

    @ModelAttribute는 사실 @RequestParam을 한꺼번에 처리해주는 것과 동일하다. 따라서 필드 단위로 세밀하게 적용된다. 그래서 특정 필드 타입에 null 값이 들어오거나, 타입이 맞지 않더라도 나머지 필드는 정상으로 처리할 수 있다.

    반면 @RequestBody에 사용되는 HttpMessageConverter는 전체 객체 단위로 적용된다. 따라서 메세지 컨버터터의 동작이 성공해서 Item 객체를 만들어야 Validator가 적용된다! 

    댓글

    Designed by JB FACTORY