Bean Validation

김도현·2024년 2월 23일

스프링 MVC 2편

목록 보기
2/7
post-thumbnail

Bean Validation - 소개

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

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

이런 검증 로직을 모든 프로젝트에 적용할 수 있게 공통화하고, 표준화 한 것이 바로 Bean Validation이다. Bean Validation을 잘 활용하면, 어노테이션 하나로 검증 로직을 매우 편리하게 적용할 수 있다.

Bean Validation 이란?
먼저 Bean Validation은 특정한 구현체가 아니라 Bean Validation 2.0(JSR-380)이라는 기술 표준이다. 쉽게 이야기해서 검증 어노테이션과 여러 인터페이스의 모임이다. 마치 JPA가 표준 기술이고 그 구현체로 하이버네이트가 있는 것과 같다.

Bean Validation을 구현한 기술중에 일반적으로 사용하는 구현체는 하이버네이트 Validator이다. 이름이 하이버네이트가 붙어서 그렇지 ORM과는 관련이 없다.

Bean Validation - 시작

Bean Validation 기능을 어떻게 사용하는지 코드로 알아보자. 먼저 스프링과 통합하지 않고, 순수한 Bean Validation 사용법 부터 테스트 코드로 알아보자.

Bean Validation 의존관계 추가

build.gradle

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

spring-boot-starter-validation 의존관계를 추가하면 라이브러리가 추가된다.

jakarta.validation-api: Bean Validation 인터페이스
hibernate-validator: 구현체

Item - Bean Validation 애노테이션 적용

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

 public Item() {
 }

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

검증 애노테이션
@NotBlank : 빈값 + 공백만 있는 경우를 허용하지 않는다.
@NotNull: null을 허용하지 않는다.
@Range(min = 1000, max = 1000000): 범위 안의 값이어야 한다.
@Max(9999) : 최대 9999까지만 허용한다.

정리
이렇게 빈 검증기를 직접 사용하는 방법을 알아보았다. 아마 지금까지 배웠던 스프링 MVC 검증 방법에 빈 검증기를 어떻게 적용하면 좋을지 여러가지 생각이 들 것이다. 스프링은 이미 개발자를 위해 빈 검증기를 스프링에 완전히 통합해두었다.

Bean Validation - Spring 적용

스프링 MVC는 어떻게 Bean Validator를 사용?
스프링 부트가 spring-boot-starter-validation 라이브러리를 넣으면 자동으로 Bean Validator를 인지하고 스프링에 통합한다.

스프링 부트는 자동으로 글로벌 Validator로 등록한다.
LocalValidatorFactoryBean을 글로벌 Validator로 등록한다. 이 validator는 @NotNull같은 어노테이션을 보고 검증을 수행한다. 이렇게 글로벌 Validator가 적용되어 있기 때문에, @Valid, @Validated 만 적용하면 된다. 검증 오류가 발생하면, FieldError, ObjectError를 생성해서 BindingResult에 담아준다.

참고
검증시 @Validated @Valid 둘다 사용가능하다.
@Validated는 스프링 전용 검증 어노테이션이고, @Valid는 자바 표준 검증 어노테이션이다. 둘 중 아무거나 사용해도 동일하게 작동한다.

검증 순서

  1. @ModelAttribute 각각의 필드에 타입 변환 시도
  2. 성공하면 다음으로
  3. 실패하면 typeMismatchFieldError 추가
  4. Validator 적용

Bean Validation - 에러 코드

Bean Validation이 기본으로 제공하는 오류 메시지를 좀 더 자세히 변경하고 싶으면 어떻게 하면 될까?

Bean Validation을 적용하고 bindingResult에 등록된 검증 오류 코드를 보자
오류 코드가 어노테이션 이름으로 등록된다. 마치 typeMismatch와 유사하다.

NotBlank 라는 오류 코드를 기반으로 MessageCodesResolver를 통해 다양한 메시지 코드가 순서대로 생성된다.

@NotBlank

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

Bean Validation - 오브젝트 오류

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

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

