Bean Validation

이정원·2024년 11월 8일
post-thumbnail

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

해당 문제를 애노테이션으로 해결하자는 아이디어가 제시되었다.

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;
 
 //...
 }
  • @NotBlank: 해당 값의 NULL,빈값,공백을 허용하지 않음
  • @NotNull: 해당 값의 NULL을 허용하지 않음
  • @Range(min,max): 해당 값의 최대~최소 정의
  • @MAX: 해당 값의 최대 수량

이후 @ModelAttribute앞에 @Validated를 붙여주면 검증기가 동작한다. 이유는 LocalValidatorFactoryBean을 글로벌 validator로 등록하기 때문에 @Validated가 붙은 DTO의 @NotNull 같은 애노테이션을 검증한다. 검증 오류가 발생하면, FieldError,ObjectError를 생성해서 BindingResult에 담아준다.

1.Bean Validation

Bean Validation 표준은 Java에서 데이터 검증을 위한 인터페이스와 규칙을 정의하지만, 직접 구현체를 제공하지는 않는다. 쉽게 말해, Bean Validation은 “검증해야 할 것”과 “검증이 어떻게 이루어져야 하는지”를 설명할 뿐이다. 실제로 이 표준을 구현한 라이브러리는 여러 벤더나 오픈소스 커뮤니티가 만들 수 있다.

Hibernate Validator는 가장 널리 사용되는 Bean Validation의 구현체이다. 여기서 혼란이 생기는 이유는 '하이버네이트'라는 이름 때문이다. Hibernate는 주로 ORM (Object-Relational Mapping) 기술로 잘 알려져 있지만, Hibernate Validator는 ORM과 관계없이 Bean Validation의 표준을 구현한 독립적인 라이브러리이다.

즉, Hibernate Validator는 이름과 달리 Hibernate ORM과의 의존성이 없으며, Bean Validation의 표준 인터페이스를 구현한 라이브러리 중 하나일 뿐이다. 이 라이브러리를 사용하면 Java 애플리케이션에서 Bean Validation 기능을 쉽게 적용할 수 있다.

Bean Validation 테스트

public class BeanValidationTest {
    @Test
    void beanValidation(){
        ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
        Validator validator = factory.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());

        }
    }
}

결과

기존의 검증기(ItemValidator)를 지우고 서버를 실행하면 검증이 된다. 왜일까?

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

순서

@ModelAttribute -> 각각의 필드 타입 변환시도 -> 바인딩에 성공한 필드만 BeanValidation 적용

예)
검증 O: itemName에 문자 "A" 입력 -> 타입 변환 성공 -> itemName 필드에 BeanValidation 적용

검증 X: price에 문자 "A" 입력 -> "A"를 숫자 타입 변환 시도 실패 -> typeMismatch FieldError 추가 -> price 필드는 BeanValidation 적용 X

2.에러 메세지

@Validated를 적용한 검증에서 메세지 생성은 @NotBlank 애노테이션의 단어MessageCodeResolver를 통해 rejectValue가 생성한다.
자세한 메세지를 등록하고 싶다면 errors.properties에 응용하여 등록하면 된다.

해당 오류 메세지를 변경하고 싶다면 errors.properties를 통해 변경이 가능하다.

# 공백 검증 메시지 (NotBlank)
NotBlank.item.itemName=상품 이름을 입력해야 합니다.
NotBlank.order.customerName=고객 이름을 반드시 입력해야 합니다.
NotBlank={0} 값은 공백일 수 없습니다.

#{0}은 field명, 나머지 애노테이션 파라미터는 {1},{2} 순서대로 바인딩해서 메세지 사용  
(!Range에서 {2}는 max,{1}은 min)

3.오브젝트 오류

Bean Validation에서 특정 필드가 아닌 오브젝트 오류를 설정하고 싶다면
자바 코드로 로직을 직접 작성 후 bindingresult에 추가한다.

