앞서 나는 Controller에서 검증 로직을 분리하고 스프링에서 검증기를 호출하는 방법을 배웠다.
이보다 더 간결하고 보기 좋게 애노테이션을 활용하여 검증 로직을 만들 수 있게 하는 Bean Validation에 대해서 배워보자.
전체적인 흐름은 아래와 같다.
- 스프링과 통합되지 않은 순수 Bean Validation
- 스프링 mvc는 어떻게 Bean Validation을 사용하는지
- Bean Validation 에러코드를 더 자세히 변경하는 방법
- 페이지마다 도메인에 요구하는 검증 조건이 다른 경우
오늘도 🏃🏃♂️🏃♀️🏃♂️🏃
implementation 'org.springframework.boot:spring-boot-starter-validation'
-jakarta.validation-api
는 Bean Validation 인터페이스이다.hiberate-validator
는 구현체이다.이제 실제로 활용해보자.
먼저 스프링과 통합되지 않은 순수 Bean Validation을 살펴본다.
...
public class Item {
private Long id;
@NotBlank
private String itemName;
@NotNull
@Range(min=1000, max=1000000)
private Integer price;
@NotNull
@Max(value = 9999)
private Integer quantity;
public Item() {
}
public Item(String itemName, Integer price, Integer quantity) {
this.itemName = itemName;
this.price = price;
this.quantity = quantity;
}
}
@Test
void beanValidation(){
//검증기 직접 생성
ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
Validator validator = factory.getValidator();
Item item = new Item();
item.setItemName(" ");
item.setPrice(0);
item.setQuantity(10000);
// 검증 대상을 직접 검증기에 넣고 그 결과 받기
//ConstraintViolation이라는 검증 오류가 담긴다.
Set<ConstraintViolation<Item>> violations = validator.validate(item);
for(ConstraintViolation<Item> violation : violations) {
System.out.println("violation="+violation);
System.out.println("violation.message="+violation.getMessage());
}
}
앞서 build.gradle에 의존관계를 추가했다.
implementation 'org.springframework.boot:spring-boot-starter-validation'
위와 같이 추가하면 검증 객체에 @Validater가 있으면 자동으로 Bean Validator로 인지하고 검증한다.
글로벌 Validator은 모든 컨트롤러에 적용되는 검증기이다.
스프링 부트는 LacalValidatorFactoryBean을 글로벌 Validator로 등록한다.
따라서 앞서 main 함수에 직접 등록했던 과정은 필요없고 검증할 객체에 @Valid나 @Validater 애노테이션을 붙여주면 된다.
검증 오류가 발생하면 FieldError, ObjectError를 생성해서 BindingResult에 담아둔다.
기본으로 제공하는 오류 메세지를 좀 더 자세히 변경하기
메세지 코드는 검증 애노테이션을 기반으로 만들어진다.
MessageCodesResolver가 어떤 순서로 메시지 코드를 만드는지 살펴보자.
@NotBlank
@Range
예를 들어 price는 무조건 1000원 이상이어야 한다.
수량은 9999를 넘길 수 없다가 한 특정 필드에 대한 검증조건이라면
price * quantity 는 10000원 이상이어야 한다가 오브젝트 오류이다.
이건 도메인 객체에서 애노테이션으로 검증이 불가능한걸까?
가능하다!
@ScriptAssert()를 사용하여
@Data
@ScriptAssert(lang = "javascript", script = "_this.price * _this.quantity >=
10000")
public class Item { ...}
위와 같이 표현한다
하지만 제약이 많고 복잡해서 권장하지 않는 방법이다.
따라서 직접 자바 코드로 작성하는 것을 권장!
if (item.getPrice() != null && item.getQuantity() != null) {
int resultPrice = item.getPrice() * item.getQuantity();
if (resultPrice < 10000) {
bindingResult.reject("totalPriceMin", new Object[] {10000,resultPrice}, null);
}
}
등록 페이지의 도메인 객체 요구 조건 ≠ 수정 페이지의 도메인 객체 요구 조건
검증 조건이 충돌하지 않도록 설정하는 방법에 대해서 알아보자
groups의 기능은 @Validated에서 제공한다.
전체적 흐름
각각의 페이지에 대한 인터페이스 생성(수정 Form 인터페이스, 등록 Form 인터페이스)
➡️ 도메인 객체에 groups를 이용해서 검증 로직에 해당하는 인터페이스 넣기
➡️ Controller에서 @Validated(인터페이스.class)로 해당 검증 로직을 거쳐가게 하기
등록 페이지에 대한 그룹
package hello.itemservice.domain.item;
public interface SaveCheck {
}
수정 페이지에 대한 그룹
package hello.itemservice.domain.item;
public interface UpdateCheck {
}
...
@NotNull(groups = UpdateCheck.class) //수정시에만 적용
private Long id;
@NotBlank(groups = {SaveCheck.class, UpdateCheck.class})
private String itemName;
...
@PostMapping("/add")
public String addItemV2(@Validated(SaveCheck.class) @ModelAttribute Item item,
BindingResult bindingResult, RedirectAttributes redirectAttributes) {
//...
}
여러개의 각 계층을 건들다보니 복잡도가 올라갔다.
따라서 객체 자체를 분리해서 사용하는 다음 방법을 이용하자.
Item이라는 도메인 객체가 있다고 가정하자
클라이언트가 Form에 데이터를 입력하여
서버가 데이터를 받을 때
그 데이터가 Item 도메인 객체의 정보만을 가지고 있는 것이 아니라
다른 부가적인 정보를 가지고 있기 때문에
바로 Form으로부터 Item 객체만을 받는게 불가능하다.
따라서 실무에서는
Form으로 Item 객체를 바로 받기 않고
Form ➡️ ItemSaveForm ➡️ Controller ➡️ Controller에서 Item 객체 생성 ➡️ Repository 순으로 전달된다.
@Data
public class Item {
private Long id;
private String itemName;
private Integer price;
private Integer quantity;
}
모든 검증 애노테이션이 제거되었다.
@Data
public class ItemSaveForm {
@NotBlank
private String itemName;
@NotNull
@Range(min = 1000, max = 1000000)
private Integer price;
@NotNull
@Max(value = 9999)
private Integer quantity;
}
별도 객체에 검증 애노테이션을 넣는다.
@PostMapping("/add")
public String addItem(@Validated @ModelAttribute("item") ItemSaveForm form, BindingResult bindingResult, RedirectAttributes redirectAttributes) {
//특정 필드 예외가 아닌 전체 예외
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";
}
//성공 로직
Item item = new Item();
item.setItemName(form.getItemName());
item.setPrice(form.getPrice());
item.setQuantity(form.getQuantity());
Item savedItem = itemRepository.save(item);
redirectAttributes.addAttribute("itemId", savedItem.getId());
redirectAttributes.addAttribute("status", true);
return "redirect:/validation/v4/items/{itemId}";
}
<form action="item.html" th:action th:object="${item}" method="post">
"item"이라는 이름으로 값을 받았기 때문이다.
클라언트로부터 데이터를 전달받을 때 Get+쿼리 파라미터 , Post-HTML Form , HTTP Message Body가 있습니다. 반대로 서버에서 클라이언트로 데이터를 전달할 때는 동적/정적 HTML, HTTP Message Body가 있습니다.
HTTP API를 제공하는 경우 HTTP Message Body에 JSON, XML, TEXT 형식으로 데이터를 직접 담아서 요청 및 응답을 처리
HttpMessageConverter는 HttpEntity 객체나 @RequestBody, @ResponseBody 애노테이션을 사용하여 데이터를 직접 매핑할 필요 없이 자동으로 요청 및 응답 데이터를 적절한 형식으로 변환해주는 기능을 제공한다.
그렇다면 Contoroller에서 @Validated @ModelAttribute와 @Validated @RequestBody로 데이터를 전달받을 수 있는데 (물론 전달 방식의 차이가 있지만) 둘이 바인딩 오류를 처리하는 방식에 차이가 있다.
@Validated @ModelAttribute의 경우 바인딩오류가 발생한 필드를 제외한 필드는 정상으로 처리되지만
@Validated @RequestBody는 객체 자체가 만들어지지 않는다.