Spring MVC - Bean Validation

2022. 3. 29. 15:19
반응형

스프링의 검증 기능을 Validation 부분에서 살펴보았는데, 일일이 코드로 작성해야 한다는 점이 매우 불편하다. 개발자는 게으르다는 소리가 여기서 나오지 않나 싶다..

 

 스프링부트는 전에 일일이 작성했던 Validation 부분을 통합하여 관리해서 제공해주는데, 처음에 검증 코드를 일일이 작성할때와 비교해보면 스프링부트가 제공해주는 Bean Validation을 사용할 때 뭔가 혁신적인 느낌이 들었다. 무엇인지 바로 알아보자.

 

다음은 내가 예전에 일일이 작성했던 검증 부분과 스프링 부트가 제공해주는 애노테이션 기반 검증 부분의 코드이다.

if(item.getPrice() == null || item.getPrice() < 1000 || item.getPrice() > 1000000){
      bindingResult.rejectValue("price", "range", new Object[]{1000, 1000000}, null);
}
@NotNull
@Range(min = 1000, max = 1000000)
private Integer price;

애노테이션 하나를 검증 객체의 필드에다가 넣기만 하면 마법처럼 위의 복잡한 코드가 동일하게 동작한다. 또한 디폴트 메시지를 작성할 수 있는 속성도 존재한다. 하지만 수고롭게도 애노테이션 기반 Bean Validation 을 사용하려면 다음과 같이 build.gradle에 의존관계를 추가해줘야 한다.

 

build.gradle

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

그렇다면 스프링부트는 어떻게 Bean Validator를 사용할까? 스프링부트는 build.gradle에 위 의존관계를 추가하기만 하면 자동으로 Bean Validator를 인지하고 스프링에 통합한다.

 

 또한, 스프링부트는 자동으로 글로벌 Validator로 등록한다. LocalValidatorFactoryBean을 글로벌 Validator로 등록한다고 한다. 이 Validator는 @NotNull, @NotBlank 같은 애노테이션을 인식하여 동작한다. 여기서 주의할점은 Validator이기 때문에 컨트롤러에 붙어있는 검증객체 앞에 @Valid, @Validated는 필수여야 한다는 점이다. 이렇게 검증을 하고, 검증 오류가 발생하면 FieldError, ObjectError 객체를 생성하여 BindingResult에 담아주게 된다.

 

 주의할점이 하나 더있다! 만약에 Validator 인터페이스를 직접 구현하여 글로벌 검증기로 커스텀하게 설정을 해 둔다면 Bean Validator가 동작하지 않는다는 점이다. 이 점은 꼭 신경쓰도록 하자!

 

Bean Validator 검증 및 검증 순서

검증을 할 때 보통 객체를 검증하게 되는데, 이 때 @ModelAttribute나 @RequestBody로 데이터를 받아서 바인딩을 하여 검증을 하게된다. 여기서 둘의 검증 순서의 차이점이 존재한다. @ModelAttribute로 각각의 필드 값이 넘어와서 바인딩이 수행되면 바인딩이 실패한 필드는 검증을 하지않고 종료되고, 바인딩이 성공한 필드는 검증을 수행하게 된다. 이와 달리 @RequestBody로 넘어온 데이터, 즉 HTTP message body로 데이터가 넘어와서 객체로 바인딩이 수행되면 하나의 필드 값이라도 바인딩이 실패하면 모두 검증까지도 못가서 오류가 나서 종료된다. 

 

 이유는 어찌보면 당연하다 @RequestBody에 데이터가 담기고 바인딩이 될 때, HttpMessageConverter가 해당 데이터를 읽어서 객체에 저장할 수 있는지 판단하기 때문이다. 반면에 @ModelAttribute는 넘어온 데이터 중 존재하는 값만 필드에 넣어주기 때문에 가능할 것이다.

Bean Validation - 에러 코드

Bean Validation을 필드에 적용하면 어떤 에러 메시지코드가 생성될까? 스프링은 데이터 타입 검증 오류 메시지코드를 자동적으로 생성해주는데, 이 메시지코드 생성 전략이랑 거의 동일하게 메시지코드를 찾는다. 만약 Item 객체에 name이라는 필드에 @NotBlank 애노테이션을 달았다고 하면 스프링이 생성해주는 메시지코드는 다음과 같이 구체적인것을 우선적으로 생성하여 찾는다.

  • NotBlank.item.name
  • NotBlank.name
  • NotBlank.java.lang.String
  • NotBlank

에러 메시지 코드를 찾는 순서는 다음과 같다.

  1. 생성된 메시지코드를 순서대로 messageSource 에서 메시지를 찾는다.
  2. 애노테이션 속성에서 message 작성하였다면 사용한다.
  3. 라이브러리가 제공하는 기본 값을 사용한다. -> 공백일 수 없습니다.

Bean Validation - Object Error

