[Spring] 검증(2) - BeanValidation

imcool2551·2022년 3월 3일
0

Spring

목록 보기
11/15
post-thumbnail

본 글은 인프런 김영한님의 스프링 완전 정복 로드맵을 기반으로 정리했습니다.

1. BeanValidation


필드 검증 기능을 매번 코드로 작성하기는 번거롭다. 필드에 대한 검증은 대부분 빈 값인지 아닌지, 특정 범위내에 있는지와 같이 매우 일반적인 경우가 많다. 이 때 애노테이션을 사용하여 다음처럼 검증을 매우 간단하고 명확하게 할 수 있다. 이전 글과 똑같이 상품 데이터의 이름, 가격, 수량에대해 검증하는 예제를 가지고 진행하겠다.

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

위와같은 애노테이션 기반의 검증을 BeanValidation 이라고 한다. BeanValidation 은 JSR-380 기술 표준으로써 특정한 구현체가 아니다. 일반적으로 구현체로 하이버네이트 Validator를 사용한다. 참고로, 이름만 같을 뿐 ORM 과는 관련 없다.

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

BeanValidation을 사용하려면 build.gradle 에 위 의존관계를 추가해야한다. 의존관계만 추가하면 스프링 부트에서는 Bean Validator가 스프링에 통합된다. 다만, 직접 글로벌 검증기를 등록하면 Bean Validator가 등록되지 않기 때문에 주의가 필요하다. LocalValidatorFactoryBean 이 글로벌 검증기 로 등록되는데 이 검증기는 @NotNull 과 같은 애노테이션을 보고 검증을 수행하기 때문에 검증하고자 하는 객체에 @Valid@Validated 만 적용하면 된다.

@Valid, @Validated 는 각각 자바 표준, 스프링 전용 애노테이션이다. 둘 중 아무거나 사용해도 되지만, @Validated 는 groups라는 기능을 추가로 포함한다. groups는 특정 그룹에만 검증을 적용하는 기능이지만 사용할 일이 거의 없다.

검증 오류가 발생하면 이전 글에서 살펴본 FieldError, ObjectError 를 생성해서 BindingResult 에 담아준다.

2. BeanValidation 에러 코드


이전 글에서 MessageCodesResolver 를 통해 단계적으로 메시지 코드(오류 코드)가 생성되는 것을 봤다. Bean Validation도 검증에 실패하면 애노테이션 이름으로 코드가 등록된다. 다음과 같이 단계적으로 등록해준다.

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

Bean Validation 검증 실패도 마찬가지로 단계적으로 메시지 코드를 생성해주기 때문에 범용적인 메시지 코드에 대한 에러 메시지부터 정의해주고 필요에 따라 구체적인 메시지를 정의해주면 된다.

NotBlank={0} 공백X
Range={0}, {2} ~ {1} 허용
Max={0}, 최대 {1}

Bean Validation이 메시지를 찾는 순서는 다음과 같다.

  1. 생성된 메시지 코드 순서대로 messageSource 에서 메시지 찾기
  2. 애노테이션의 message 속성 사용. ex) @NotBlank(message = "공백! {0}")
  3. 라이브러리가 제공하는 기본 값 사용. ex) 공백일 수 없습니다.

3. ModelAttribute 검증


ModelAttribute는 쿼리 파라미터 형식의 데이터를 객체 형식으로 받을 수 있게 해준다. ModelAttribute를 먼저 각각의 필드에 대해 타입 변환을 시도한다. 실패하면 typeMismatch 라는 이름으로 FieldError 를 추가한다. 실패한 필드는 검증 작업이 일어나지 않는다. 즉, 바인딩에 성공한 필드만 Validator를 적용한다. 생각해보면 당연한데, 타입 변환에 성공해서 객체의 필드에 어떤 값이리도 들어와야 검증을 할 수 있기 때문이다.

예를 들어, 정수형 필드인 price에 "A"를 입력하면 타입 변환에 실패하여 typeMisMatch 필드 에러가 BindingResult 에 추가된다. 더 이상 @NotNull@Range 에 대한 검증은 일어나지 않는다.

컨트롤러에서 검증을 사용하려면 다음과 같이 하면 된다.

