자바 스프링/스프링MVC

검증 - BeanValidation

열동 2023. 3. 3. 17:31

다음과 같은 요구사항이 있는 상황에서 컨트롤러를 설계한다고 가정하자.

 

Item도메인

  1. id: Item마다 가지고 있는 고유의 값으로, 상품을 등록하면 서버에서 생성해준다. id로 상품을 구분하기 때문에 상품 수정 시 반드시 필요하다.
  2. ItemName: 상품의 이름으로 null값과 공백을 허용하지 않는다.
  3. price: 상품의 가격으로 null값과 공백을 허용하지 않으며, 범위는 1,000~1,000,000까지 허용된다.
  4. quantity: 상품의 수량으로 null값과 공백을 허용하지 않으며, 범위는 최대 9999까지 허용된다. 단, 한번 등록 후 수정 시에는 수량의 제한은 없다.
  5. 수량*가격은 10000보다 커야한다.

서버측에서는 사용자가 위 조건에 만족하는 값을 전달했는지 반드시 검증해야하며, 여러가지 검증법이 있다. 그 중 스프링이 제공하는 BeanValidation을 사용하는 방법에 대해 포스팅 하겠다.


BindingResult

BeanValidation에 대해 설명하기 앞서 BindingResult에 대해 알아야한다.

-@ModelAttribute 어노테이션을 사용할 때, BindingResult 객체를 같이 파라미터로 받으면, 유효성 검증 결과를 확인할 있다. 이는 스프링이 제공하는 객체로 검증 오류가 발생하면 여기에 보관하면 된다.
-이때 BindingResult는 반드시 검증할 대상 바로 다음 파라미터로 와야한다. 

-BindingResult 는 Model에 자동으로 포함된다.


BeanValidation 사용

스프링 부트를 사용하면 스프링 부트가 spring-boot-starter-validation 라이브러리를 넣고, 자동으로 BeanValidator를 인지하고 스프링에 통합한다. 또한 자동으로 글로벌 Validator로 등록한다.

public String addItem(@Validated @ModelAttribute Item item, BindingResult bindingResult) {}
  • @Validated를 파라미터에 추가하면 BeanValidator를 사용할 수 있다.
  • @Validated 는 스프링 전용 검증 애노테이션이고, @Valid 는 자바 표준 검증 애노테이션이다. 둘중 아무거나 사용해도 동일하게 작동하지만, @Validated 는 내부에 groups 라는 기능을 포함하고 있다.
  • 바인딩에 성공한 필드만 Bean Validation을 적용한다. 바인딩에 실패한 데이터를 검증하는것은 의미가 없기 때문에 BeanValidator는 바인딩에 실패한 필드는 BeanValidation을 적용하지 않는다.

검증 애노테이션들 예시

  • @NotBlank : 빈값 + 공백만 있는 경우를 허용하지 않는다.
  • @NotNull : null 을 허용하지 않는다.
  • @Range(min = , max = ) : 범위 안의 값이어야 한다. 
  • @Max(num) : 최대 num까지만 허용한다.

이 애노테이션들을 도메인에 선언되어있는 변수들에 달아주면 BeanValidator가 이를 확인 후 검증한다.

 

Bean Validation - 에러 코드

Bean Validation은 기본으로 오류메시지를 제공한다. BindingResult에 등록된 검증 오류 코드를 보면 다음과 같은 오류코드들이 나온다.

 

@NotBlank

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

BeanValidation의 메시지 탐색 순서

1.생성된 메시지 코드 순서대로 messageSource 에서 메시지 찾기

2.애노테이션의 message 속성 사용 ex) @NotBlank(message = "공백! {0}")

3.라이브러리가 제공하는 기본 값 사용 ex) 공백일 수 없습니다.

 

위와 같은 순서로 메시지를 탐색하기 때문에, messageSource인 errors.properties 파일에 원하는 메시지를 적용하면 사용자가 원하는 에러코드를 만들 수 있다.

 

Bean Validation - 오브젝트 오류

Bean Validation에서 FieldError는 위 방법과 같이 애노테이션으로 처리할 수 있다. 그렇다면 특정 필드( FieldError)가 아닌 해당 오브젝트 관련 오류( ObjectError )는 어떻게 처리할 수 있을까? 다음과 같이 @ScriptAssert() 를 사용하면 된다.

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

하지만 ScriptAssert는 사용에 제약이 많고, 상당히 복잡하다.

따라서 글로벌 오류의 경우 이를 억지로 사용하는것 보다는 다음과 같이 단순 자바 코드로 처리하는 방법이 더 좋은 방법일 수 있다!

if (item.getPrice() != null && item.getQuantity() != null) {
    int resultPrice = item.getPrice() * item.getQuantity();
    if (resultPrice < 10000) {
        bindingResult.reject("totalPriceMin", new Object[]{10000,resultPrice},null);
    }
}

BeanValidation의 한계와 극복

Bean Validation의 한계

