Bean Validation

검증 로직을 직접 코드로 설계하게 되면 상당히 번거롭다. 특정 필드가 비었느지 안 비었는지, 최대,최소값, 등 범용적으로 사용되는 일반적인 코드이다. Spring에서는 이러한 검증 로직에 대해 annotation 기반으로 수행될 수 있도록 제공해준다.

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

이와 같이 annotation 기반으로 표준화된 검증을 수행하는 것이 BeanValidation 이다. Bean Validation은 JPA 처럼 Spring에서 표준화한 검증 방식이고, 이에 대한 구현체가 존재한다. 일반적으로 사용되는 구현체는 Hibernator 구현체이다.

build.gradle

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

위와 같이 라이브러리만 추가하게 되면 자동적으로 Spring에서 Validation이 이루어진다. Spring에서는 @Validated 으로 명시되어 있는 검증 대상에 대해 자동으로 BeanValidation을 적용하게 된다..

Bean Validation Test

@Test
void beanValidation() {
    ValidatorFactory validatorFactory = Validation.buildDefaultValidatorFactory();
    Validator validator = validatorFactory.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());
        
    }
}

ValidatorFactory를 이용해서 검증기를 만들고, item을 검증기(Bean Validation으로 지정한 domain 객체)로 검증을 하게 되면

위와 같이 Set<ConstraintViolation<Item>>에 담기게 된다. Set에 error가 없으면 검증 로직을 모두 통과했다는 의미이고, error가 담겼으면, 검증에 실패했음을 뜻한다.

Spring 에서는 내부적으로 위의 과정에 대해 처리를 수행해놨기 때문에, 개발자가 직접 위 처럼 Validator을 생성해서 검증을 직접 수행할 일은 없다.

Applying Bean Validation

@PostMapping("/add")
public String addItem(@Validated @ModelAttribute Item item, BindingResult
bindingResult, RedirectAttributes redirectAttributes) {
    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}";
}

위와 같이 기존의 검증로직은 모두 제거하고, @Validated annotation과 BindingResult만 남기면 된다.

Spring에서는 LocalValidatorFactorBean 이라는 글로벌 Validator을 등록하게 되는데, 해당 Validator는 @Notnull, @Max와 같은 annotation을 보고 검증을 수행한다. 해당 객체가 검증기로 수행가능한지 여부를 판단하기 위해 내부적으로 supports 메소드가 구현되어 있다.

동작 과정

우선 @ModelAttribute에 의해 Item 객체로 초기화를 시도한다. 성공하게 되면 Bean Validation에 의해서 검증이 수행되고, 바인딩이 이루어지지 않은 경우 typeMismatch FieldError가 BindingResult에 추가된다, 바인딩이 이루어지지 않은 필드에 대해서는 검증 로직이 동작하지 않는다.

Error code

Bean Validation이 제공하는 에러 메세지 코드의 경우 아래와 같이 MessageCodesResolver와 동일하다. 그렇기 때문에 에러 메세지를 수정하고자 하면 아래의 방식에 맞춰 메세지 코드를 생성해주면 된다.

  • @NotBlank
    • NotBlank.item.itemName
    • NotBlank.itemName
    • NotBlank.java.lang.String
    • NotBlank
  • @Range
    • Range.item.price
    • Range.price
    • Range.java.lang.Integer
    • Range

Message Code 찾는 순서

  1. Message Source(errors.properties)
  2. message 속성 사용 (ex: @NotBlank(message=” “))
  3. Spring이 제공하는 기본 메세지

Object Error

필드 검증의 경우 위의 방식 처럼 해당 필드에 annotation을 추가하면 됬다. 하지만 object error의 경우 다른 방식으로 처리해야한다.

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

위와 같이 @ScriptAssert을 이용해서 object error을 이용할 수 있다. 하지만, 이와 같이 javascript가 혼재되어 있는 방식의 object error보다는 해당 부분만 코드로 작성해서 controller에서 예외적으로 수행하는 편이 좋다.

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

groups

한계

만약 아래와 같은 요구사항이 추가되었을 때, 어떻게 검증을 수행해야될까?

  1. 수정 시에는 수량을 무제한으로 변경이 가능하다.
  2. 수정 시에는 id값이 필수로 입력되야 한다.

위와 같이 요구조건이 추가되었기 때문에 domain에 검증 로직을 추가해야한다.

@Data
public class Item {
    @NotNull //수정 요구사항 추가
    private Long id;

    @NotBlank
    private String itemName;

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

    //@Max(9999) //수정 요구사항 추가
    private Integer quantity;
    //...
}

하지만 위와 같이 하게 되면, 등록 시에 id가 없는 문제가 있고, 수량 또한 무제한으로 입력할 수 있게되는 문제가 발생한다. 이는 해당 검증 로직을 등록과 수정에서 공유하고 있기 때문에 발생하는 문제이다.

이때, Bean Validation에 groups을 지정하면 문제를 해결할 수 있다.

groups

저장용 group

public interface SaveCheck {
}