실행해보면 정상 수행됨

그런데 실제로 사용해보면 제약이 많고 복잡하다. 그리고 실무에서는 검증 기능에 해당 객체의 범위를 넘어서는 경우들도 종종 등장하는데, 그런 경우 대응이 어렵다.

따라서 오브젝트 오류(글로벌 오류)의 경우 @ScriptAssert을 억지로 사용하는 것 보다는 다음과 같이 오브젝트 오류 관련 부분만 직접 자바 코드로 작성하는 것을 권장한다.

ValidationItemControllerV3 - 글로벌 오류 추가

  @PostMapping("/add")
  public String addItem(@Valid @ModelAttribute Item item, BindingResult bindingResult,
    RedirectAttributes redirectAttributes) {

    //특정 필드 예외가 아닌 전체 예외
    if (item.getPrice() != null && item.getQuantity() != null) {
      int resultPrice = item.getPrice() * item.getQuantity();
      if (resultPrice < 10000) {
        bindingResult.reject("totalPriceMin", new Object[]{10000,
          resultPrice}, null);
      }
    }

    // 검증에 실패하면 다시 입력 폼으로
    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}";
  }

Bean Validation - 한계

데이터를 등록할 때와 수정 할때는 요구사항이 다를 수 있다.

이럴 때 보통 실무에서는 AddRequestDto, SetRequestDto 를 나누어서 @NotNull 과 같은 Bean Validaiton 을 따로 걸어준다.

Form 전송 객체 분리 - 소개

실무에서는 @Validated의 groups를 잘 사용하지 않는데, 그 이유가 다른 곳에 있다. 바로 등록시 폼에서 전달하는 데이터가 Item 도메인 객체와 딱 맞지 않기 때문이다.
소위 "Hello World" 예제에서는 폼에서 전달하는 데이터와 Item 도메인 객체가 딱 맞는다. 하지만 실무에서는 회원 등록시 회원과 관련된 데이터만 전달받는 것이 아니라, 약관 정보도 추가로 받는 등 Item과 관계없는 수 많은 부가 데이터가 넘어온다.
그래서 보통 Item을 직접 전달받는 것이 아니라, 복잡한 폼의 데이터를 컨트롤러까지 전달할 별도의 객체를 만들어서 전달한다. 예를 들면 ItemSaveForm이라는 폼을 전달받는 전용 객체를 만들어서 @ModelAttribute로 사용한다. 이것을 통해 폼 데이터를 전달 받고, 이후 컨트롤러에서 필요한 데이터를 사용해서 Item을 생성한다.

수정의 경우 등록과 수정은 완전히 다른 데이터가 넘어온다. 생각해보면 회원 가입시 다루는 데이타와 수정시 다루는 데이터는 범위에 차이가 있다. 예를 들면 등록시에서는 로그인id, 주민번호 등등을 받을 수 있지만, 수정시에는 이런 부분이 빠진다. 그리고 검증 로직도 많이 달라진다. 그래서 ItemUpdateForm이라는 별도의 객체로 데이터를 전달받는 것이 좋다.

Item 도메인 객체를 폼 전달 데이터로 사용하고, 그대로 쭉 넘기면 편리하겠지만, 앞에서 설명한 것과 같이 실무에서는 Item의 데이터만 넘어오는 것이 아니라 무수한 추가 데이터가 넘어온다. 그리고 더 나아가서 Item을 생성하는데 필요한 추가 데이터를 데이터베이스나 다른 곳에서 찾아와야 할 수도 있다.

Bean Validation - HTTP 메시지 컨버터

@Valid, @Validated, HttpMessageConverter (@RequestBody)에도 적용할 수 있다.

참고
@ModelAttribute는 HTTP 요청 파라미터 (URL 쿼리 스트링, POST Form)를 다룰 때 사용한다.
@RequestBody는 HTTP Body의 데이터를 객체로 변환할 때 사용한다. 주로 API JSON 요청을 다룰 때 사용한다.

ValidationItemApiController 생성

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

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

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

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

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

