Bean Validation 소개
- 지금까지 작성했던 검증 로직 관련 코드를 하나의 어노테이션으로 끝낼 수 있는 기능
- 대상 객체에 대해서 검증 로직을 모든 프로젝트에 적용할 수 있게 공통화하고, 표준화 한 것
- Bean Validation 이란? → 특정한 구현체가 아니라 Bean Validation 2.0(JSR-380)이라는 기술 표준. 즉, 검증 애노테이션과 여러 인터페이스의 모음. 이를 구현한 기술 중에서 일반적으로 사용하는 구현체는 하이버네이트 Validator (ORM이랑은 관련 없음!)
- Bean Validation 사용
- Spring 통합없이, 순수 Bean Validation 사용법
- Bean Validation 사용을 위한 의존관계 추가 (라이브러리 추가)
→
implementation 'org.springframework.boot:spring-boot-starter-validation'
-
Item 객체 (Bean Validation 적용 객체)
... @NotBlank // 빈 문자나 null이면 안됨 private String itemName; @NotNull // null이면 안됨 @Range(min = 1000, max = 1000000) // 1000~1000000 범위 private Integer price; @NotNull // null이면 안됨 @Max(9999) // 최댓값이 9999보다 크면 안됨 private Integer quantity; ...
- 검증 어노테이션으로 Validation 편리하게 적용 가능
@NotBlank
: 빈 문자나 null이면 안됨@NotNull
: null이면 안됨@Range(min = 1000, max = 1000000)
: 범위 안의 값이어야 됨@Max(9999)
: 최대 범위 설정
- 검증 Test
-
검증기 생성
ValidatorFactory factory = Validation.buildDefaultValidatorFactory(); Validator validator = factory.getValidator();
-
검증 실행
Set<ConstraintViolation<Item>> violations = validator.validate(item);
- item에 검증에 벗어나는 값을 설정해서 넣어주면, 그에 맞는 검증 결과를 violations에 담아두는 것.
- violations를 통해 어떤 검증 실패가 발생했는지 확인 가능
-
Bean Validation 적용 (Spring)
Bean Validation with Spring
- Bean Validation의 Validator 등록을 위해 사용했던
@InitBinder
제거! → 검증이 중복으로 적용됨!! (Bean Validation의 Validator 랑 custom Validator(ItemValidator) 가 중복으로 실행 되는 것) -
그리고 다른 코드는 수정할 필요 없음! → Bean Validation이 자동으로 Global Validator로 등록 되고 이를 사용하고자 하는 객체에
@Validated
를 달아주면 알아서 Validator가 적용됨! (@Validated @ModelAttribute Item item
)@PostMapping("/add") public String addItem(@Validated @ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes) { // 실패 시 if (bindingResult.hasErrors()) { log.info("errors={}", bindingResult); return "validation/v3/addForm"; } ... // 성공 로직 }
- 어떻게 Bean Validator가 적용 되나?
- 스프링 부트는
spring-boot-starter-validation
라이브러리가 있으면 자동으로 Bean Validator를 인지하고 스프링에 통합시킴 (즉, Spring 내에서 사용할 수 있게 해준다는 것) - 그리고,
LocalValidatorFactoryBean
을 Global Validator (프로젝트 내의 모든 곳에서 사용할 수 있는 Validator)로 등록. (LocalValidatorFactoryBean
는@NotNull
과 같은 애노테이션을 보고 검증을 수행. 추가로 Global Validator를 직접 등록하는 경우, 해당 Validator는 동작하지 않음 → Global은 직접 등록한 애들만 있다고 생각하기에) - 이렇게 Bean Validation 기반 Global Validator가 자동으로 등록되었기 때문에
@Valid
,@Validated
만 적용해주면 해당 Validator를 원하는 객체에 적용할 수 있음! - 적용한 객체에서 검증 오류가 발생하면, 이전과 동일하게
FieldError
,ObjectError
를 생성해서BindingResult
에 담아줌!
- 스프링 부트는
- Bean Validator 검증 순서
@ModelAttribute
각각의 필드에 Type 변환 (String to X) 시도- 성공하면 2번
- 실패하면
typeMismatch
로FieldError
추가
- 어노테이션 기반 Validator 적용 (Bean Validation)
Bean Validation with Error Message(Code)
- 더 자세히, 계층적으로 Bean Validation의 오류 메시지 설정하기
- Bean Validation으로 인해 발생된 bindingResult의 검증 오류 코드를 확인해보면 오류 코드가 애노테이션 이름으로 등록되는 것을 확인할 수 있음. →
codes=[NotBlank.item.itemName, NotBlank.itemName, NotBlank.java.lang.String, NotBlank]
- 즉, Bean Validation도 이전과 동일하게 errors.propertes 메시지 소스에 해당 어노테이션을 이름으로 하는 오류 코드를 지정해주면 됨
-
errors.properties
NotBlank={0} 공백X Range={0}, {2} ~ {1} 허용 Max={0}, 최대 {1}
- {0} : 필드 명. {1},{2}, … 는 각 애노테이션에서 설정한 값들
Bean Validation in ObjectError
- 지금까지는 Bean Validation을 FieldError에 적용하는 것을 봤음
- 그럼 ObjectError는 어떻게?
@ScriptAssert()
이용- 기존에 사용했던, 직접
ObjectError
를 생성해서 만들기
-
@ScriptAssert()
@Data @ScriptAssert(lang = "javascript", script = "_this.price * _this.quantity >= 10000", message = "10000원 이상") public class Item { //... }
- item의 price, quantity의 값의 곱이 10000 이상인 검증 수행
- message 설정 가능
- But, script 언어로 사용하기도 하고 사용 방법이 단순하지 않음
- 또한, 검증 과정 자체가 복잡할 경우는 사용하기 까다로움
- 보통 많이 사용하지는 않는 방법
-
[주로 사용하는 방법] 직접 검증 후
ObjectError
를 생성해서 넣어주기 (Controller 단에서)if (item.getPrice() != null && item.getQuantity() != null) { int resultPrice = item.getPrice() * item.getQuantity(); if (resultPrice < 10000) { bindingResult.reject("totalPriceMin", new Object[]{10000, resultPrice}, null); } }
- 직접 검증을 실행하여 검증 조건에 맞지 않다면 BindingResult의
reject
를 사용하여ObjectError
를 생성하여 넣어줌 - 코드가 길어지지만, 복잡한 검증도 가능하며 원하는 형태로 구성할 수 있음
- 직접 검증을 실행하여 검증 조건에 맞지 않다면 BindingResult의
Bean Validation - 요구사항에 따른 검증
- 요구사항에 따른 검증 2가지 방법 (with Bean Validation)
- BeanValidation의 groups 기능 사용
- Item을 직접 From Data로 사용하는 것이 아닌, ItemSaveForm, ItemUpdateForm 같은 DTO를 사용 (DTO : Data Transfer Object)
- 실무에서는 groups 보다는 DTO를 주로 사용
BeanValidation의 groups
- 등록시에 검증할 기능과 수정시에 검증할 기능을 각각 그룹으로 나누어 적용
- group은 interface로 지정만 해주면 됨
- 등록용 groups interface 생성 →
public interface SaveCheck {}
- 수정용 groups interface 생성 →
public interface UpdateCheck {}
- 등록용은 SaveCheck.class로 지정, 수정용은 UpdateCheck.class로 group을 지정해주면 됨
- 등록용 groups interface 생성 →
- Item 객체에 group 지정하기
- 등록만 적용 :
@Max
->@Max(value = 9999, groups = SaveCheck.class)
- 수정만 적용 :
@NotNull
→@NotNull(groups = UpdateCheck.class)
- 둘 다 적용 :
@NotNull
→@NotNull(groups = {SaveCheck.class, UpdateCheck.class})
- 등록만 적용 :
- Controller의
@Validated
에 group 지정@Validated()
안에 해당 group interface를 지정해주면 됨- 등록 method (
@PostMapping(”/add”)
) →public String addItemV2(@Validated(SaveCheck.class) @ModelAttribute Item item ...
- 수정 method (
@PostMapping(”/{itemId}/edit”)
) →public String editV2(@PathVariable Long itemId, @Validated(UpdateCheck.class) @ModelAttribute Item item, …
- BUT, 실무에서는 groups 보다는 DTO 형식을 이용!!!
참고 :
@Valid
에서는 groups 사용 못함. 즉, groups를 사용하려면@Validated
를 사용해야 됨
Bean Validation with DTO (Form 전송 객체 분리)
- 실무에서 groups 를 잘 사용하지 않는 이유 → 폼에서 전달하는 데이터가 서비스 도메인 객체와 정확히 딱 드러맞지 않기 때문
- 즉, 사용자가 입력한 데이터와 실제로 저장하려 하는 도메인 객체가 딱 맞지 않음! (추가적은 정보를 넣어야할 때도 있고, 부가적으로 작업 후에 새로운 값으로 넣어주는 경우도 있기 때문)
- 그래서 보통 도메인으로 사용되는 객체를 Form으로 보내 직접 받아 오는 것이 아니라, 폼의 데이터를 컨트롤러까지 전달할 별도의 객체(DTO)를 만들어서 전달함. 즉, Form을 전달받는 전용 객체를 만들어
@ModelAttribute
로 사용하는 것. 이후 해당 데이터를 받아 컨트롤러에서 필요한 데이터만을 사용하여 서비스 도메인을 생성하는 것!
- DTO 사용
- 기존 Item 도메인 객체의 Bean Validation은 모두 제거 (Form DTO를 통해 검증을 마친 후 사용되므로!)
- 등록용 DTO (ItemSaveForm) →
public class ItemSaveForm
- 등록 스펙에 맞는 property들(id는 등록시 넘어오지 않으므로 id를 제외한 item의 모든 proerty)만을 설정
- 등록 시 요구사항에 맞는 Bean Validation 진행
- 수정용 DTO (ItemUpdateForm) →
public class ItemUpdateForm
- 수정 스펙에 맞는 property들 (수정 시 어떤 item을 수정하는 지 알아야 하기 떄문에 id 도 포함) 만을 설정
- 수정 시 요구사항에 맞는 Bean Validation 진행
- 이제 각 등록, 수정 Controller에서 해당 하는 Form 객체(DTO)를 사용하여 데이터를 받아 온 후 도메인 객체로 값을 이전(변환)하여 저장하면 됨
- 등록 Controller (
@PostMapping("/add")
) →public String addItem(@Validated @ModelAttribute("item") ItemSaveForm form ...
- ItemSaveForm 등록 DTO 사용
@ModelAttribute("item")
를 통해 model 에 itemSaveForm의 이름이 아닌 item의 이름으로 View template에 넘겨줌 (view template에서 item의 이름으로 값에 접근하기 위함)-
도메인 객체로 값 이전 후 repository에 저장
Item item = new Item(); item.setItemName(form.getItemName()); item.setPrice(form.getPrice()); item.setQuantity(form.getQuantity());
- 수정 Controller(
@PostMapping("/{itemId}/edit")
) →public String edit(@PathVariable Long itemId, @Validated @ModelAttribute("item") ItemUpdateForm form, …
- ItemUpdateForm 수정 DTO 사용
- 나머지는 등록 Controlle과 유사
- 등록 Controller (
- Form 전송 객체 분리해서 등록과 수정에 딱 맞는 기능을 구성하고, 검증도 명확히 분리함
Bean Validation in API (@RequestBody, HttpMessageConverter)
참고 :
@ModelAttribute
는 HTTP 요청 파라미터(URL 쿼리 스트링, POST Form)를 다룰 때 사용,@RequestBody
는 HTTP Body의 데이터를 객체로 변환할 때 사용(API JSON 요청)
@Validated
를 통한 검증은@RequestBody
(HttpMessageConverter) 에도 적용 가능- API 3가지 경우
- 성공
- 실패 : JSON 객체로 생성하는 것 자체가 실패
- 검증 오류 : JSON 객체로 생성 후 검증에서 실패
- 여기서 중요한 부분은 JSON 객체로 생성하는 것 자체가 실패한 경우
- JSON 객체로 생성하는 것 자체가 실패한 경우는 오류 코드(메세지)들이 반환되는 것이 아닌 “400 Bad Request” 가 반환됨
- 즉, price에 Integer가 아닌 String으로 값을 넣는 경우 JSON 객체 생성 자체가 안되며 Controller가 호출 자체가 안되고 검증 자체가 실행되지 않고 오류가 발생해버림!!