Bean Validation - 스프링 적용
스프링 MVC는 어떻게 Bean Validator를 사용?
스프링 부트가 spring-boot-starter-validation 라이브러리를 넣으면 자동으로 Bean Validator를 인지하고 스프링에 통합한다.
스프링 부트는 자동으로 글로벌 Validator로 등록한다.
LocalValidatorFactoryBean 을 글로벌 Validator로 등록한다. 이 Validator는 @NotNull 같은 애노테이션을 보고 검증을 수행한다. 이렇게 글로벌 Validator가 적용되어 있기 때문에, @Valid , @Validated 만 적용하면 된다.
검증 오류가 발생하면, FieldError , ObjectError 를 생성해서 BindingResult 에 담아준다. (검증 결과)
@PostMapping("/add")
public String addItem(@Validated @ModelAttribute Item item, BindingResult bindingResult,
RedirectAttributes redirectAttributes) {
//검증에 실패하면 다시 입력 폼으로
if(bindingResult.hasErrors()){
log.info("bindingResult = {}", bindingResult);
return "validation/v3/addForm";
}
//성공 로직
}
주의!
글로벌 Validator를 직접 등록하면 스프링 부트는 Bean Validator를 글로벌 Validator 로 등록하지 않는다.
따라서 애노테이션 기반의 빈 검증기가 동작하지 않는다.
참고: @Validated @Valid 둘다 사용가능하다.
javax.validation.@Valid 를 사용하려면 build.gradle 의존관계 추가가 필요하다.
> implementation 'org.springframework.boot:spring-boot-starter-validation'
@Validated 는 스프링 전용 검증 애노테이션이고, @Valid 는 자바 표준 검증 애노테이션이다. 둘 중 아무거나 사용해도 동일하게 작동하지만, @Validated 는 내부에 groups 라는 기능을 포함하고 있다.
검증 순서
1. @ModelAttribute 각각의 필드에 타입 변환 시도 (타입 바인딩)
성공하면 다음으로, 실패하면 typeMismatch 로 FieldError 추가
2. Validator 적용 (검증 애노테이션)
바인딩에 성공한 필드만 Bean Validation 적용
BeanValidator는 바인딩에 실패한 필드는 BeanValidation을 적용하지 않는다.
타입 변환에 성공해서 바인딩에 성공한 필드여야 BeanValidation 적용이 의미 있다. (일단 모델 객체에 바인딩 받는 값이 정상으로 들어와야 검증도 의미가 있다.)
@ModelAttribute -> 각각의 필드 타입 변환시도 -> 변환에 성공한 필드만 BeanValidation 적용
예) price 에 문자 "A" 입력 "A"를 숫자 타입 변환 시도 실패 -> typeMismatch FieldError 추가 (관련 오류 메세지만 출력)
-> price 필드는 BeanValidation 적용 X
Bean Validation - 에러 코드
목표
Bean Validation이 기본으로 제공하는 오류 메시지를 변경하기
Bean Validation을 적용하고 bindingResult 에 등록된 검증 오류 코드를 보자.
오류 코드가 애노테이션 이름으로 등록된다. 마치 typeMismatch 와 유사하다.
@NotBlank -> [NotBlank.item.itemName, NotBlank.itemName, NotBlank.java.lang.String, NotBlank]
@Range -> [Range.item.price, Range.price, Range.java.lang.Integer, Range]
메세지 등록
> errors.properties
#Bean Validation 추가
NotBlank={0} 공백X
Range={0}, {2} ~ {1} 허용
Max={0}, 최대 {1}
{0} 은 필드명이고, {1} , {2} ...은 각 애노테이션 마다 다르다.
레벨에 따라 메세지를 계층화하면 더 체계적으로 관리할 수 있다.
BeanValidation 메시지 찾는 순서
- 생성된 메시지 코드 순서대로 messageSource 에서 메시지 찾기
- 애노테이션의 message 속성 사용 -> @NotBlank(message = "공백! {0}")
- 라이브러리가 제공하는 기본 값 사용 -> 공백일 수 없습니다.
Bean Validation - 오브젝트 오류
Bean Validation이 특정 필드( FieldError ) 에 애노테이션으로 정의되어 있다면, 오브젝트 오류( ObjectError )는 어떻게 처리할 수 있을까?
org.hibernate.validator.@ScriptAssert()
@Data
@ScriptAssert(lang = "javascript", script = "_this.price * _this.quantity >= 10000")
public class Item {
//...
}
메시지 코드
ScriptAssert.item
ScriptAssert
그런데 실제 사용해보면 제약이 많고 복잡하다.
그리고 실무에서는 검증 기능이 해당 객체의 범위를 넘어서는 경우들도 종종 등장하는데, 그런 경우 대응이 어렵다.
따라서 오브젝트 오류(글로벌 오류)의 경우 @ScriptAssert 을 억지로 사용하는 것 보다는 다음과 같이 오브젝트 오류 관련 부분만 직접 자바 코드로 작성하는 것을 권장한다.
Bean Validation - 수정에 적용
컨트롤러
- 검증 대상(모델 객체)에 @Validated, BindingResult 파라미터
- 오브젝트 오류 검증 코드
- 검증 오류가 발생하면 editForm 으로 이동하는 코드
HTML
- .field-error css
- 글로벌 오류 메시지
- 상품명, 가격, 수량 필드에 검증 기능
Bean Validation - 한계
데이터를 등록할 때와 수정할 때는 요구사항이 다를 수 있다.
수정시 요구사항
- 등록시에는 quantity 수량을 최대 9999까지 등록할 수 있지만 수정시에는 수량을 무제한으로 변경할 수 있다.
- 등록시에는 id 에 값이 없어도 되지만, 수정시에는 id 값이 필수이다.
모델 객체 필드에 애노테이션을 달아서 검증하는 BeanValidation 특성 상 등록과 수정에서 검증 조건의 충돌이 발생하고, 등록과 수정은 같은 BeanValidation 을 적용할 수 없다. -> Bean Validation - groups
참고: 현재 구조에서는 수정시 item 의 id 값은 항상 들어있도록 로직이 구성되어 있다. 그래서 검증하지 않아도 된다고 생각할 수 있다. 그런데 HTTP 요청은 언제든지 악의적으로 변경해서 요청할 수 있으므로 서버에서 항상 검증해야 한다. 예를 들어서 HTTP 요청을 변경해서 item 의 id 값을 삭제하고 요청할 수도 있다. 따라서 최종 검증은 서버에서 진행하는 것이 안전한다.
해결 방법 2가지
- BeanValidation의 groups 기능 사용
- Item을 직접 사용하지 않고, ItemSaveForm, ItemUpdateForm 같은 폼 전송을 위한 별도의 모델 객체를 만들어서 사용
Bean Validation - groups
목표
동일한 모델 객체를 등록할 때와 수정할 때 각각 다르게 검증하기
BeanValidation groups 기능
검증 기능을 그룹으로 나누어 적용할 수 있다.
1. groups 을 분류할 때 사용할 인터페이스 생성 (단순 마커용이기 때문에 별다른 기능 추가X)
2. BeanValidation 애노테이션의 멤버 groups 에 인터페이스(Class[]) 지정
3. 컨트롤러 @Validated의 멤버 value에 인터페이스 지정
참고: @Valid 에는 groups를 적용할 수 있는 기능이 없다. 따라서 groups를 사용하려면 @Validated 를 사용해야 한다.
정리
groups 기능을 사용해서 등록과 수정시에 각각 다르게 검증을 할 수 있었다. 그런데 groups 기능을 사용하니 Item 은 물론이고, 전반적으로 복잡도가 올라갔다.
사실 groups 기능은 실제 잘 사용되지는 않는데, 그 이유는 실무에서는 주로 등록용 폼 객체와 수정용 폼 객체를 분리해서 사용하기 때문이다.
Form 전송 객체 분리 - 소개
실무에서는 groups 를 잘 사용하지 않는데, 등록이나 수정 시 폼에서 전달하는 데이터가 도메인 객체와 딱 맞지 않기 때문이다. 그에 따라 검증 로직도 많이 달라진다.
그래서 보통은 도메인 객체를 직접 전달받는 것이 아니라, 복잡한 폼의 데이터를 컨트롤러까지 전달할 별도의 객체를 만들어서 전달한다. 예를 들면 ItemSaveForm 이라는 폼을 전달받는 전용 객체를 만들어서 @ModelAttribute 로 사용한다. 이것을 통해 컨트롤러에서 폼 데이터를 전달 받고, 이후 컨트롤러에서 필요한 데이터를 사용해서 Item 을 생성한다.
폼 데이터 전달에 Item 도메인 객체 사용
HTML Form -> Item -> Controller -> Item -> Repository
장점: Item 도메인 객체를 컨트롤러, 리포지토리 까지 직접 전달해서 중간에 Item을 만드는 과정이 없어서 간단하다.
단점: 간단한 경우에만 적용할 수 있다. 수정시 검증이 중복될 수 있고, groups를 사용해야 한다.
폼 데이터 전달을 위한 별도의 객체 사용
HTML Form -> ItemSaveForm -> Controller -> Item 생성 -> Repository
장점: 전송하는 폼 데이터가 복잡해도 거기에 맞춘 별도의 폼 객체를 사용해서 데이터를 전달 받을 수 있다. 보통 등록과, 수정용으로 별도의 폼 객체를 만들기 때문에 검증이 중복되지 않는다.
단점: 폼 데이터를 기반으로 컨트롤러에서 Item 객체를 생성하는 변환 과정이 추가된다.
Form 전송 객체 분리 - 개발
폼 객체 바인딩
@PostMapping("/add")
public String addItem(@Validated @ModelAttribute("item") ItemSaveForm form,
BindingResult bindingResult, RedirectAttributes redirectAttributes) {
//ItemSaveForm 객체를 Item 객체로 변환하는 로직
}
Item 대신에 ItemSaveform 을 전달 받는다. 그리고 @Validated 로 검증도 수행하고, BindingResult 로 검증 결과도 받는다.
주의
@ModelAttribute("item") 에 item 이름을 넣어준 부분을 주의하자. 이것을 넣지 않으면 ItemSaveForm 의 경우 규칙에 의해 itemSaveForm 이라는 이름으로 MVC Model에 담기게 된다. 이렇게 되면 뷰 템플릿에서 접근하는 th:object 이름도 함께 변경해주어야 한다.
model.addAttribute("itemSaveForm", form) <-> th:object="${item}"
정리
Form 전송 객체 분리해서 등록과 수정에 딱 맞는 기능을 구성하고, 검증도 명확히 분리했다.
Bean Validation - HTTP 메시지 컨버터
@Valid , @Validated 는 HttpMessageConverter ( @RequestBody )에도 적용할 수 있다.
참고: @ModelAttribute 는 HTTP 요청 파라미터(URL 쿼리 스트링, POST Form)를 다룰 때 사용한다.
@RequestBody 는 HTTP Body의 데이터를 객체로 변환할 때 사용한다. 주로 API JSON 요청을 다룰 때 사용한다.
API의 경우 3가지 경우를 나누어 생각해야 한다.
- 성공 요청: 성공
- 실패 요청(400, bad request): JSON을 객체로 생성하는 것 자체가 실패함. 이경우 컨트롤러가 호출되지도 않음
- 검증 오류 요청: JSON을 객체로 생성하는 것은 성공했고, 검증에서 실패함
bindingResult.getAllErrors() 는 ObjectError 와 FieldError 를 반환한다.
실제 개발할 때는 이 객체들을 그대로 반환하지 말고, 필요한 데이터만 뽑아서 별도의 API 스펙을 정의하고 그에 맞는 객체를 만들어서 반환해야 한다.
@ModelAttribute vs @RequestBody
HTTP 요청 파리미터를 처리하는 @ModelAttribute 는 각각의 필드 단위로 세밀하게 적용된다. 그래서 특정 필드에 타입이 맞지 않는 오류가 발생해도 나머지 필드는 정상 처리할 수 있었다.
HttpMessageConverter 는 @ModelAttribute 와 다르게 각각의 필드 단위로 적용되는 것이 아니라, 전체 객체 단위로 적용된다. 따라서 메시지 컨버터의 작동이 성공해서 ItemSaveForm 객체를 만들어야 @Valid , @Validated 가 적용된다.
- @ModelAttribute 는 특정 필드가 바인딩 되지 않아도 나머지 필드는 정상 바인딩 되고, Validator를 사용한 검증도 적용할 수 있다.
- @RequestBody 는 HttpMessageConverter 단계에서 JSON 데이터를 객체로 변경하지 못하면 이후 단계 자체가 진행되지 않고 예외가 발생한다. 따라서, 컨트롤러도 호출되지 않고, Validator도 적용할 수 없다.
참고: HttpMessageConverter 단계에서 실패하면 예외가 발생한다. 예외 발생시 원하는 모양으로 예외를 처리하는 방법은 예외 처리 부분에서 다룬다.