@PostMapping("/add")
public String addItem(@Validated @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()) {
      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 Validator를 사용하기 위해 스프링 제공 애노테이션 @Validated 를 사용했다. 자바 표준인 @Valid 를 사용해도 된다. 두 애노테이션중 하나를 붙이기만 해도 Bean Validator가 동작해서 FieldError가 생기면 BindingResult 에 넣어준다.

객체 오류 ObjectError 는 어쩔 수 없이 코드로 검증해야 한다. @ScriptAssert 애노테이션이 있기는 하지만 기능이 너무 제한적이라 사용하기 어려운 경우가 많다.

4. 전송 객체 분리


지금까지 데이터를 도메인 객체에 그대로 입력받아서 검증했다. 현실에서 실제 도메인 객체는 사용자가 입력하는 값과 매우 다르기 때문에 전송 객체를 분리하는 것이 일반적이다. @Data 와 같은 애노테이션도 도메인 객체에는 붙이지 않는 것이 좋다.

전송 객체를 분리하면 비즈니스와 관계없는 웹계층 검증 애노테이션을 도메인으로부터 분리할 수 있다. 대신 전송 객체로부터 도메인 객체를 생성해야 하는 변환 과정을 추가로 거쳐야 한다.

@Data
public class ItemSaveForm {

  @NotBlank
  private String itemName;

  @NotNull
  @Range(min = 1_000, max = 1_000_000)
  private Integer price;

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

Item을 저장하기 위해 입력받는 객체를 분리했다. Long타입의 id는 repository를 통해 저장할 때 생성되는 값이기 때문에 전송 객체에서 제외했다.

@Data
public class ItemUpdateForm {

  @NotNull
  private Long id;

  @NotBlank
  private String itemName;

  @NotNull
  @Range(min = 1_000, max = 1_000_000)
  private Integer price;

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

Item을 수정하기 위해 입력받는 객체다. 전송 객체를 조회/수정 에 재사용하고 싶은 생각이 들 수 있다. 하지만 이 또한 소위 HelloWorld 수준을 벗어나면 조회와 수정시 입력받는 값은 매우 상이하기 때문에 따로 만들어주는 것이 좋다. 수정시에는 어떤 item을 수정해야 하는지 클라이언트가 알려줘야 하므로 Long타입의 id를 받는다.

전송객체의 이름을 무엇으로 할지도 고민될 수 있다. ItemSaveForm, ItemSaveRequest, ItemSaveDto 처럼 다양한 이름이 가능하다. 이름이 의미있으면 무엇을 사용해도 된다. 중요한 것은 일관성이다. 일관성있게 이름을 지어야 유지보수 하기에 좋다.

5. API 검증


사용자는 쿼리 파라미터(GET 쿼리 스트링, POST Form) 외에도 HTTP Body에 데이터를 직접 담아 보낼수도 있다. 주로 JSON을 통해 통신하는 API를 다룰 때 사용한다. BeanValidation과 @Valid, @Validated 는 HTTP Body 처리와 관련된 @RequestBody 에도 적용할 수 있다.

@ResponseBody
@PostMapping("/validation/api/items/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;
}

코드를 통해 살펴보자. 단순한 컨트롤러로, 성공하면 사용자가 보낸 JSON 데이터를 그대로 반환한다.

API 검증은 크게 세 가지 경우로 나눠서 생각해야 한다. 실패의 경우, @ModelAttribute 와 약간의 차이가 있다. 하나씩 살펴보자.

5a. 성공

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

위와 같이 요청을 보내면 성공한다.

사용자는 보낸 데이터를 그대로 응답받는다.

{
  "itemName": "hello",
  "price": 1000,
  "quantity": 10
}

로그는 아래와 같이 찍힌다.

API 컨트롤러 호출
성공 로직 실행

5b. 변환 실패(JSON-> 객체 변환 실패)

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

정수형 price에 문자 "A"를 보냈다.

@ModelAttributerequest.getParamter("price") 처럼 요청 파라미터를 하나씩 가져와서 검증하기 때문에 하나의 필드가 바인딩 되지 않아도 다른 필드에 대해 검증을 수행할 수 있다.

반면, @RequestBodyHttpMessageConverter 를 통해 JSON에서 객체로의 변환이 일어나기 때문에, 객체 변환에 실패하면 컨트롤러 자체가 호출되지 않고 그 전에 예외가 발생한다. 객체가 생성되지 않았기 때문에 당연히 검증 자체가 수행되지 않는다.

사용자는 아래처럼 스프링이 생성한 JSON을 받게된다.

{
  "timestamp": "2022-03-04T00: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"])]

5c. 검증 실패(JSON->객체 성공, but 검증에 실패)

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

위와 같이 데이터를 보내면 객체 생성에는 성공한다. 그러나, BeanValidator가 @Max(9999) 를 확인해서 quantity 필드에서 오류를 발생시킨다.

사용자는 아래처럼 bindingResult.getAllErrors() 가 생성한 ObjectErrorFieldError 를 변환한 JSON을 응답받는다. 실제 개발할 때는 이 객체들을 그대로 사용하지 말고, 필요한 데이터만 뽑아서 별도의 API 스펙을 정의하고 그에 맞는 객체를 만들어서 반환해야 한다.

[
  {
    "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"
  }
]

로그는 아래와 같이 찍힌다. 객체 생성에는 성공해서 컨트롤러에는 도달한 것을 수 있다.

API 컨트롤러 호출
검증 오류 발생, errors=org.springframework.validation.BeanPropertyBindingResult: 1 errors
Field error in object 'itemSaveForm' on field 'quantity': rejected value [99999]; codes [Max.itemSaveForm.quantity,Max.quantity,Max.java.lang.Integer,Max]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [itemSaveForm.quantity,quantity]; arguments []; default message [quantity],9999]; default message [9999 이하여야 합니다]

6. 정리


BeanValidation을 사용하여 필드 검증을 매우 간단하고 명확하게 처리할 수 있다. BeanValidation이 제공하는 애노테이션은 매우 다양하다. 이메일, 신용카드, isbn 과 같이 필요한 거의 모든 애노테이션을 제공하기 때문에 적극 활용하도록 하자. 다만, 필드를 벗어난 객체 검증의 경우엔 어쩔 수 없이 코드를 통해 직접 BindingResult 에 오류를 넣어줘야 한다.

또한, BeanValidation이 요청 파라미터 뿐 아니라 메시지 바디를 사용하는 @RequestBody 에도 적용될 수 있는걸 알아봤다. 차이는, @RequesetBody 검증은 타입 오류 등으로 객체 바인딩에 실패하면 컨트롤러 호출 자체가 안되고 HttpMessaageConveter 단에서 400 응답코드가 나간다는 것이다. 예외 발생시 나가는 JSON을 원하는 모양으로 처리하는 방법은 예외 처리 부분에서 다루겠다.

profile
아임쿨

0개의 댓글