필드 오류에는 이렇게 애노테이션을 달아서 처리할 수 있다면 오브젝트 오류에서는 처리를 할 수 없을까? 물론 할 수 있다. 오브젝트 에러를 처리하기 위한 방법에는 여러가지가 존재하는데 그중 하나는 클래스 필드에 @ScriptAssert 애노테이션을 적용하는 것이다.

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

얼핏 보기에는 컨트롤러 안에다가 자바 코드로 작성하는 것 보다 코드가 더 효율적이고 깨끗해 보일 수 있겠지만, 조건 자체를 문자열로 작성하기 때문에 컴파일 시점에 오류를 찾을 수 없어서 오타가 한번이라도 나면 개고생을 할 수 있을 것이다. 따라서 이 방식은 별로 권장하지 않고 깨끗해 보이지 않더라도 컴파일 시점에 오류를 찾을 수 있는 자바 코드로 오브젝트 오류 검증을 처리하는 것이 낫다.

 

Bean Validation - 여러개의 요구사항

만약 어떤 쇼핑몰에서 관리자가 상품을 등록할 때의 요구사항과 상품을 수정할 때의 요구사항이 다르다고 가정해보자. 상품을 등록할 때에는 1000개 이상을 등록할 수 없고, 상품을 수정할 때는 무제한으로 등록할 수 있다. 이 때, 같은 Item 객체에 Bean Validation을 적용하면 상품을 등록할 때와 수정할 때 같은 검증기가 돌아가게 된다. 이 문제점을 해결할 수 없을까?

 

이 문제점을 해결하기 위해서 다음과 같이 두가지 방법을 쓸 수 있다.

  1. Bean Validation 애노테이션에 속성 group을 분리해서 적용
  2. 등록 폼과 수정 폼을 분리해서 Bean Validation을 적용

1번 방식 적용

우선 첫번째 방식을 사용한 방식을 알아보기 위해서 SaveCheck, UpdateCheck 인터페이스를 생성한다. 단순히 구분하기 위한 인터페이스 이므로 생성만 하면 된다.

public interface SaveCheck {}
public interface UpdateCheck {}

이렇게 구분을 위한 인터페이스를 생성하고 검증하기를 원하는 애노테이션에 적용하면 된다.

@NotNull(groups = {SaveCheck.class, UpdateCheck.class})
@Max(value = 1000, groups = {SaveCheck.class})
private Integer quantity;

이렇게 group 속성으로 검증기를 적용할 인터페이스를 넣어주면 된다. 이제 마지막으로 검증할 객체 앞에다가 @Validated 애노테이션을 넣어주면 서로 다른 검증을 적용할 수 있다. 이 때, @Valid는 group 속성이 존재하지 않기 때문에 스프링에서 제공하는 @Validated를 사용해야 한다.

public String edit(@PathVariable Long itemId, @Validated(UpdateCheck.class) @ModelAttribute Item item, BindingResult bindingResult) {}

첫번째 방식은 코드가 간결해진 것 같지만 사실은 코드 복잡도가 올라갔다. 그래서 개발자들은 첫번째 방식을 선호하지 않는다. 복잡한 것은 유지보수할 때 최악의 상황으로 갈 수 있기 때문이다. 사실 첫번째 방식을 사용하지 않는 이유는 따로 있다. 이는 두번째 방식을 설명하면서 말해보겠다.

2번 방식 적용

보통 실무에서는 2번방식을 적용해서 개발한다고 한다. 이 방식은 상품등록 폼과 상품수정 폼을 따로 분리해서 사용하는 방식인데, 이렇게 분리하여 사용하는 방식을 선호하는 이유는 각각의 기능마다 요구사항이 다르기 때문에 분리가 필요하다. 예를들면 상품 등록을 할 때 약관정보를 추가로 받는 것 등 Item 객체와 상관없는 데이터가 넘어올 수 있는 상황이 존재하기 때문이다. 즉, 항상 도메인 객체와 넘어오는 데이터들이 딱 들어맞지 않는다는 것이다.

 

 2번방식의 단점은 개발자들이 폼에서 실제 Item 도메인 객체로 변환하는 과정을 한번 더 수행해야 한다는 것이다. 하지만 이 방식은 개발자들의 수고로움이 더 들어가는 것이지 다른 방면에서의 단점은 거의 없다고 볼 수 있다. 

 

 다음은 상품 등록폼과 상품 수정폼을 분리하여 작성한 소스코드이다.

@Data
public class ItemSaveForm {

    @NotBlank
    private String itemName;

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

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

 아래는 상품등록 컨트롤러 로직이다. 중간에 상품 등록폼에서 실제 도메인 객체로 변환하는 과정이 있는 것을 확인할 수 있다.

    @PostMapping("/add")
    public String addItem(@Validated @ModelAttribute("item") ItemSaveForm form, BindingResult bindingResult, RedirectAttributes redirectAttributes) {

        //가격 * 수량의 합이 10000 이상이 안돼는 경우
        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);
            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}";
    }

 

반응형

BELATED ARTICLES

more