3 분 소요

Bean Validation이란?

이전글에서는 직접 검증기를 만들고 적용해서 에러 발생 시 메시지 처리하는 방법을 배웠다. 하지만 그 과정 중에
작성해야하는 코드량이 너무 많아서 매번 작성해야 한다는 번거롭다는 문제점이 있다. 하지만 대부분 로직이 간단하다!
때문에 이런 문제점을 해결하고자 반복적으로 사용하는 간단한 검증 로직들을 표준화 한게 Bean Validation이다.

JPA가 표준 기술이고 구현체로 hibernate가 있는 것처럼, Bean Validation도 구현체가 아니라 기술 표준이다.
즉, 검증 애노테이션과 인터페이스의 모음이며 일반적인 구현체로 hibernate validator가 있다.
사용하려면 build.gradle에 다음 내용을 추가해줘야 한다.

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


Item 클래스를 예로 들자면, 다음과 같이 애노테이션 설정으로 간단히 처리할 수 있다!
해당 애노테이션에 따라 Bean Validation에 설정된 메시지가 오류 검출 시 출력된다.

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;

    ...
}
  • @NotNull : null 허용X
  • @NotBlank : 빈값 + 공백 허용X
  • @Range : min ~ max 범위 값만 허용
  • @Max : 최댓값 설정

검증 순서

검증 시 이전 글에서 설정한 @Valid 또는 @Validated가 반드시 있어야 한다!
(참고) 뒤에서 설명할 groups라는 기능을 적용하려면 @Validated만 사용해야 한다.

다음 과정을 거쳐 검증을 진행한다.

  • @ModelAttribute에 대해 타입 변환을 시도
    • 실패 시, typeMismatchFieldError를 추가 (해당 에러 메시지가 출력됨)
  • Bean Validation 적용

바인딩에 실패한 경우(@ModelAttribute로의 타입 변환 실패), Bean Valitation을 적용하지 않는다.

오류 메시지 변경

기본으로 제공하는 메시지보단 직접 설정한 메시지를 사용하는게 더 의미있다! 어떻게 해야할까?
@NotBlank에서 발생하는 오류 NotBlankMessageCodesResolver를 통해 다음의 메시지 코드가 순서대로 생성된다.

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

그러면 메시지를 담고있는 errors.properties 파일에서 다음 내용을 추가해주면 메시지 설정이 끝난다!

NotBlank.item.itemName=아이템 이름 필수
NotBlank={0} 공백X 
Range={0}, {2} ~ {1} 허용
Max={0}, 최대 {1}


필드 1개가 아닌 여러 개에 대해 처리하는 방법

@ScriptAssert를 통해서 다음과 같이 설정해줄 수 있다.
하지만 이 방법은 제약이 많고 복잡하므로 이전 글에서 설정한 것처럼 자바 코드로 처리하는걸 추천한다.

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


한계 및 해결방법

데이터를 등록하는 기능과 수정하는 기능 2가지가 특정 필드에 대해 적용하는 조건이 다르다고 해보자.
이 때 위처럼 필드마다 애노테이션을 설정할 경우 일부 기능에서는 원하는 조건이 걸리지 않을 수 있는 문제점이 있다.
해결방법은 groups 기능을 사용하는 것과 Item을 직접 사용하지 않고 각 기능에 맞는 폼 전송용 객체를 만들면 된다.

groups 기능을 사용하려면 인터페이스를 만들어줘야 한다. 각 기능에 맞게 2가지 인터페이스를 만들어주자.

//SaveCheck.java
public interface SaveCheck {
}

//UpdateCheck.java
public interface UpdateCheck {
}


그리고 Item 클래스에 다음과 같이 설정해주면 된다.

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


위처럼 groups로 처리하는 방법도 있지만 코드가 복잡해지고, 기능에 따라 주고받는 데이터 형식이 Item 도메인과
반드시 같지 않다는 단점이 있다. 이 때 각 기능에 맞는 폼 객체를 사용하기 위해 새로 만들어서 @ModelAttribute
인자로 설정해주면 된다. 여기서는 ItemSaveForm이라 하고 특정 기능에 필요한 필드만으로 이루어졌다고 하자.
다음은 등록 기능에 대한 API 함수이다.

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

    //form으로 받아온 내용을 생성한 Item 객체에 설정해주는 과정
    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}";
}


@Validated로 검증을, BindingResult를 통해 검증 결과 정보를 얻는다.

HTTP 메시지 컨버터

@Validated@RequestBody에도 적용할 수 있다!
예시 코드는 다음과 같다.

@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가지 케이스를 고려해야 한다.

  • 성공
  • 실패
    • JSON -> 객체 생성 실패 : controller 실행 자체가 안됨
    • JSON -> 객체 생성 성공했지만 검증 실패 : 오류 메시지 반환

@ModelAttribute는 필드 단위로 적용되므로 특정 필드에서 문제 발생 시 다른 필드는 정상적인 처리가 가능했지만
@RequestBody(HttpMessageConverter)는 각 필드 단위 적용이 아닌 전체 객체 단위로 적용한다.
때문에 위처럼 JSON에서 객체 생성에 실패한 경우 다른 필드도 처리되지 않고 controller 실행 자체가 안된다.
그러므로 Validator를 통한 검증도 불가능해진다.

카테고리:

업데이트:

댓글남기기