검증 기능을 지금처럼 매번 코드로 작성하는 것은 상당히 번거롭다.
특히 특정 필드에 대한 검증 로직은 대부분 빈 값인지 아닌지,
특정 크기를 넘는지 아닌지와 같이 매우 일반적인 로직이다.
이런 검증 로직을 모든 프로젝트에 적용할 수 있게 공통화하고,
표준화 한 것이 바로 Bean Validation 이다.
Bean Validation을 잘 활용하면, 애노테이션 하나로 검증 로직을 매우 편리하게 적용할 수 있다.
Validator가 지원하는 검증 애노테이션
https://docs.jboss.org/hibernate/validator/6.2/reference/en-US/html_single/#validator-defineconstraints-spec
private final ItemValidator itemValidator;
@InitBinder
public void init(WebDataBinder dataBinder) {
log.info("init binder {}", dataBinder);
dataBinder.addValidators(itemValidator);
}
기존에 등록한 ItemValidator를 제거해두자, 오류 검증기가 중복 적용된다.
실행해보면 애노테이션 기반의 Bean Validation이 정상 동작하는 것을 확인할 수 있다.
스프링 부트가 spring-boot-starter-validation 라이브러리를 넣으면 자동으로 Bean Validator를 인지하고 스프링에 통합한다.
스프링 부트는 자동으로 LocalValidatorFactoryBean 을 글로벌 Validator로 등록한다. 이 Validator는 @NotNull 같은 애노테이션을 보고 검증을 수행한다.
BeanValidator는 바인딩에 실패한 필드는 BeanValidation을 적용하지 않는다.
생각해보면 타입 변환에 성공해서 바인딩에 성공한 필드여야 BeanValidation 적용이 의미 있다. (일단 모델 객체에 바인딩 받는 값이 정상으로 들어와야 검증도 의미가 있다.)
@ModelAttribute -> 각각의 필드 타입 변환시도 -> 변환에 성공한 필드만 BeanValidation 적용 (실패 시 typeMismatch)
Bean Validation이 기본으로 제공하는 오류 메시지를 좀 더 자세히 변경하고 싶으면 어떻게 하면 될까?
Bean Validation을 적용하고 bindingResult 에 등록된 검증 오류 코드를 보자. 오류 코드가 애노테이션 이름으로 등록된다.
마치 typeMismatch 와 유사하다.
NotBlank 라는 오류 코드를 기반으로 MessageCodesResolver 를 통해 다양한 메시지 코드가 순서대로 생성된다.
@NotBlank
NotBlank.item.itemName
NotBlank.itemName
NotBlank.java.lang.String
NotBlank
@Range
Range.item.price
Range.price
Range.java.lang.Integer
Range
메시지 처리 또한 errors.properties에서 진행한다.
errors.properties
#Bean Validation 추가
NotBlank={0} 공백X
Range={0}, {2} ~ {1} 허용
Max={0}, 최대 {1}
BeanValidation 메시지는 아래와 같은 우선순위로 찾는다.
Bean Validation에서 특정 필드( FieldError )가 아닌 해당 오브젝트 관련 오류( ObjectError )는 @ScriptAssert() 를 사용하면 된다.
@Data
@ScriptAssert(lang = "javascript", script = "_this.price * _this.quantity >=10000")
public class Item {
...
}
그런데 실제 사용해보면 제약이 많고 복잡하다.
그리고 실무에서는 검증 기능이 해당 객체의 범위를 넘어서는 경우들도 종종 등장하는데, 그런 경우 대응이 어렵다.
따라서 오브젝트 오류(글로벌 오류)의 경우 @ScriptAssert 을 억지로 사용하는 것 보다는 다음과 같이 오브젝트 오류 관련 부분만 직접 자바 코드로 작성하는 것을 권장한다.
당연히 등록이 아닌 수정시에도 Validation이 필요하다.
하지만 데이터를 등록할 때와 수정할 때는 요구사항이 다를 수 있다.
그런데 이제까지 본 Item 객체에 Validation이 되있다면..?
한 객체로 다른 검증 애노테이션이 작동해야한다.
이것을 위해 BeanValidation은 group 기능을 제공한다.
등록시에 검증할 기능과 수정시에 검증할 기능을 각각 그룹으로 나누어 적용할 수 있다.
참고: @Valid 에는 groups를 적용할 수 있는 기능이 없다. 따라서 groups를 사용하려면 @Validated 를 사용해야 한다.**
하지만 실무에서는 groups 를 잘 사용하지 않는데, 그 이유가 다른 곳에 있다. 바로 등록시 폼에서 전달하는 데이터가 Item 도메인 객체와 딱 맞지 않기 때문이다.
그래서 등록용 DTO, 수정용 DTO를 통하는 것이 좋다!
만약 DTO가 없다면
HTML Form -> Item -> Controller -> Item -> Repository
의 과정을 거치는데 Item 도메인 객체를 컨트롤러, 리포지토리 까지 직접 전달해서 중간에 Item을 만드는 과정이 없어서 간단하지만,
간단한 경우에만 적용할 수 있다. 수정시 검증이 중복될 수 있고, groups를 사용해야 한다.
DTO를 사용한다면
HTML Form -> ItemSaveForm -> Controller -> Item 생성 -> Repository
전송하는 폼 데이터가 복잡해도 거기에 맞춘 별도의 폼 객체를 사용해서 데이터를 전달 받을 수있다.
보통 등록과, 수정용으로 별도의 폼 객체를 만들기 때문에 검증이 중복되지 않는다.
하지만 폼 데이터를 기반으로 컨트롤러에서 Item 객체를 생성하는 변환 과정이 추가된다.
수정의 경우 등록과 수정은 완전히 다른 데이터가 넘어온다. 생각해보면 회원 가입시 다루는 데이터와 수정시 다루는 데이터는 범위에 차이가 있다.
예를 들면 등록시에는 로그인id, 주민번호 등등을 받을 수 있지만, 수정시에는 이런 부분이 빠진다.
그리고 검증 로직도 많이 달라진다.
그래서 별도의 객체로 데이터를 전달받는 것이 좋다.
DTO의 중요성 등은 많이 보았으니 간단하게 넘어가도록 하자!
@Valid , @Validated 는 HttpMessageConverter ( @RequestBody )에도 적용할 수 있다.
참고
@ModelAttribute 는 HTTP 요청 파라미터(URL 쿼리 스트링, POST Form)를 다룰 때 사용한다.
@RequestBody 는 HTTP Body의 데이터를 객체로 변환할 때 사용한다. 주로 API JSON 요청을 다룰 때 사용한다.
@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;
}
위 컨트롤러를 이용해 JSON으로 데이터를 받아보자
API의 경우 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 "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"])]
price 의 값에 숫자가 아닌 문자를 전달해서 실패했다.
HttpMessageConverter 에서 요청 JSON을 Item 객체로 생성하는데 실패한다.
이 경우는 Item 객체를 만들지 못하기 때문에 컨트롤러 자체가 호출되지 않고 그 전에 예외가 발생한다.
물론 Validator도 실행되지 않는다.
로그
API 컨트롤러 호출
검증 오류 발생, errors=org.springframework.validation.BeanPropertyBindingResult: 1
quantity의 수량이 요구사항인 9999를 넘었다.
HttpMessageConverter 는 성공하지만 검증(Validator)에서 오류가 했다.
return bindingResult.getAllErrors(); 는 ObjectError 와 FieldError 를 반환한다.
스프링이 이 객체를 JSON으로 변환해서 클라이언트에 전달했다.
여기서는 예시로 보여주기 위해서 검증 오류 객체들을 그대로 반환했다.
실제 개발할 때는 이 객체들을 그대로 사용하지 말고, 필요한 데이터만 뽑아서 별도의 API 스펙을 정의하고 그에 맞는 객체를 만들어서 반환해야 한다.
HTTP 요청 파리미터를 처리하는 @ModelAttribute 는 각각의 필드 단위로 세밀하게 적용된다.
그래서 특정 필드에 타입이 맞지 않는 오류가 발생해도 나머지 필드는 정상 처리할 수 있었다.
HttpMessageConverter 는 @ModelAttribute 와 다르게 각각의 필드 단위로 적용되는 것이 아니라, 전체 객체 단위로 적용된다.
따라서 메시지 컨버터의 작동이 성공해서 Item 객체를 만들어야 @Valid , @Validated 가 적용된다.