Spring MVC(6) Bean Validation (애노테이션을 이용한 편리한 검증)

오잉·2023년 5월 24일
0

SPRING

목록 보기
15/15

과거 : Validator를 직접 만들어야 했다

@Component
public class ItemValidator implements Validator {
    @Override
    public boolean supports(Class<?> clazz) {
        return Item.class.isAssignableFrom(clazz);
    }

    @Override
    public void validate(Object target, Errors errors) {
        Item item = (Item) target;
        ValidationUtils.rejectIfEmptyOrWhitespace(errors, "itemName", "required");
        if (item.getPrice() == null || item.getPrice() < 1000 || item.getPrice() > 1000000) {
            errors.rejectValue("price", "range", new Object[]{1000, 1000000}, null);
        }
        if (item.getQuantity() == null || item.getQuantity() > 10000) {
            errors.rejectValue("quantity", "max", new Object[]{9999}, null);
        }

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

검증 기능을 매번 코드로 작성하는 것은 굉장히 귀찮다.
근데 생각해보면 필드에 대한 일반적인 검증 로직은 대체로 정해져있다.

Bean Validation이란?

검증 로직을 모든 프로젝트에 적용할 수 있게 공통화하고, 표준화 한 것이 바로 Bean Validation이다.

  • Bean Validation을 잘 활용하면, 애노테이션만으로 검증 로직을 매우 편리하게 적용할 수 있다.
  • Bean Validation은 특정한 구현체가 아니라 Bean Validation 2.0이라는 기술 표준이다.
  • 쉽게 이야기해서 검증 애노테이션과 여러 인터페이스의 모음이다.
  • Bean Validation을 구현한 기술중에 일반적으로 사용하는 구현체는 하이버네이트 Validator이다. (이름이 하이버네이트이긴 하지만, JPA의 구현체인 하이버네이트와는 관련이 없다)

Bean Validation을 사용해보자

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

Bean Validation을 사용하려면 build.gradle에 위의 의존성을 추가해야한다.

@Data
public class Item {
      private Long id;
      
      @NotBlank // "" or " " or null 불가
      private String itemName;
      
      @NotNull // null 불가
      @Range(min = 1000, max = 1000000) // 1000 <= x <= 1000000
      private Integer price;
      
      @NotNull
      @Max(9999) // x <= 9999
      private Integer quantity;
      
      public Item() {
	  }
      
      public Item(String itemName, Integer price, Integer quantity) {
            this.itemName = itemName;
            this.price = price;
            this.quantity = quantity;
      }
}

Item객체에 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}";
}
  • 스프링 부트는 LocalValidatorFactoryBean을 자동으로 글로벌 Validator로 등록한다.
  • 이 Validator는 @NotNull과 같은 어노테이션을 보고 검증을 수행한다.
  • 이렇게 글로벌 Validator가 적용되어 있기 때문에, 검증하고자 하는 객체 앞에@Valid@Validated만 붙여주면 알아서 검증 로직이 실행된다.
  • 기본적인 스프링 검증 오류 처리와 마찬가지로, 검증 오류가 발생하면 FieldError, ObjectError를 생성해서 BindingResult에 담아준다.
  • 주의 : 글로벌 Validator를 직접 등록하면 스프링 부트는 Bean Validator를 글로벌 Validator로 등록하지 않는다. 따라서 애노테이션 기반의 빈 검증기가 동작하지 않는다.

Bean Validation에서 특정 필드가 아닌 해당 오브젝트 관련 오류를 처리하는 법

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

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

그런데 실제 사용해보면 제약이 많고 복잡하다. 그리고 실무에서는 검증 기능이 해당 객체의 범위를 넘어서는 경우들도 종종 등장하는데, 그런 경우 대응이 어렵다.
따라서 오브젝트 오류(글로벌 오류)의 경우 @ScriptAssert 을 억지로 사용하는 것 보다는 다음과 같이 오브젝트 오류 관련 부분만 직접 자바 코드로 작성하는 것을 권장한다.

@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()) {
          		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}";
}

