스프링 MVC는 어떻게 Bean Validator를 사용?
스프링 부트가 spring-boot-starter-validation 라이브러리를 넣으면 자동으로 Bean Validator를
인지하고 스프링에 통합한다.
스프링 부트는 자동으로 글로벌 Validator로 등록한다.
LocalValidatorFactoryBean 을 글로벌 Validator로 등록한다.
이 Validator는 @NotNull 같은 애노테이션을 보고 검증을 수행한다. 이렇게 글로벌 Validator가 적용되어 있기 때문에, @Valid, @Validated 만 적용하면 된다.
검증 오류가 발생하면, FieldError , ObjectError 를 생성해서 BindingResult 에 담아준다.
주의!
다음과 같이 직접 글로벌 Validator를 직접 등록하면 스프링 부트는 Bean Validator를 글로벌
Validator 로 등록하지 않는다. 따라서 애노테이션 기반의 빈 검증기가 동작하지 않는다
검증 순서
1. @ModelAttribute 각각의 필드에 타입 변환 시도
2. 성공하면 다음으로
3. 실패하면 typeMismatch 로 FieldError 추가
4. Validator 적용
바인딩에 성공한 필드만 Bean Validation 적용★
• BeanValidator는 바인딩에 실패한 필드는 BeanValidation을 적용하지 않는다.★
• 생각해보면 타입 변환에 성공해서 바인딩에 성공한 필드여야 BeanValidation 적용이 의미 있다.
(일단 모델 객체에 바인딩 받는 값이 정상으로 들어와야 검증도 의미가 있다.)
• @ModelAttribute →각각의 필드 타입 변환시도→변환에 성공한 필드만 BeanValidation 적용
예)
• itemName 에 문자 "A" 입력 →타입 변환 성공→ itemName 필드에 BeanValidation 적용
• price 에 문자 "A" 입력→ "A"를 숫자 타입 변환 시도 실패→ typeMismatch FieldError 추가→
• price 필드는 BeanValidation 적용 X
Bean Validation - 에러 코드
• NotBlank 라는 오류 코드를 기반으로 MessageCodesResolver 를 통해 다양한 메시지 코드가 순서대로
생성된다.
@NotBlank
• NotBlank.item.itemName
• NotBlank.item.Name
• 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 메시지 찾는 순서★★
1. 생성된 메시지 코드 순서대로 messageSource에서 메시지 찾기
2. 애노테이션의 message속성 사용 → @NotBlank(message = "공백! {0}")
3. 라이브러리가 제공하는 기본 값 사용 → 공백일 수 없습니다.
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}";
}
edit() : Item 모델 객체에 @Validated 를 추가하자. BindingResult bindingresult 매개변수도 추가하자
Bean Validation - groups
동일한 모델 객체를 등록할 때와 수정할 때 각각 다르게 검증하는 방법을 알아보자.
방법 2가지
• BeanValidation의 groups 기능을 사용한다.
• Item을 직접 사용하지 않고, ItemSaveForm, ItemUpdateForm 같은 폼 전송을 위한 별도의 모델객체를 만들어서 사용한 다.
BeanValidation groups 기능 사용
이런 문제를 해결하기 위해 Bean Validation은 groups라는 기능을 제공한다.
예를 들어서 등록시에 검증할 기능과 수정시에 검증할 기능을 각각 그룹으로 나누어 적용할 수 있다.
코드로 확인해보자.
저장용 groups 생성
public interface SaveCheck {
}
수정용 groups 생성
public interface UpdateCheck {
}
Item- groups 적용
import lombok.Data;
import org.hibernate.validator.constraints.Range;
import javax.validation.constraints.Max;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
@Data
public class Item {
@NotNull(groups = UpdateCheck.class) //수정시에만 적용
private Long id;
@NotBlank(groups = {SaveCheck.class, UpdateCheck.class})
private String itemName;
@NotNull(groups = {SaveCheck.class, UpdateCheck.class})
@Range(min = 1000, max = 1000000, groups = {SaveCheck.class, UpdateCheck.class})
private Integer price;
@NotNull(groups = {SaveCheck.class, UpdateCheck.class})
@Max(value = 9999, groups = SaveCheck.class) //등록시에만 적용
private Integer quantity;
public Item() {
}
public Item(String itemName, Integer price, Integer quantity) {
this.itemName = itemName;
this.price = price;
this.quantity = quantity;
}
}
참고:@Valid에는 gropus를 적용할수 있는 기능이 없다. 따라서 groups를 사용하려면 @Validated를 사용해야 한다.
Form 전송 객체 분리
폼 객체 바인딩
@PostMapping("/add")
public String addItem(@Validated @ModelAttribute("item") ItemSaveForm form,
BindingResult bindingResult, RedirectAttributes redirectAttributes) {
//...
}
Item 대신에 ItemSaveform 을 전달 받는다. 그리고 @Validated 로 검증도 수행하고, BindingResult로 검증 결과도 받는다.
주의
@ModelAttribute("item") 에 item 이름을 넣어준 부분을 주의하자. 이것을 넣지 않으면 ItemSaveForm 의 경우 규칙에 의해 itemSaveForm 이라는 이름으로 MVC Model에 담기게 된다.
이렇게 되면 뷰 템플릿에서 접근하는 th:object 이름도 함께 변경해주어야 한다.
HTTP 메시지 컨버터
@Valid , @Validated 는 HttpMessageConverter ( @RequestBody )에도 적용할 수 있다.
참고
• @ModelAttribute 는 HTTP 요청 파라미터(URL 쿼리 스트링, POST Form)를 다룰 때 사용한다.
• @RequestBody 는 HTTP Body의 데이터를 객체로 변환할 때 사용한다. 주로 API JSON 요청을 다룰 때
사용한다.
API의 경우 3가지 경우를 나누어 생각해야 한다.
• 성공 요청: 성공
• 실패 요청: JSON을 객체로 생성하는 것 자체가 실패함
• 검증 오류 요청: JSON을 객체로 생성하는 것은 성공했고, 검증에서 실패함
@ModelAttribute vs @RequestBody
HTTP 요청 파리미터를 처리하는 @ModelAttribute 는 각각의 필드 단위로 세밀하게 적용된다. 그래서
특정 필드에 타입이 맞지 않는 오류가 발생해도 나머지 필드는 정상 처리할 수 있었다.
HttpMessageConverter 는 @ModelAttribute 와 다르게 각각의 필드 단위로 적용되는 것이 아니라,
전체 객체 단위로 적용된다.
따라서 메시지 컨버터의 작동이 성공해서 ItemSaveForm 객체를 만들어야 @Valid , @Validated 가
적용된다.
• @ModelAttribute 는 필드 단위로 정교하게 바인딩이 적용된다. 특정 필드가 바인딩 되지 않아도 나머지
필드는 정상 바인딩 되고, Validator를 사용한 검증도 적용할 수 있다.
• @RequestBody 는 HttpMessageConverter 단계에서 JSON 데이터를 객체로 변경하지 못하면 이후
단계 자체가 진행되지 않고 예외가 발생한다. 컨트롤러도 호출되지 않고, Validator도 적용할 수 없다.
참고
HttpMessageConverter 단계에서 실패하면 예외가 발생한다. 예외 발생시 원하는 모양으로 예외를
처리하는 방법은 예외 처리 부분에서 다룬다.
'스프링 > 스프링 MVC 패턴 2편' 카테고리의 다른 글
4. 검증1 - Validation (0) | 2022.11.30 |
---|---|
3. 메시지, 국제화 (0) | 2022.11.29 |
2. 타임 리프 - 스프링 통합과 폼 (0) | 2022.11.28 |
1. 타임리프 - 기본 기능 (0) | 2022.10.28 |