개발놀이터

스프링 Bean Validation 본문

Spring/Spring

스프링 Bean Validation

마늘냄새폴폴 2021. 10. 25. 14:55

본 포스트는 김영한님의 인프런강의 스프링 MVC 2편 - 백엔드 웹 개발 활용 기술을 보고 정리한 포스트입니다. 자세한 내용은 강의를 참고해주세요

*Bean Validation
검증 기능을 전 시간 처럼 매번 코드로 작성하는 것은 상당히 번거롭다. 특히 특정 필드에 대한 검증 로직은 대부분 빈 값이 아닌지, 특정 크기를 넘는지 아닌지와 같이 매우 일반적인 로직이다. 

Bean Validation 시작하기
1. 의존관계 추가 build.gradle에 implementation 'org.springframework.boot:spring-boot-starter-validation' 를 추가한다. 
2. @NotNull, @NotBlank, @NotEmpty등 어노테이션을 필드에 추가해준다. 

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



    @PostMapping("/add")
    public String addItem(@Validated @ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model) {

        //검증에 실패하면 다시 입력 폼으로
        if (bindingResult.hasErrors()) {
            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이 정상 동작하는 것을 확인할 수 있다. 

스프링 MVC는 어떻게 Bean Validation을 사용할까?
스프링 부트가 spring-boot-starter-validation라이브러리를 넣으면 자동으로 Bean Validator를 인지하고 스프링에 통합한다. 

스프링 부트는 자동으로 글로벌 Validator로 등록한다. 
LocalValidatorFactoryBean을 글로벌 Validator로 등록한다. 이 Validator는 @NotNull같은 어노테이션을 보고 검증을 수행한다. 이렇게 글로벌 Validator가 적용되어 있기 때문에, @Valid, @Validated만 적용하면 된다. 검증 오류가 발생하면, FieldError, ObjectError를 생성해서 BindingResult에 담아준다. 

검증 순서
1. @ModelAttribute 각각의 필드에 타입 변환 시도
1-1. 성공하면 2번으로
1-2. 실패하면 typeMismatch로 FieldError추가
2. Validator적용

바인딩에 성공한 필드만 Bean Validation을 적용한다. BeanValidator는 바인딩에 실패한 필드는 BeanValidation을 적용하지 않는다. 생각해보면 타입 변환에 성공해서 바인딩에 성공한 필드여야 BeanValidation 적용이 의미가 있다. (일단 모델 객체에 바인딩 받는 값이 정상으로 들어와야 검증도 의미 있다.)

Ex)
1. itemName에 문자 "A" 입력 => 타입 변환 성공 => itemName필드에 BeanValidation적용
2. price에 문자 "A" 입력 => "A"를 숫자 타입 변환 시도 실패 => typeMismatch FieldError 추가 => price필드에는 BeanValidation적용X


*Bean Validation 에러코드
Bean Validation이 기본으로 제공하는 오류 메시지를 좀 더 자세히 변경하고 싶으면 어떻게 하면 될까?

Bean Validation을 적용하고 bindingResult에 등록된 검증 오류 코드를 보면 오류 코드가 어노테이션 이름으로 등록된다. 마치 typeMismatch와 유사하다. NotBlank라는 오류 코드 기반으로 MessageCodesResolver를 통해 다양한 메시지 코드가 순서대로 생성된다. 
@NotBlank
NotBlank.item.itemName
NotBlank.itemName
NotBlank.java.lang.String
NotBlank

@Range
Range.item.price
Range.price
Range.java.lang.Integer
Range


*Bean Validation 오브젝트 오류 (글로벌 오류)
Bean Validation에서 특정 필드가 아닌 해당 오브젝트 관련 오류는 어떻게 처리할 수 있을까? @ScriptAssert()를 사용하면 된다. 하지만 실제 사용해보면 제약이 많고 복잡하다. 그리고 실무에서는 검증 기능이 해당 객체의 범위를 넘어서는 경우들도 종종 등장하는데 그런 경우 대응이 어렵다. 

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

    @PostMapping("/add")
    public String addItem(@Validated @ModelAttribute("item") ItemSaveForm form, BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model) {
        //특정 필드가 아닌 복합 룰 검증
        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()) {
            return "validation/v4/addForm";
        }

        //성공 로직
        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}";
    }