@ModelAttribute에서의 검증 순서

  1. @ModelAttribute 각각의 필드에 타입 변환(바인딩) 시도
    • 성공하면 다음으로 (2)
    • 실패하면 typeMismatch로 FieldError 추가
  2. Validator 적용

BeanValidator는 바인딩에 성공한 필드에만 Bean Validation을 적용한다
(바인딩에 실패한 필드는 BeanValidation을 적용하지 않는다.)

  • 생각해보면 타입 변환에 성공해서 바인딩에 성공한 필드여야 BeanValidation 적용이 의미 있다.
    (일단 모델 객체에 바인딩 받는 값이 정상으로 들어와야 검증도 의미가 있다.)
  • @ModelAttribute -> 각각의 필드 타입 변환시도 -> 변환에 성공한 필드만 BeanValidation 적용
  • 예)
    • String 필드에 문자 "A" 입력 -> 타입 변환 성공 -> 해당 필드에 BeanValidation 적용
    • int 필드에 문자 "A" 입력 -> "A"를 숫자 타입 변환 실패 -> typeMismatch FieldError 추가 -> 해당 필드는 BeanValidation 적용x

@RequestBody에 Bean Validation 적용하기

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

참고

  • @ModelAttribute : HTTP 요청 파라미터(URL 쿼리 스트링, POST Form) 처리
  • @RequestBody : HTTP Body의 데이터를 객체로 변환할 때 (주로 API JSON 요청을 다룰 때)
@PostMapping("/add")
public Object addItem(@RequestBody @Validated ItemSaveForm form,
					  BindingResult bindingResult) {  
	if (bindingResult.hasErrors()) {
    	log.info("errors={}", bindingResult);
        return bindingResult.getAllErrors();
    }

	// 성공로직
    return form;
}

API의 경우 3가지 경우를 나누어 생각해야 한다.
1) 성공 요청: 성공
2) 실패 요청: JSON을 객체로 생성하는 것 자체가 실패함
3) 검증 오류 요청: JSON을 객체로 생성하는 것은 성공했고, 검증에서 실패함

2)의 경우

  • 예시 : int 필드에 String 전달시
  • HttpMessageConverter 에서 요청 JSON을 ItemSaveForm 객체로 생성하는데 실패한다.
  • ItemSaveForm 객체를 만들지 못하기 때문에 컨트롤러 자체가 호출되지 않고 그 전에 예외가 발생한다. 물론 Validator도 실행되지 않는다
  • 예외로그
.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"])]

3)의 경우

  • 예시 : @max(9999)인 int 필드에 10000 전달
  • HttpMessageConverter 는 성공하지만 검증(Validator)에서 오류가 발생하는 경우

@ModelAttribute에서의 검증 vs @RequestBody에서의 검증

  • HTTP 요청 파리미터를 처리하는 @ModelAttribute

    • 각각의 필드 단위로 세밀하게 바인딩이 적용된다.
    • 그래서 특정 필드에 타입이 맞지 않는 오류(바인딩 실패)가 발생해도 나머지 필드는 정상 처리할 수 있다.
    • 특정 필드가 바인딩 되지 않아도 나머지 필드는 정상 바인딩 되고, Validator를 사용한 검증도 적용할 수 있다.
  • HTTP Body를 처리하는 @RequestBody (HttpMessageConverter)

    • HttpMessageConverter는 전체 객체 단위로 적용된다. HttpMessageConverter 단계에서 실패하면 예외가 발생한다.
    • 따라서 메시지 컨버터의 작동이 성공해서 ItemSaveForm 객체를 만들어야 @Valid , @Validated 가 적용된다.
    • HttpMessageConverter 단계에서 JSON 데이터를 객체로 변경하지 못하면 이후 단계 자체가 진행되지 않고 예외가 발생한다. 컨트롤러도 호출되지 않고, Validator도 적용할 수 없다.

이 글은 인프런 김영한님의 스프링 MVC2의 [섹션5. 검증2 - Bean Validation]을 정리한 글입니다.

profile
오잉이라네 오잉이라네 오잉이라네 ~

0개의 댓글