@PostMapping("/add")
    public String addItem(@Validated @ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model) {
    //특정 필드가 아닌 복합 룰 검증
        if (item.getPrice() != null && item.getQuantity() != null) {
            int resultPrice = item.getPrice() * item.getQuantity();
            if (resultPrice < 10000) {
                bindingResult.reject("totalPriceMin", new Object[]{10000, resultPrice}, null);
            }
        }

Bean Validation - 한계

상품 등록과 수정에서 각각 다른 요구사항이 주어졌다.

  • 등록시에는 id에 값이 없어도 되지만, 수정시에는 id 값이 필수이다.
  • 등록시에는 quantity 수량을 최대 9999까지 등록할 수 있지만 수정시에는 수량을 무제한으로 변경할 수 있다.

해당 요구사항을 위해 id 필드에 @NotNull 추가,quantity의 @MAX를 제거하면 상품 등록할때 처음 등록하기 때문에 id값이 존재하지 않아 등록이 되지 않고 수량 제한도 적용되지 않는다.

-> Groups 기능,별도의 객체 생성을 통해 해결 가능하다.

4.Groups(실무 사용X)

저장할때의 SaveCheck,수정할때의 UpdateCheck 인터페이스 생성 후 Item 클래스의 어노테이션을 다음과 같이 변경하였다.

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

해당 데이터를 정의하고 Http 요청한 페이지에 대해 컨트롤러에서 검증 그룹을 설정하면 된다.

@PostMapping("/add")
public String addItem2(@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) {

해당 기능은 복잡한 관리로 인해 실무에선 잘 사용되지 않고 실제 수정을 위한 DTO를 새로 정의하는게 좋다.

5.검증 객체 분리

실무에서는 회원 등록시 회원과 관련된 데이터만 전달받는 것이 아니라, 약관 정보도 추가로 받는 등 수 많은 부가 데이터가 넘어온다. 따라서 별도의 폼을 전달받는 전용 객체를 통해 필요한 데이터를 추출하여 Item 객체를 생성한다.

  • 장점: 폼 데이터가 복잡해도 맞춤형 객체를 생성하여 검증이 중복되지 않는다.
  • 단점: 복잡한 로직으로 유지·보수에 어려움이 있을수 있다.

등록과 수정은 완전히 다른 데이터가 넘어오는데, 로그인id, 주민번호 같은 경우 수정할수 없다. 또한 추가 데이터를 데이터베이스나 다른 Api를 통해 찾아와야 할수 있다. 따라서 Form 데이터를 받는 별도의 객체를 생성하고 추가 로직을 구현하는게 좋다.

컨트롤러

 @PostMapping("/add")
    public String addItem(@Validated @ModelAttribute("item") ItemSaveForm form, BindingResult bindingResult, RedirectAttributes redirectAttributes) {
    // 성공 로직
        Item item = new Item();
        item.setItemName(form.getItemName());
        item.setPrice(form.getPrice());
        item.setQuantity(form.getQuantity());
        Item savedItem = itemRepository.save(item);

//수정도 마찬가지

@ModelAttribute("") 괄호 안에 명칭을 지정하지 않으면 뷰에서 객체명으로 모델이 담긴다.

6.HTTP 메시지 컨버터 - 검증

@Valid , @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;
    }
}
2024-11-12 21:02:30.793  WARN 23936 --- [nio-8080-exec-3] .w.s.m.s.DefaultHandlerExceptionResolver : Resolved [org.springframework.http.converter.HttpMessageNotReadableException: JSON parse error: Cannot deserialize value of type `java.lang.Integer` from String "aa": not a valid Integer value; nested exception is com.fasterxml.jackson.databind.exc.InvalidFormatException: Cannot deserialize value of type `java.lang.Integer` from String "aa": not a valid Integer value
 at [Source: (PushbackInputStream); line: 2, column: 32] (through reference chain: hello.itemservice.web.validation.form.ItemSaveForm["price"])]

잘못된 요청을 보낼시, HttpMessageConverter가 Json을 ItemSaveForm 객체로 생성하는것 자체가 실패하면 컨트롤러 호출이 안되고, 객체 생성 후 검증에서 오류가 발생하면 BindingResult를 객체로 반환한다.

@ModelAttribute vs @RequestBody

@ModelAttribute는 특정 필드가 바인딩되지 않아도 나머지 필드는 정상 바인딩이 되고,@RequestBodyHttpMessageConverter단계에서 JSON 데이터를 객체로 변경하지 못하면 이후 단계가 진행되지 않고 예외가 발생한다.

0개의 댓글