성공 요청

 POST http://localhost:8080/validation/api/items/add
 {"itemName":"hello", "price":1000, "quantity": 10}

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

  • 성공 요청:성공
  • 실패 요청: JSON을 객체로 생성하는 것 자체가 실패함
  • 검증 오류 요청: JSON을 객체로 생성하는 것은 성공했고, 검증에서 실패함

실패 요청

 POST http://localhost:8080/validation/api/items/add
 {"itemName":"hello", "price":"A", "quantity": 10}

실패 요청 결과

 {
 "timestamp": "2021-04-20T00:00:00.000+00:00",
 "status": 400,
 "error": "Bad Request",
 "message": "",
 "path": "/validation/api/items/add"
 }

실패 요청 로그

.w.s.m.s.DefaultHandlerExceptionResolver : Resolved 
[org.springframework.http.converter.HttpMessageNotReadableException: JSON parse 
error: Cannot deserialize value of type `java.lang.Integer` from String "A": not 
a valid Integer value; nested exception is 
com.fasterxml.jackson.databind.exc.InvalidFormatException: Cannot deserialize 
value of type `java.lang.Integer` from String "A": not a valid Integer value
 at [Source: (PushbackInputStream); line: 1, column: 30] (through reference 
chain: hello.itemservice.domain.item.Item["price"])]

HttpMessageConverter에 요청 JSON을 ItemSaveForm 객체로 생성하는데 실패한다.
이 경우 ItemSaveForm 객체를 만들지 못하기 때문에 컨트롤러 자체가 호출되지 않고 그 전에 예외가 발생한다. 물론 Validator도 실행되지 않는다.

검증 오류 요청
이번에는 HttpMessageConverter는 성공하지만 검증에서 오류가 발생하는 경우를 확인해보자.

검증 오류 결과

 [
    {
     "codes": [
     "Max.itemSaveForm.quantity",
     "Max.quantity",
     "Max.java.lang.Integer",
     "Max"
  ],
 "arguments": [
            {
 "codes": [
 "itemSaveForm.quantity",
 "quantity"
                ],
 "arguments": null,
 "defaultMessage": "quantity",
 "code": "quantity"
            },
 9999
        ],
 "defaultMessage": "9999 이하여야 합니다",
 "objectName": "itemSaveForm",
 "field": "quantity",
 "rejectedValue": 10000,
 "bindingFailure": false,
 "code": "Max"
    }
]

return bindingResult.getAllErrors();ObjectErrorFieldError를 반환한다. 스프링이 이 객체 JSON으로 변환해서 클라이언트에 전달했다. 여기서는 예시로 보여주기 위해서 검증 오류 객체들을 그대로 반환했다. 실제로 개발할 때는 이 객체들을 그대로 사용하지 말고, 필요한 데이터만 뽑아서 별도의 API 스펙을 정의하고 그에 맞는 객체를 만들어서 반환해야 한다.

@ModelAttribute vs @ResponseBody
HTTP 요청 파라미터를 처리하는 @ModelAttribute는 각각의 필드 단위로 세밀하게 적용된다. 그래서 특정 필드 타입이 맞지 않는 오류가 발생해도 나머지 필드가 정상 처리할 수 있었다.
HttpMessageConverter@ModelAttribute와 다르게 각각 필드 단위로 적용되는 것이 아니라, 전체 객체 단위로 적용된다.
따라서 메시지 컨버터의 작동이 성공해서 ItemSaveForm 객체를 만들어야 @Valid가 적용된다.

  • @ModelAttribute는 필드 단위로 정교하게 바인딩이 적용된다. 특정 필드가 바인딩 되지 않아도 나머지 필드는 정상 바인딩 되고, Validator를 사용한 검증도 적용할 수 있다.
  • @ReuqestBody는 HttpMessageConverter 단계에서 JSON 데이터를 객체로 변경하지 못하면 이후 단계 자체가 진행되지 않고 예외가 발생한다. 컨트롤러도 호출되지 않고, Validator도 적용할 수 있다.
profile
Just do it

0개의 댓글