*Bean Validation의 한계
데이터를 등록할 때와 수정할 때는 요구사항이 다를 수 있다. 
등록시 요구사항
1. 타입 검증
 1-1. 가격, 수량에 문자가 들어가면 검증 오류 처리
2. 필드 검증
 2-1. 상품명 : 필수, 공백X
 2-2. 가격 : 1000원 이상 100만원 이하
 2-3. 수량 : 최대 9999
3. 특정 필드의 범위를 넘어서는 검증
 3-1. 가격 * 수량의 합은 10000원 이상

수정시 추가 요구사항
1. 등록시에는 quantity 수량을 최대 9999까지 등록할 수 있지만 수정시에는 수량을 무제한으로 변경할 수 있게 해줘라
2. 등록시에는 id에 값이 없어도 되지만, 수정시에는 id 값이 필수이다. 

참고)
현재 구조에서는 수정시 item 의 id 값은 항상 들어있도록 로직이 구성되어 있다. 그래서 검증하지 않아도 된다고 생각할 수 있다. 그런데 HTTP 요청은 언제든지 악의적으로 변경해서 요청할 수 있으므로 서버에서 항상 검증해야 한다. 예를 들어서 HTTP 요청을 변경해서 item 의 id 값을 삭제하고 요청할 수도 있다. 따라서 최종 검증은 서버에서 진행하는 것이 안전한다.

이렇게 등록과 수정시에 요구사항이 서로 다른데 같은 객체를 사용하면 충돌이 일어난다. 수정시 id가 필수라고 해서 @NotNull을 넣어버리면 등록때 @NotNull때문에 등록자체가 안될 수 있다. 

이럴 때 사용할 수 있는 방법으로 두가지 정도가 있다. 
1. Bean Validation groups 기능을 사용한다. 
2. Item을 직접 사용하지 않고 ItemSaveForm, ItemUpdateForm같은 폼 전송을 위한 별도의 모델 객체를 만들어서 사용한다. 

groups 기능을 사용해서 등록과 수정시에 각각 다르게 검증할 수 있다. 그런데 groups기능을 사용하면 Item은 물론이고 전반적으로 복잡도가 올라간다. 사실 groups 기능은 실제 잘 사용되지 않는데 그 이유는 실무에서는 주로 다음에 등장하는 등록용 폼 객체와 수정용 폼 객체를 분리해서 사용하기 때문이다. 그렇게 하는 이유는 groups를 사용하면 등록시 폼에서 전달하는 데이터가 Item도메인 객체와 딱 맞지 않기 때문이다. 

@Data
public class ItemSaveForm {

    @NotBlank
    private String itemName;

    @NotNull
    @Range(min = 1000, max = 1000000)
    private Integer price;

    @NotNull
    @Max(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;

    @NotNull
    private Integer quantity;
}



    @PostMapping("/add")
    public String addItem(@Validated @ModelAttribute("item") ItemSaveForm form, BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model) {
        //특정 필드가 아닌 복합 룰 검증                            ↑이렇게 등록시에 요구사항을 담은 폼을 모델로 받는다. 
        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()) {
            return "validation/v4/addForm";
        }

        //성공 로직
        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}";
    }

'Spring > Spring' 카테고리의 다른 글

스프링 예외처리, 오류페이지  (0) 2021.11.11
스프링 필터, 인터셉터  (0) 2021.11.09
스프링 Validation  (0) 2021.10.23
스프링 메시지 / 국제화  (0) 2021.10.18
타임리프심화1  (0) 2021.10.16