현재 요구사항에서 등록과 수정에서 요구사항과 검증해야할 내용들이 일부 다르다. 대표적으로 등록시에는 id 에 값이 없어도 되지만수정시에는 id 값이 필수이다. 또한 등록과 수정에서 quantity 조건이 다르다. Item 도메인에 필드마다 애노테이션을 통해 제약을 걸어버리면, 등록과 수정에 일괄적으로 적용되기 때문에, 현재의 방법으로는 이 문제를 해결할 수 없다.

 

해결방안1 -BeanValidation groups 기능 사용

첫번째 해결방안은 BeanValidation의 groups 기능을 사용는 것이다. 등록시에 검증할 기능과 수정시에 검증할 기능을 각각 그룹으로 나누어 적용하는 방법이다. 우선 그룹으로 나누기 위해 저장용 interface를 생성해야한다. interface에 특별한 기능은 없으며 SaveCheck과 UpdateCheck이라는 두개의 interface를 만들었다 가정하자.

    @NotNull(groups = UpdateCheck.class)
    private Long id;

    @NotBlank(groups = {SaveCheck.class,UpdateCheck.class})
    private String itemName;

    @NotNull(groups = {SaveCheck.class,UpdateCheck.class})
    @Range(min = 1000, max=1000000, groups = {SaveCheck.class,UpdateCheck.class})
    private Integer price;

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

위와 같이 등록과 수정시에 사용될 검증 애노테이션을 분리하여 사용할 수 있다.  컨트롤러에서는 @Validated(UpdateCheck.class)와 같이 파라미터에 사용할 group interface를 넣어주면 된다.

하지만 실무에서는 groups 를 잘 사용하지 않는데, 그 이유는 등록시 폼에서 전달하는 데이터가 Item 도메인 객체와 딱 맞지 않기 때문이다.

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

 

 

해결방안2 -Form 전송 객체 분리 

실무에서는 회원 등록시 회원과 관련된 데이터만 전달받는 것이 아니라, 약관 정보도 추가로 받는 등 Item 과 관계없는 수 많은 부가 데이터가 넘어온다. 현재 상황에서도 등록과 수정은 완전히 다른 데이터가 넘어온다. 그래서 보통 Item 을 직접 전달받는 것이 아니라, 복잡한 폼의 데이터를 컨트롤러까지 전달할 별도의 객체를 만들어서 전달한다. 예를 들면 ItemSaveForm 이라는 폼을 전달받는 전용 객체를 만들어서 @ModelAttribute 로 사용한다. 이것을 통해 컨트롤러에서 폼 데이터를 전달 받고, 이후 컨트롤러에서 필요한 데이터를 사용해서 Item 을 생성한다.

과정: HTML Form -> ItemSaveForm -> Controller -> Item 생성 -> Repository

@PostMapping("/add")
    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);
            }
        }   //오브젝트 에러는 자바 코드로 처리

        //실패 로직(검증에 실패하면 다시 입력 폼으로)
        ~~
        //
        
        Item item = SaveFormToItem(form); //form을 Item객체로 변환

        //성공 로직(Repository에 item 저장)
        Item savedItem = itemRepository.save(item);
        ~~
        //
    }

등록 시 데이터에 맞게 ItemSaveForm객체를 생성하였다. 검증에 성공하면 이를 다시 Item객체로 바꿔 성공로직을 수행한다.

Form 전송 객체를 분리해서 등록과 수정에 딱 맞는 기능을 구성하고, 검증도 명확히 분리했다!!


Bean Validation - HTTP 메시지 컨버터

API를 통해 값을 받을 때 검증을 어떻게 처리하는지 알아보자.

 

API처리 컨트롤러

@Slf4j
@RestController
@RequestMapping("/validation/api/items")
public class ValidationItemApiController {

    @PostMapping("/add")
    public Object addItem(@RequestBody @Validated ItemSaveForm form, BindingResult bindingResult) {

        log.info("API 컨트롤러 호출");

        if (bindingResult.hasErrors()) {
            log.info("검증 오류 발생 errors = {}", bindingResult);
            return bindingResult.getAllErrors();
        }

        log.info("성공 로직 실행");
        return form;
    }
}

API의 경우 3가지 경우를 나누어 생각해야한다.

1. 성공요청

2. 실패요청: JSON을 객체로 생성하는 것 자체를 실패

3. 검증 오류 요청: JSON을 객체로 생성하는 것 까지는 성공했지만, 검증에서 실패

 

Form(@ModelAttribute)과 API(@RequestBody)에서의 차이점은 Form은 하나의 필드가 바인딩에 실패해도 나머지 필드는 정상적으로 바인딩 된다. 하지만 API에서는 전체가 바인딩 되지 못하면(JSON을 객체로 변경하지 못하면) 이후 단계가 진행되지 못한다. 즉 컨트롤러가 호출되지 못하며, Validator도 적용할 수 없게 된다.

 


이 포스팅은 Inflearn 김영한님의 스프링 강의 및 강의자료를 참고하여 작성하였습니다.