수정용 group

public interface UpdateCheck {
}

Item Domain

@Data
public class Item {
    @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;

    public Item() {
    }

    public Item(String itemName, Integer price, Integer quantity) {
    this.itemName = itemName;
    this.price = price;
    this.quantity = quantity;
    }
}

등록과 수정에 관한 controller에 @Validated 부분에 group을 지정한다.

등록

@PostMapping("/add")
public String addItemV2(@Validated(SaveCheck.class) @ModelAttribute Item item,
BindingResult bindingResult, RedirectAttributes redirectAttributes) {
//...
}

수정

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

위와 같이 각각의 검증 로직에 대해 groups을 할당해주게 되면 groups로 설정된 부분만 처리되게 된다.

가령, SaveCheck.class로 등록된 Validated의 경우, groups로 Savecheck.class가 포함되어 있는 annotation만 실행하게 된다.

위 방식을 통해 등록, 수정에서 다른 검증 로직을 적용할 수 있게 된다. 하지만 Domain 객체가 매우 보기 불편하고, 전체적으로 group을 지정해줘야하는 부분이 상당히 번거로워 보인다. 실제로도 groups 기능은 잘 사용하지 않고, 폼 전송 객체를 주로 활용한다.

Form Sending Objects

실제 서비스를 진행할 때를 보면, 등록용 화면과 수정용 화면은 서로 다른 정보를 이용한다. 등록 과정에서는 약관 정보나 아이디값, 등 여러 부가적인 정보가 이용되어, 저장되어 있는 도메인과 딱 맞게 바인딩이 되지 않는다. 따라서, 이럴 때는 도메인을 직접 넘기는 것이 아니라, 등록용 폼 객체를 만들어서 등록 폼에 맞는 폼 객체를 전달하는 것이다. 이후, controller에서 폼 객체를 이용해서 도메인을 만들게 되는 것이다.

그래서 변환 과정을 살펴 보면 controller에서 도메인으로 변환해주는 작업이 추가됨을 확인할 수 있다.

폼 전송 객체를 이용하지 않는 경우

HTML Form > Item > Controller > Repository

폼 전송 객체를 이용하는 경우

HTML Form > ItemSaveForm > Controller > Item > Repository

Save

ItemSaveForm

@Data
public class ItemSaveForm {
    @NotBlank
    private String itemName;

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

    @NotNull
    @Max(9999)
    private Integer quantity;
    }

등록 controller

@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();
        log.info("price: {} * quantity: {} = {}", form.getPrice(), form.getQuantity(), resultPrice);
        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}";
}

controller을 보면 @ModelAttribute으로 받는 객체가 바뀌었고, Item 객체를 생성하는 작업이 추가됨을 확인 할 수 있다.

Update

ItemUpdateForm

@Data
public class ItemUpdateForm {
    @NotNull
    private long id;

    @NotBlank
    private String itemName;

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

    private Integer quantity;
}

수정 controller

@PostMapping("/{itemId}/edit")
public String edit(@PathVariable Long itemId, @Validated @ModelAttribute("item") ItemUpdateForm form, BindingResult bindingResult) {
    //검증 로직
    if(form.getPrice() != null && form.getQuantity() != null){
        int resultPrice = form.getPrice() * form.getQuantity();
        log.info("price: {} * quantity: {} = {}", form.getPrice(), form.getQuantity(), resultPrice);
        if(resultPrice < 10000){
            bindingResult.reject("totalPriceMin",new Object[]{10000,resultPrice}, null);
        }
    }

    if(bindingResult.hasErrors()){
        log.info("errors={}", bindingResult);
        return "validation/v4/editForm";
    }

    //성공 로직
    Item item=new Item();
    item.setItemName(form.getItemName());
    item.setPrice(form.getPrice());
    item.setQuantity(form.getQuantity());

    itemRepository.update(itemId, item);
    return "redirect:/validation/v4/items/{itemId}";
}

HttpMessageConverter

API 방식으로 주고 받는 @RequestBody에 대해서도 @Validated을 적용할 수 있다.

Controller

@Slf4j
@RestController
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. 실패 요청
  3. 검증 실패

검증 성공의 경우 검증 로직을 모두 통과한 것을 의미한다. 검증 실패의 경우 이의 반대 경우를 의미한다. 검증이 실패한 경우 에러 메세지가 출력되게 된다.

API 방식이 기존 @ModelAttribute와 차이가 나는 부분이 바로 이 실패 요청 과정이다. JSON 객체로 전달받은 객체의 경우, 특정 필드에 대해서 하나라도 바인딩 실패(타입 오류)가 나게 되면 HttpMessageConverter가 JSON을 객체로 변환할 수 없어 Validator이 동작하기 않게 된다. @ModelAttribute의 경우 타입 오류가 나더라도 필드 단위로 검증을 수행하기 때문에, 타입 오류가 난 필드를 제외한 나머지 필드에 대해서는 검증 로직이 수행된다.

References

link: inflearn

link:springmvc

댓글남기기