빈 유효성 검사 소개
- 콩 검증
- 모든 프로젝트에 대한 공통적이고 표준화된 검증 논리
- @NotBlank @NotNull @Range @Max …
- 구체적인 구현은 없지만 JSR-380이라는 기술 표준 == Validation Notes + Interfaces
- Hibernate 유효성 검사기
- Bean Validation 구현 기술의 일반적으로 사용되는 구현
- 유효성 검사 주석 모음: https://docs.jboss.org/hibernate/validator/6.2/reference/en-US/html_single/#validator-defineconstraints-spec
Bean 검증 – 시작하기
- Spring과 통합하지 않고 순수 빈 유효성 검사를 사용하는 방법
하나. 종속성 추가
# build.gradle
implementation 'org.springframework.boot:spring-boot-starter-validation'
2. 추가된 의존성으로 추가된 라이브러리
- 라이브러리가 설치되면 다음 두 패키지가 함께 설치됩니다.
- jakarta.validation-api : 빈 검증 인터페이스
- 자체적으로 구현할 수 없습니다.
- Java 표준이기 때문에 인터페이스의 validatoin 함수는 모든 구현에 적용할 수 있습니다. 예) @NotNull
- 최대 절전 유효성 검사기 구현
- 실제로는 거의 빈 유효성 검사 인터페이스의 구현으로 사용됩니다.
- Hibernet에는 자체 주석이 있습니다. 예) @범위
- jakarta.validation-api : 빈 검증 인터페이스
빈 유효성 검사를 사용한 예
- 즉, 이렇게 주석을 달아서 사용할 수 있지만 주석만 붙여서 바로 작동하는 것은 아닙니다.
- 작동 방식 Bean 유효성 검사 – Spring 적용 부분을 설명했다.
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 유효성 검사가 가져온 것을 볼 수 있습니다.
- javax.validation: 특정 구현과 상관없이 제공되는 표준 인터페이스입니다.
- 주로 다른 구현(인터페이스를 상속하는 구현)에 의해 구현된 주석을 제공합니다.
- Validation.java 클래스도 있습니다. – 스프링 작업 중에는 사용되지 않습니다. 자바에서만 사용
- org.hibernate.validation: 유효성 검사 주석이 Hibernate 구현에 의해 직접 소유될 때 가져옴
- javax.validation: 특정 구현과 상관없이 제공되는 표준 인터페이스입니다.
- 검증 참고 사항
- @NotBlank: 빈 값 + 공백은 허용되지 않습니다.
- @NotNull: 0은 허용되지 않습니다.
- @Range(min = 1000, max = 1000000): 값이 범위 내에 있어야 합니다.
- @Max(9999): 9999까지만 허용됩니다.
테스트 코드
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());
}
}
}
- 검사기 만들기
- 테스트할 때나 Spring과 연동할 때 사용하며 직접 작성하는 경우는 없다.
- 심사관 : 심사관
- 확인 실행
- 유효성 검사기는 요소를 유효성 검사기에 삽입하고 결과를 문장으로 반환합니다.
- ConstraintViolation: 유효성 검사 오류
- 오류가 없으면 값이 비어 있습니다.
- 검증 결과
- 검증 오류가 발생한 객체, 필드, 메시지 정보 등 다양한 정보를 검증할 수 있습니다.
Bean 유효성 검사 – Spring 적용
ValidationItemControllerV3
@Slf4j
@Controller
@RequestMapping("/validation/v3/items")
@RequiredArgConstuctor
public class ValidationItemControllerV3 {
private final ItemRepository itemRepository;
// 직접 만들었던 검증기 제거
// 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 유효성 검사기를 사용했습니다.
- 검증자 부분을 삭제하고 컨트롤러를 호출하면 별도의 검증자를 만들지 않아도 정상적으로 동작한다.
유효성 검사기가 작동하는 이유
- Spring MVC는 Bean Validator(== Validator)를 어떻게 사용하는가?
- build.gradle – 구현 ‘org.springframework.boot:spring-boot-starter-validation’ 라이브러리가 포함되어 있으면 Spring Boot는 자동으로 Bean 유효성 검사기를 찾아 Spring에 통합합니다.
- 스프링부트 동작
- 등록된 Bean Validator 이름은 LocalValidatorFactoryBean(Validator)입니다.
- 글로벌 검증인으로 등록
- Global Validator(LocalValidatorFactoryBean)가 적용되었으므로 @Valid와 @Validated만 적용하면 된다.
- 주석을 보면 SpringFrameWork는 유효성 검사기를 찾아 유효성 검사를 수행합니다.
- @Valid: Java 표준 유효성 검사에 대한 참고 사항으로 위 라이브러리가 설치되어 있지 않으면 작동합니다.
- @Validated: Spring 특정 유효성 검사 주석
- 스프링부트 동작
- build.gradle – 구현 ‘org.springframework.boot:spring-boot-starter-validation’ 라이브러리가 포함되어 있으면 Spring Boot는 자동으로 Bean 유효성 검사기를 찾아 Spring에 통합합니다.
- 지침
- 사용자가 직접 전역 검증자를 등록하면 Springboot는 빈 검증자(validator=Local…)를 검증자로 등록하지 않습니다.
- 주석 기반 빈 유효성 검사기는 작동하지 않습니다.
검증 순서
- @ModelAttribute는 요청에서 받은 매개변수를 각 필드로 변환하려고 시도합니다.
- 성공 시 -> 유효성 검사기 적용됨
- 오류가 발생한 경우 -> FieldError를 typeMismatch로 추가 – 컨트롤러의 @PostMapping 메서드에 BindingResult가 있어야 합니다.
- 유효성 검사기 적용
- 1을 전달한 필드에만 Bean 유효성 검증을 적용하십시오.
- 빈 유효성 검사기는 바인딩할 수 없는 필드(필드에 입력되지 않은 값)에 대해 빈 유효성 검사를 적용하지 않습니다.
- 검사는 모델 개체에 바인딩할 값이 정상적으로 도착하는 경우에만 의미가 있습니다.
- 1을 전달한 필드에만 Bean 유효성 검증을 적용하십시오.
Bean 유효성 검사 오류 코드
- 해당 주석 오류 코드를 기반으로 MessageCodesResolver에서 순서대로 다른 메시지 코드를 생성합니다.
- @NotBlank
- NotBlank.item.itemName(주석명.클래스명.필드명)
- NotBlank.itemName
- NotBlank.java.lang.String
- 비어 있지 않음
- @영역
- 구색.품목가격
- 범위.가격
- Range.java.lang.Integer
- 영역
- 메시지 등록
- {0}: 필드 이름
- {1}, {2}: 각 주석에 값이 할당됩니다.
# errors.properties
NotBlank={0} 공백X
Range={0}, {2} ~{1} 허용
Max={0}, 최대 {1}
- BeanValidation 메시지 찾기 순서
- 생성된 메시지의 코드 개선 순서대로 messageSource에서 메시지 찾기
- 오류에서 메시지 코드를 찾을 수 있는 이유는 application.properties에 오류를 등록했기 때문입니다.
- 주석 메시지 속성 사용
- 1에 메시지 코드가 없는 경우 기본값으로 사용됩니다.
- @NotBlank(message = “공백을 입력할 수 없습니다.”)
- 라이브러리에서 제공하는 기본값 사용
- 2. 이용 가능한 번호가 없을 경우, 도서관의 정보를 이용하십시오.
- 생성된 메시지의 코드 개선 순서대로 messageSource에서 메시지 찾기
Bean 유효성 검사 – 개체 오류
- @ScriptAssert() 사용
- 메시지 코드 자동 생성
- ScriptAssert.항목
- scriptAssert
- 실사용에는 한계가 너무 많습니다.
- 메시지 코드 자동 생성
@Data
@ScriptAsset(lang="javascript", script="_this.price*_this.quantity >= 10000")
public class Item {
...
}
- 개체 오류의 경우 해당 부분만 Java 코드에 직접 작성하는 것이 좋습니다.
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}
Bean 유효성 검사 제한 사항
- 처리 양식과 등록 양식의 요구 사항이 다릅니다.
- 보정
- 수량은 무기한 변경될 수 있습니다.
- ID 값이 필요합니다.
- 등록
- 수량은 9999로 고정
- 등록 시 ID 값이 필요하지 않습니다.
- 보정
- 동일한 Item 클래스의 유효성 검사 주석으로 다양한 상황에서 사용하기 어렵습니다.
- ID 값에 @NotNull을 등록하면 상품 등록 자체가 차단됩니다.
- 솔루션
- Bean 유효성 검사 – 그룹 기능 사용
- Item을 직접 사용하지 않고 양식 제출을 위한 별도의 모델 객체 생성 및 사용
빈 검증 그룹
- 잘 사용하지 마세요
- 전반적인 복잡성이 증가하기 때문에
- 실제로는 등록을 위한 폼 객체와 수정을 위한 폼 객체를 분리하여 사용하기 때문이다.
- Bean Validation에서 제공하는 기능
- 등록 및 수정 확인 기능을 그룹으로 구분하여 적용할 수 있습니다.
- @Validated 만 그룹을 사용하는 데 사용할 수 있습니다.
각 그룹 만들기
package hello.itemservice.domain.item;
public interface SaveCheck{}
package hello.itemservice.domain.item;
public interface UpdateCheck{}
항목 그룹 적용
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;
}
컨트롤러에서 각 그룹을 할당하고 게시물을 편집합니다.
@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){
...
}
양식 제출 객체 분리 – 소개
- 그룹이 실제로 자주 사용되지 않는 이유
- 이는 등록 시 양식에서 전송된 데이터가 항목 도메인 개체와 정확히 일치하지 않기 때문입니다.
- 헬로우 월드에서는 사실일 수 있지만 실제 세계에서는 등록 시 일반 이용 약관을 포함한 추가 데이터가 전송됩니다.
- 관행
- 따라서 요소를 직접 가져오는 대신 별도의 객체를 생성하여 컨트롤러에 데이터를 전달합니다.
- 그런 다음 컨트롤러는 필요한 데이터로 항목을 생성합니다.
- HelloWorld 도메인 개체와 RealWorld 도메인 개체의 배포 프로세스 비교
- 헬로 월드
- HTML 양식 -> 항목 -> 컨트롤러 -> 항목 -> 저장소
- 장점 : 아이템 도메인 객체를 컨트롤러와 리포지토리에 직접 전달하여 중간에 아이템을 생성하는 과정이 없기 때문에 간편하다.
- 단점: 단순한 경우에만 적용됩니다. 수정 시 검증 중복 가능, 그룹 사용 필수
- HTML 양식 -> 항목 -> 컨트롤러 -> 항목 -> 저장소
- 현실 세계
- HTML Form -> ItemSaveForm -> Controller -> Create Item -> 저장소
- 장점: 제출할 폼 데이터가 복잡하더라도 그에 맞는 별도의 폼 객체를 이용해 데이터를 받을 수 있다. 등록 및 수정을 위해 별도의 양식 개체가 생성되기 때문에 일반적으로 유효성 검사는 중복되지 않습니다.
- 단점: 양식 데이터를 기반으로 컨트롤러에서 항목 개체를 생성하는 변환 프로세스를 추가합니다.
- HTML Form -> ItemSaveForm -> Controller -> Create Item -> 저장소
양식 제출 개체 분리 – 개발
- 기사 오리지날 드레스
@Data
public class Item {
private Long id;
private String itemName;
private Integer price;
private Integer quantity;
}
- 저장 및 업데이트 양식 만들기
- 항목저장양식
- 항목 업데이트 양식
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;
}
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;
}
- 컨트롤러 변경
- 주의: MVC 모델에 포함될 때 키 값이 항목으로 유지되도록 @ModelAttribute(“item”)에 항목 이름을 포함해야 합니다.
- 유효성 검사 대상을 ItemSaveForm 형식으로 설정합니다.
@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 형식으로 설정합니다.
@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}";
}
Bean 유효성 검사 – HTTP 메시지 변환기
- @Valid, @Validated: HttpMessageConverter(@RequestBody)에도 적용됩니다.
- 참조
- @ModelAttribute: HTTP 요청 매개변수를 처리하는 데 사용됩니다.
- @RequestBody : HTTP 본문 데이터를 객체로 변환할 때 사용 – API JSON 요청을 처리할 때 사용
- 컨트롤러 생성
- PostMan을 사용한 유효성 검사
- API에 접근할 때 고려해야 할 세 가지 경우가 있습니다.
- 성공 요청
- 실패한 요청 – 개체의 내부 값에 형식 오류가 발생한 경우 JSON을 개체 자체로 빌드하는 데 실패함
- addItem의 @RequestBody는 HTTP messageConverter에서 @RequestBody를 보고 ItemSaveForm으로 JSON 생성을 시도하지만 ItemSaveForm 객체 생성 자체는 tye error로 실패한다.
- 요청 유효성 검사 오류 – JSON을 개체로 성공적으로 생성했지만 유효성 검사에 실패했습니다.
@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;
}
}
- 참조
