728x90
1. Bean Validation 소개
- Bean Validation
- 검증 로직을 모든 프로젝트에 적용할 수 있게 공통화하고, 표준화 한 것
- @NotBlank @NotNull @Range @Max ...
- 특정한 구현체가 아니라 JSR-380이라는 기술 표준 == 검증 애노테이션 + 인터페이스
- 구현체 : 하이버네이트 Validator
- Bean Validation 구현 기술 중 일반적으로 사용하는 구현체
- 검증 애노테이션 모음(한번보는 걸 추천): https://docs.jboss.org/hibernate/validator/6.2/reference/en-US/html_single/#validator-defineconstraints-spec
2. Bean Validation - 시작
- 스프링과 통합하지 않는 순수한 Bean Validation 사용하는 방법
1. 의존관계 추가
# build.gradle
implementation 'org.springframework.boot:spring-boot-starter-validation'
2. 의존 관계 추가로 추가된 라이브러리
- 해당 Library를 설치하면 아래 2가지의 Package가 같이 설치가 된다.
- jakarta.validation-api : Bean validation 인터페이스
- 이것 자체로만 구현 불가
- 자바 표준이기에 관련 어떤 구현체든 해당 인터페이스의 Validatoin 기능이 적용 가능하다. ex) @NotNull
- hibernate-validator 구현체
- 실무에서 Bean validation 인터페이스의 구현체로 거의 사용한다.
- 하이버네트 자체적인 어노테이션 존재한다. ex) @Range
- jakarta.validation-api : Bean validation 인터페이스
Bean Validation 사용 예
- 이렇게 어노테이션을 붙여서 사용을 할 수 있다는 것이지 어노테이션만 붙였다고 바로 작동되는 것은 아니다.
- 작동 방식은 Bean Validation - 스프링 적용 part에 설명 해놓았다.
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 {
private Long id;
@NotBlank // java표준 인터페이스의 검증 어노테이션으로 구현체인 하이버네트가 구현해준다.
private String itemName;
@NotNull
@Range(min = 1000, max = 100000) // 하이버네트 검증 어노테이션으로 하이버네트가 직접 가지고 있는 것
private Integer price;
@NotNull
@Max(9999)
private Integer quantity;
public Item() {
}
public Item(String itemName, Integer price, Integer quantity) {
this.itemName = itemName;
this.price = price;
this.quantity = quantity;
}
}
- Bean Validation이 import 된 것을 알 수 있다.
- javax.validation: 특정 구현에 관계없이 제공되는 표준 인터페이스이다.
- 주로 어노테이션을 제공 - 구현은 다른 구현체(인터페이스를 상속받은)들이 해줌
- 클래스도 존재 : Validation.java - spring 동작시 안씀. java로만 할 때 사용
- org.hibernate.validation: 하이버네이트 구현체가 직접 가진 검증 어노테이션일때 import 됨
- javax.validation: 특정 구현에 관계없이 제공되는 표준 인터페이스이다.
- 검증 annotation
- @NotBlank: 빈값 + 공백만 있는 경우를 허용하지 않는다.
- @NotNull: null을 허용하지 않는다.
- @Range(min = 1000, max = 1000000): 범위 안의 값이어야 한다.
- @Max(9999): 최대 9999까지만 허용한다.
- test code
public class BeanValidationTest{
@Test
void beanValidaion() {
// 검증기 생성
ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
Validator validator = factory.getValidator();
// 실제 Item 담는다
Item item = new Item();
item.setItemName(" "); // 문제가 되는 부분
item.setPrice(0);
item.setQuantity(10000);
// 검증 실행
Set<ConstrainViolation<Item>> violations = validator.validate(item);
for (ConstrainViolation<Item> violation : violations){
System.out.println("violation=" + violation);
System.out.println("violation.message=" + violation,getMessage());
}
}
}
- 검증기 생성
- test할 때나 쓰지 spring과 통합하여 사용시 직접 작성할 경우 없다.
- 검증기: validator
- 검증 실행
- item을 검증기에 직접 넣고 결과를 set에 반환한다.
- ConstraintViolation: 검증오류
- 오류가 없을 시 값이 비어있다.
- 검증 결과
- 검증 오류가 발생한 객체, 필드, 메시지 정보등 다양한 정보를 확인할 수 있다.
3. Bean Validation - 스프링 적용
- ValidationItemControllerV3
@Slf4j
@Controller
@RequestMapping("/validation/v3/items")
@RequiredArgConstuctor
public class ValidationItemControllerV3 {
private final ItemRepository itemRepository;
// 직접 만들었던 검증 logic 제거
// private final ItemValidator itemValidator;
// @InitBinder controller를 호출할 때마다 검증을 하기 위해 먼저 호출되는 것. 그리고 항상 new로 호출
// public void init(WebDataBinder dataBinder){
// dataBinder.addValidators(itemValidator);
// }
...
@PostMapping("/add")
// @Validated로 BeanValidation 적용
public String addItem(@Validated @ModelAttribute Item, BindingResult bindingResult, RedirectAttributes redirectAttributes){
if(bindingResult.hasErrors()){
return "validation/v3/addForm";
}
// 성공로직
Item savedItem = itemRepository.save(item);
redirectAttributes.addAttribute("itemId", savedItem.getId());
redirectAttributes.addAttribute("status", true);
return "redirct:/validation/v3/items/{itemId}";
}
...
}
- 이전 버전에서는 직접 만든 ItemValidator 검증기를 사용했었다.
- 검증기 부분을 지우고 controller를 호출하면 따로 검증기를 만들지 않았는데도 정상 작동을 한다.
4. 검증기 작동 이유
- 스프링 MVC가 어떻게 Bean Validator(== 검증기)를 사용하나?
- build.gradle - implementation 'org.springframework.boot:spring-boot-starter-validation' 라이브러리를 넣으면 스프링 부트가 자동으로 Bean Validator(검증기) 찾아서 스프링에 통합시킨다.
- 스프링 부트 동작
- 등록한 Bean Validator 이름은 LocalValidatorFactoryBean(검증기) 이다.
- 글로벌 검증기로 등록
- 글로벌 검증기(LocalValidatorFactoryBean)이 적용되었기 때문에 @Valid, @Validated만 적용하면 된다.
- 해당 어노테이션을 보고 SpringFrameWork가 검증기를 찾아서 검증을 실시한다.
- @Valid: 자바표준 검증 어노테이션으로 아까 위의 library를 설치하지 않으면 작동X
- @Validated: 스프링 전용 검증 어노테이션
- 스프링 부트 동작
- build.gradle - implementation 'org.springframework.boot:spring-boot-starter-validation' 라이브러리를 넣으면 스프링 부트가 자동으로 Bean Validator(검증기) 찾아서 스프링에 통합시킨다.
- 주의점
- 개인이 직접 글로벌 Validator(검증기)를 등록시, springboot는 Bean Validator(검증기=Local...)을 검증기로 등록하지 않는다.
- 어노테이션 기반 빈 검증기 동작 안함.
5. 검증 순서
- @ModelAttribute가 request로 부터 받은 params를 각 필드에 타입 변환 시도
- 성공시 -> Validator 적용
- 실패시 -> typeMismatch로 FieldError 추가 - 해당 controller의 @PostMapping의 method에 BindingResult가 존재해야함
- Validator 적용
- 1.에서 성공한 필드만 Bean Validation 적용
- Bean validator는 바인딩에 실패한 field(field에 못들어간 값)는 Bean Validation을 적용하지 않는다.
- 모델 객체에 바인딩 받는 값이 정상으로 들어와야 검증도 의미가 있다.
- 1.에서 성공한 필드만 Bean Validation 적용
6. Bean Validation - 에러코드
- 해당 어노테이션 오류코드를 기반으로 MessageCodesResolver를 통해 다양한 메시지 코드가 순서대로 생성
- 어노테이션 이름.class명.필드이름
- 어노테이션 이름.필드이름
- 어노테이션 이름.자바클래스
- 어노테이션 이름
- @NotBlank
- NotBlank.item.itemName
- NotBlank.itemName
- NotBlank.java.lang.String
- NotBlank
- @Range
- Range.item.price
- Range.price
- Range.java.lang.Integer
- Range
- 메시지 등록
- {0}: 필드명
- {1}, {2}: 어노테이션 마다 할당 된 값이 존재
# errors.properties
NotBlank={0} 공백X
Range={0}, {2} ~{1} 허용
Max={0}, 최대 {1}
- BeanValidation 메시지 찾는 순서
- 생성된 메시지 코드 구체화 순서대로 messageSource에서 메시지 찾기
- errors 에서 메시지 코드를 찾을 수 있는 이유는 application.properties에서 errors를 등록했기 때문
- 어노테이션 message 속성 사용
- 1.번에 메시지 코드가 없을 때 default값으로 사용
- @NotBlank(message = "공백은 입력 불가")
- 라이브러리가 제공하는 기본 값 사용
- 2.번도 없을 때, library에서 나오는 정보 사용
- 생성된 메시지 코드 구체화 순서대로 messageSource에서 메시지 찾기
7. Bean Validation - 오브젝트 오류
7.1. @ScriptAssert()
- @ScriptAssert() 사용
- 메시지 코드 자동 생성
- ScriptAssert.item
- scriptAssert
- 실무에서 사용하기에 제약이 너무 많다.
- 메시지 코드 자동 생성
@Data
@ScriptAsset(lang="javascript", script="_this.price*_this.quantity >= 10000")
public class Item {
...
}
7.2. 자바 logic
- 오브젝트 오류의 경우 해당부분만 직접 자바 코드로 작성하는 것을 권장
ValidationItemControllerV3 - 글로벌 오류 추가
@PostMapping("/add")
public String addItem(@Validated @ModelAttribue 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()){
return "validation/v3/addForm";
}
...
}
- 에러 메시지 추가
# ObjectError
# Level1
totalPriceMin.item=상품의 가격 * 수량의 합은 {0}원 이상이어야 합니다. 현재 값 = {1}
8. Bean Validation - 한계
- 수정 form 과 등록 form의 요구사항이 다르다
- 수정
- 수량을 무제한으로 변경 가능
- id값이 필수
- 등록
- 수량은 9999개로 고정
- id값은 등록시 필요 없음
- 수정
- 동일 Item class에서 검증 어노테이션으로 다른 상황에 맞게 사용이 힘들다.
- id 값에 @NotNull을 등록시, 상품등록자체가 막힌다.
- 해결방안
- Bean Validation - groups 기능 사용
- Item을 직접 사용하지 않고 form 전송을 위한 별도의 모델 객체를 만들어서 사용
9. Bean Validation -groups
- 잘 사용 안한다.
- 전반적인 복잡도가 올라가기 때문
- 실무에선 주로 등록용 폼 객체와 수정용 폼 객체를 분리해서 사용하기 때문
- bean validation이 제공하는 기능
- 등록과 수정의 검증 기능을 각각 그룹으로 나누어 적용할 수 있다.
- groups를 사용하기 위해선 @Validated만 사용 가능하다.
- 각 그룹 생성
package hello.itemservice.domain.item;
public interface SaveCheck{}
package hello.itemservice.domain.item;
public interface UpdateCheck{}
- Item - groups 적용
package hello.itemservice.domain.item;
@Data
public class Item {
@NotNull(groups = UpdateCheck.class) // 수정시에만 적용
private Long id;
@NotBlank(groups = {SaveCheck.class, UpdateCheck.class}) // 수정과 등록에 둘다 적용
private String itemName;
...
@Max(value = 9999, groups = SaveCheck.class) // 등록시에만 적용
private nteger quantity;
}
- Controller 저장,수정 post 부분에 각 group 지정
@PostMapping("/add")
public String addItemV2(@Validated(SaveCheck.class) @ModelAttribue Item item, BindingResult bindingResult, RedirecAttributes redirectAttributes){
...
}
@PostMapping("/edit")
public String editV2(@PathVaribale Long itemId, @Validated(UpdateCheck.class) @ModelAttribue Item item, BindingResult bindingResult){
...
}
10. Form 전송 객체 분리 - 소개
- 실무에서 groups를 잘 사용하지 않는 이유
- 등록시 폼에서 전달하는 데이터가 Item domain 객체와 딱 맞지 않기 때문
- Hello World 세상에서는 맞을지 몰라도 Real World에서는 등록시 약관 정보를 비롯한 부가 데이터가 넘어온다.
- 실무
- 그래서 보통 item을 직접 전달 받는 것이 아니라 별도의 객체를 만들어서 controller에거 데이터를 전달한다.
- 이후 컨트롤러에서 필요데이터를 가지고 Item을 생성한다.
- HelloWorld 도메인객체와 RealWorld 도메인 객체 전달과정 비교
- HelloWorld
- HTML Form -> Item -> Controller -> Item -> Repository
- 장점: Item 도메인 객체를 컨트롤러, 리포지토리 까지 직접 전달해서 중간에 Item을 만드는 과정이 없어서 간단하다.
- 단점: 간단한 경우에만 적용할 수 있다. 수정시 검증이 중복될 수 있고, groups를 사용해야 한다
- HTML Form -> Item -> Controller -> Item -> Repository
- RealWorld
- HTML Form -> ItemSaveForm -> Controller -> Item 생성 -> Repository
- 장점: 전송하는 폼 데이터가 복잡해도 거기에 맞춘 별도의 폼 객체를 사용해서 데이터를 전달 받을 수 있다. 보통 등록과, 수정용으로 별도의 폼 객체를 만들기 때문에 검증이 중복되지 않는다.
- 단점: 폼 데이터를 기반으로 컨트롤러에서 Item 객체를 생성하는 변환 과정이 추가된다.
- 장점: 전송하는 폼 데이터가 복잡해도 거기에 맞춘 별도의 폼 객체를 사용해서 데이터를 전달 받을 수 있다. 보통 등록과, 수정용으로 별도의 폼 객체를 만들기 때문에 검증이 중복되지 않는다.
- HTML Form -> ItemSaveForm -> Controller -> Item 생성 -> Repository
- HelloWorld
11. Form 전송 객체 분리 - 개발
- Item 원복
@Data
public class Item {
private Long id;
private String itemName;
private Integer price;
private Integer quantity;
}
- ItemSaveForm
package hello.itemservice.web.validation.form;
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 ItemSaveForm {
@NotBlank
private String itemName;
@NotNull
@Range(min = 1000, max = 1000000)
private Integer price;
@NotNull
@Max(value = 9999)
private Integer quantity;
... 현실에서는 동의서 클릭 했는지, 주민등록번호, 개인정보동의, 마케팅 정보 등등 추가적인 조건이 더 존재
}
- ItemUpdateForm
package hello.itemservice.web.validation.form;
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 ItemUpdateForm {
@NotNull
private Long id;
@NotBlank
private String itemName;
@NotNull
@Range(min = 1000, max = 1000000)
private Integer price;
//수정에서는 수량은 자유롭게 변경 가능
private Integer quantity;
}
- Controller 수정
- 주의: @ModelAttribute("item")에 item 이름을 넣어줘야지 MVC Model에 담길 때 item으로 key값이 유지 된다.
- 검증 대상을 ItemSaveForm form으로 지정해줌
- 복합 룰 검증은 java logic으로 직접 구현
@PostMapping("/add")
// 검증 대상을 ItemSaveForm form으로 지정해줌
public String addItem(@Validated @ModelAttribute("item") ItemSaveForm form, BindingResult bindingResult, RedirectAttributes redirectAttributes) {
// 특정 필드가 아닌 복합 룰 검증 - @ScriptAsset() 사용보다 java code 이용하는 것을 추천
if (form.getPrice() != null && form.getQuantity() != null){
int resultPrice = form.getPrice() * form.getQuantity();
if (resultPrice < 10000){
bindingResult.reject("totalPriceMin", new Object[]{10000, resultPrice}, null);
}
}
// 검증에 실패하면 다시 입력 폼으로
if (bindingResult.hasErrors()) {
log.info("errors = {}", bindingResult);
return "validation/v4/addForm";
}
// 성공 로직
// 성공 로직에서는 실제 data를 저장하는 공간을 Item item으로 설정
Item item = new Item(form.getItemName(),form.getPrice(), form.getQuantity());
Item savedItem = itemRepository.save(item);
redirectAttributes.addAttribute("itemId", savedItem.getId());
redirectAttributes.addAttribute("status", true);
return "redirect:/validation/v4/items/{itemId}";
}
- 검증 대상을 ItemUpdateForm form으로 지정해줌
- 작성 순서 : @pathVariable -> @Validated -> @ModelAttribute -> BindingResult
@PostMapping("/{itemId}/edit")
public String edit(@PathVariable Long itemId, @Validated @ModelAttribute("item") ItemUpdateForm form, BindingResult bindingResult) {
// 특정 필드가 아닌 복합 룰 검증 - @ScriptAsset() 사용보다 java code 이용하는 것을 추천
if (form.getPrice() != null && form.getQuantity() != null){
int resultPrice = form.getPrice() * form.getQuantity();
if (resultPrice < 10000){
bindingResult.reject("totalPriceMin", new Object[]{10000, resultPrice}, null);
}
}
// 검증에 실패하면 다시 입력 폼으로
if (bindingResult.hasErrors()) {
log.info("errors = {}", bindingResult);
return "validation/v4/editForm";
}
// 성공 로직
// 수정한 item 실제 Item item에 넣어준다.
Item itemParam = new Item();
itemParam.setItemName(form.getItemName());
itemParam.setPrice(form.getPrice());
itemParam.setQuantity(form.getQuantity());
itemRepository.update(itemId, itemParam);
return "redirect:/validation/v4/items/{itemId}";
}
12. Bean Validation - HTTP 메시지 컨버터
- @Valid, @Validated는 HttpMessageConverter(@RequestBody)에도 적용 가능하다.
- 참고
- @ModelAttribute: HTTP 요청 파라미터 다룰 때 사용
- @RequestBody: HTTP Body의 데이터를 객체로 변환할 때 사용 - API JSON 요청 다룰 때 사용
- Controller 생성
- PostMan으로 검증
- API 접근시 3가지 검증 결과가 존재
- 성공 요청
- 실패 요청
- 객체 내부 값의 type error가 발생시 JSON을 객체로 생성하는 것 자체가 실패
- addItem의 @RequestBody가 HTTP messageConverter가 @RequestBody를 보고 ItemSaveForm을 이용해서 JSON을 만들려고 하는데 Tye error로 ItemSaveForm 객체 생성자체를 실패함
- 검증 오류 요청
- type은 일치하여 binding에는 성공하여 JSON을 객체로 생성하는 것을 성공했지만, 검증에서 실패
- 성공 요청
- PostMan으로 검증
@Slf4j
@RestController
@RequestMapping("/validation/pai/items")
public class ValidationItemApiController {
// postMan - Body - raw - json {"itemName":"hello", "price": "1000", "quantity": 100000}
@PostMapping("/add")
public Object addItem(@RequestBody @Validated ItemSaveForm form, BindingResult bindingResult){
//HttpMessageConvert가 @RequestBody를 보고 ItemSaveForm을 이용해서 JSON을 넘겨준다.
//HttpMessageConvert는 @ModelAttribute와 다르게 각각의 field 단위로 적용되는 것이 아니라 전체 객체 단위로 적용된다. - type이 다르면 controller 호출 자체가 안되는 예외 발생
log.info("API 컨트롤러 호출");
if (bindingResult.hasErrors()){
log.info("검증 오류 발생");
return bindingResult.getAllErrors();
}
log.info("성공 로직 실행");
return form;
}
}
- 참고