Bean Validation
검증 기능을 지금처럼 매번 코드로 작성하는 것은 상당히 번거롭다.
또한 특정 필드에 대한 검증 로직은 대부분 빈 값인지 아닌지,
특정 크기를 넘는지 아닌지와 같이 매우 일반적인 로직이다.
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;
//...
}
이런 검증 로직을 모든 프로젝트에 적용할 수 있게 공통화하고, 표준화 한 것이 바로 Bean Validation 이다.
Bean Validation을 잘 활용하면, 애노테이션 하나로 검증 로직을 매우 편리하게 적용할 수 있다.
검증 애노테이션
- @NotBlank : 빈값 + 공백만 있는 경우를 허용하지 않는다.
- @NotNull : null 을 허용하지 않는다.
- @Range(min = 1000, max = 1000000) : 범위 안의 값이어야 한다.
- @Max(9999) : 최대 9999까지만 허용한다.
스프링 MVC는 어떻게 Bean Validator를 사용?
스프링 부트가 spring-boot-starter-validation 라이브러리를 넣으면
자동으로 Bean Validator를 인지하고 스프링에 통합한다.
스프링 부트는 자동으로 글로벌 Validator로 등록한다.
LocalValidatorFactoryBean 을 글로벌 Validator로 등록한다.
이 Validator는 @NotNull 같은 애노테이션을 보고 검증을 수행한다.
이렇게 글로벌 Validator가 적용되어 있기 때문에, @Valid , @Validated 만 적용하면 된다.
검증 오류가 발생하면, FieldError , ObjectError 를 생성해서 BindingResult 에 담아준다.
Bean Validation - 에러 코드
Bean Validation이 기본으로 제공하는 오류 메시지를 좀 더 자세히 변경하고 싶으면 어떻게 하면 될까?
Bean Validation을 적용하고 bindingResult 에 등록된 검증 오류 코드를 보면
NotBlank, Range와 같은 오류 코드를 기반으로 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 에 원하는 매시지를 넣으면 된다.
#Bean Validation 추가
NotBlank={0} 공백X
Range={0}, {2} ~ {1} 허용
Max={0}, 최대 {1}
BeanValidation 메시지 찾는 순서
- 생성된 메시지 코드 순서대로 messageSource 에서 메시지 찾기
- 애노테이션의 message 속성 사용 @NotBlank(message = "공백! {0}")
- 라이브러리가 제공하는 기본 값 사용 공백일 수 없습니다.
애노테이션의 message 사용 예
@NotBlank(message = "공백은 입력할 수 없습니다.")
private String itemName;
Bean Validation - 오브젝트 오류
Bean Validation에서 특정 필드( FieldError )가 아닌 해당 오브젝트 관련 오류( ObjectError )는 어떻게 처리할 수 있을까?
@Data
@ScriptAssert(
lang = "javascript",
script = "_this.price * _this.quantity >= 10000",
message = "총합이 10000원 넘게 설정해주세요"
)
public class Item {
//...
}
위 처럼 @ScreiptAssert() 를 사용 할 수 도 있지만 제약이 많고 복잡하므로 아래와 같이
오브젝트 오류 관련 부분만 직접 자바 코드로 작성하는 것을 권장한다.
ValidationItemControllerV3 - 글로벌 오류 추가
@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}";
}
Bean Validation - 한계
수정시 요구사항에 Id 값이 필수일 경우 다음과 같이 수정한다면 등록시 오류가 발생한다.
@Data
public class Item {
@NotNull //수정 요구사항 추가
private Long id;
//...
}
등록시에는 Id 값이 정해지지 않기 때문에 @NoutNull 부분에서 검증 오류가 발생하는 것이다.
이처럼 등록과 수정할 때 각각 다르게 검증하는 방법을 알아보자.
- BeanValidation의 groups 기능을 사용한다.
- Item을 직접 사용하지 않고, ItemSaveForm, ItemUpdateForm 같은
폼 전송을 위한 별도의 모델 객체를 만들어서 사용한다.
BeanValidation groups 기능 사용
이런 문제를 해결하기 위해 Bean Validation은 groups라는 기능을 제공한다.
예를 들어서 등록시에 검증할 기능과 수정시에 검증할 기능을 각각 그룹으로 나누어 적용할 수 있다
groups 적용
// 저장용 groups 생성
public interface SaveCheck {
}
// 수정용 groups 생성
public interface UpdateCheck {
}
Item - groups 적용
@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;
}
}
ValidationItemControllerV3 - 저장 로직에 SaveCheck Groups 적용
@PostMapping("/add")
public String addItemV2(@Validated(SaveCheck.class) @ModelAttribute Item item,
BindingResult bindingResult,
RedirectAttributes redirectAttributes) {
//...
}
ValidationItemControllerV3 - 수정 로직에 UpdateCheck Groups 적용
@PostMapping("/{itemId}/edit")
public String editV2(@PathVariable Long itemId,
@Validated(UpdateCheck.class) @ModelAttribute Item item,
BindingResult bindingResult) {
//...
}
groups 기능을 사용해서 등록과 수정시에 각각 다르게 검증을 할 수 있었다.
그런데 groups 기능을 사용하니 Item 은 물론이고, 전반적으로 복잡도가 올라갔다.
사실 groups 기능은 실제 잘 사용되지는 않는데, 그 이유는 실무에서는
주로 다음에 등장하는 등록용 폼 객체와 수정용 폼 객체를 분리해서 사용하기 때문이다.
Form 전송 객체 분리
실무에서는 회원 등록시 회원과 관련된 데이터만 전달받는 것이 아니라,
약관 정보도 추가로 받는 등 Item 과 관계없는 수 많은 부가 데이터가 넘어온다.
그래서 보통 Item 을 직접 전달받는 대신, 복잡한 폼 데이터를 컨트롤러까지 전달할 별도의 객체를 만들어서 전달한다.
예를 들면 ItemSaveForm 이라는 폼을 전달받는 전용 객체를 만들어서 @ModelAttribute 로 사용한다.
이것을 통해 컨트롤러에서 폼 데이터를 전달 받고, 이후 컨트롤러에서 필요한 데이터를 사용해서 Item 을 생성한다.
폼 데이터 전달에 Item 도메인 객체 사용
HTML Form -> Item -> Controller -> Item -> Repository
- 장점 : Item 도메인 객체를 컨트롤러, 리포지토리 까지 직접 전달해서 중간에 Item을 만드는 과정이 없어 간단하다.
- 단점 : 간단한 경우에만 적용할 수 있다. 수정시 검증이 중복될 수 있고, groups를 사용해야 한다.
폼 데이터 전달을 위한 별도의 객체 사용
HTML Form -> ItemSaveForm -> Controller -> Item 생성 -> Repository
- 장점 : 전송하는 폼 데이터가 복잡해도 거기에 맞춘 별도의 폼 객체를 사용해서 데이터를 전달 받을 수 있다.
보통 등록과, 수정용으로 별도의 폼 객체를 만들기 때문에 검증이 중복되지 않는다. - 단점 : 폼 데이터를 기반으로 컨트롤러에서 Item 객체를 생성하는 변환 과정이 추가된다.
Bean Validation - HTTP 메시지 컨버터
@Valid , @Validated 는 HttpMessageConverter ( @RequestBody )에도 적용할 수 있다.
- @ModelAttribute 는 HTTP 요청 파라미터(URL 쿼리 스트링, POST Form)를 다룰 때 사용한다.
- @RequestBody 는 HTTP Body의 데이터를 객체로 변환할 때 사용한다. 주로 API JSON 요청을 다룰 때 사용한다.
'백엔드 > 강의' 카테고리의 다른 글
[MVC 2편] 섹션 4. 검증1 - Validation (1) | 2023.08.15 |
---|---|
[MVC 2편] 섹션 3. 메시지, 국제화 (0) | 2023.08.15 |
[MVC 1편] 섹션 7. 스프링 MVC - 웹 페이지 만들기 (0) | 2023.08.07 |
[MVC 1편] 섹션 6. 스프링 MVC - 기본 기능 (0) | 2023.08.01 |
[MVC 1편] 섹션 5. 스프링 MVC - 구조 이해 (0) | 2023.08.01 |