제육's 휘발성 코딩
반응형

1. Bean Validation

검증 기능을 매번 코드로 작성하는 것은 상당히 번거롭다. 특히 특정 필드에 대한 검증 로직은 대부분 빈 값인지 아닌지, 특정 크기를 넘는지 아닌지와 같이 매우 일반적인 로직이다. 이러한 불편함을 애노테이션 하나로 검증 로직을 적용한 것이 Bean Validation이다.

<code />
implementation 'org.springframework.boot:spring-boot-starter-validation'
  • build.gradle에 라이브러리를 추가하자. 해당 라이브러리를 등록하면 스프링 부트는 자동으로 글로벌 Validator로 등록한다. 단, 사용할 때 해당 메서드 파라미터에 @Validated 또는 @Valid 를 넣어줘야 한다. 검증 오류가 발생하면 FieldError, ObjectError를 생성해서 BindingResult에 담아준다.
  • 이후에 나올 groups 기능을 사용하려면 스프링 전용 검증 애노테이션인 @Validated를 사용해야 한다.
<code />
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 private String itemName; @NotNull @Range(min = 1000, max = 1000000) 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을 적용할 수 있다. 애노테이션이 직관적이기 때문에 별도의 설명은 넘어간다.
<code />
import hello.itemservice.domain.item.Item; import org.junit.jupiter.api.Test; import javax.validation.ConstraintViolation; import javax.validation.Validation; import javax.validation.Validator; import javax.validation.ValidatorFactory; import java.util.Set; public class BeanValidationTest { @Test void beanValidation() { ValidatorFactory factory = Validation.buildDefaultValidatorFactory(); Validator validator = factory.getValidator(); Item item = new Item(); item.setItemName(" "); item.setPrice(0); item.setQuantity(10000); Set<ConstraintViolation<Item>> violations = validator.validate(item); for (ConstraintViolation<Item> violation : violations) { System.out.println("violation = " + violation); System.out.println("violation.message = " + violation.getMessage()); } } }
  • 검증기 생성 방식이다. 이후에 스프링과 통합하여 사용하므로 참고만 하자.

1.1. 검증 순서

  1. @ModelAttribute 각각의 필드에 타입 변환 시도
    1. 성공하면 다음으로
    2. 실패하면 typeMismatchFieldError 추가
  2. Validator 적용

여기서 고민해볼 점은 1번 과정에서 바인딩에 성공한 필드만 BeanValidation을 적용한다는 것이다. 그 이유는 당연하게도 모델 객체에 바인딩 받는 값이 정상적으로 들어와야 검증에도 의미가 있기 때문이다.

즉, 중간에 오류가 발생 시 typeMismatch를 통해 BindingResult에 값을 담아줄 뿐 BeanValidation은 적용되지 않는다.

 

1.2. Bean Validation - 에러 코드

BeanValidation이 기본으로 제공하는 오류 메시지는 bindingResult에 등록되며 확인할 수 있다. 즉, typeMismatch와 유사하다. 즉, 에러 메시지만 설정하면 직접 에러 코드를 구현할 수 있다.

<code />
#Bean Validation 추가 NotBlank={0} 공백X Range={0}, {2} ~ {1} 허용 Max={0}, 최대 {1}
  • errors.properties 에 메시지를 등록해보자.

image

  • 적용한 메시지가 정상 적용되는 것을 확인할 수 있다.

1.3. Bean Validation - 오브젝트 오류

특정 필드 (FieldError)가 아닌 해당 오브젝트 관련 오류(ObjectError)는 어떻게 처리할 수 있을까?

다음과 같이 @ScriptAssert()를 사용하면 된다.

<code />
package hello.itemservice.domain.item; import lombok.Data; import org.hibernate.validator.constraints.Range; import org.hibernate.validator.constraints.ScriptAssert; import javax.validation.constraints.Max; import javax.validation.constraints.NotBlank; import javax.validation.constraints.NotNull; @Data @ScriptAssert(lang = "javascript", script = "_this.price * _this.quantity >= 10000", message = "총합이 10000원 넘게 넣어주세요.") public class Item { private Long id; @NotBlank(message = "공백X") private String itemName; @NotNull @Range(min = 1000, max = 1000000) 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; } }
  • 실행해보면 정상 수행되는 것을 확인할 수 있다. 다만, 실제 사용하기에 제약이 많고 복잡하므로 직접 자바 코드로 작성하는 것을 권장한다.
<code />
@PostMapping("/add") public String addItemV6(@Validated @ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model) { // 특정 필드가 아닌 복합 룰 검증 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}"; }
  • 기존 방식대로 오브젝트와 관련된 검증은 직접 처리하는 것이 바람직하다.

1.4. Bean Validation - 수정에 적용

<code />
@PostMapping("/{itemId}/edit") public String edit(@PathVariable Long itemId, @Validated @ModelAttribute Item item, BindingResult bindingResult) { // 특정 필드가 아닌 복합 룰 검증 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/editForm"; } itemRepository.update(itemId, item); return "redirect:/validation/v3/items/{itemId}"; }
  • edit에도 적용하기 위해 파라미터에 @Validated, BindingResult를 추가하고, 오브젝트 에러와 에러 존재 여부 판단 조건을 넣어준다.
<code />
<form action="item.html" th:action th:object="${item}" method="post"> <div th:if="${#fields.hasGlobalErrors()}"> <p class="field-error" th:each="err : ${#fields.globalErrors()}" th:text="${err}">전체 오류 메시지</p> </div> <div> <label for="id" th:text="#{label.item.id}">상품 ID</label> <input type="text" id="id" th:field="*{id}" class="form-control" readonly> </div> <div> <label for="itemName" th:text="#{label.item.itemName}">상품명</label> <input type="text" id="itemName" th:field="*{itemName}" th:errorclass="field-error" class="form-control"> <div class="field-error" th:errors="*{itemName}"> 상품명 오류 </div> </div> <div> <label for="price" th:text="#{label.item.price}">가격</label> <input type="text" id="price" th:field="*{price}" th:errorclass="field-error" class="form-control"> <div class="field-error" th:errors="*{price}"> 가격 오류 </div> </div> <div> <label for="quantity" th:text="#{label.item.quantity}">수량</label> <input type="text" id="quantity" th:field="*{quantity}" th:errorclass="field-error" class="form-control"> <div class="field-error" th:errors="*{quantity}"> 수량 오류 </div> </div> <hr class="my-4"> <div class="row"> <div class="col"> <button class="w-100 btn btn-primary btn-lg" type="submit" th:text="#{button.save}">저장</button> </div> <div class="col"> <button class="w-100 btn btn-secondary btn-lg" onclick="location.href='item.html'" th:onclick="|location.href='@{/validation/v3/items/{itemId}(itemId=${item.id})}'|" type="button" th:text="#{button.cancel}">취소</button> </div> </div> </form>
  • 화면에도 보여주기 위해 th:errorclass="field-error"th:errors를 적용시켜서 오류 발생 시에 화면의 변화를 줄 수 있도록 한다.

1.4.1. Bean Validation 한계

현재 까지는 등록과 수정에 동일한 검증을 했다. 만약에 등록과 수정 때 요구 사항이 달라져 검증 범위가 다르다면 어떻게 될까?

다음과 같이 수정의 요구사항 변경으로 인한 엔티티 클래스 변경을 봐보자.

<code />
import lombok.Data; import org.hibernate.validator.constraints.Range; import javax.validation.constraints.NotBlank; import javax.validation.constraints.NotNull; @Data public class Item { @NotNull // 수정 요구사항 추가 private Long id; @NotBlank(message = "공백X") private String itemName; @NotNull @Range(min = 1000, max = 1000000) 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; } }
  • 수정 시에는 요구사항대로 동작하지만, 등록 시에 id값이 필수가 되어 다음 페이지로 넘어가지 못하는 문제와 수량의 최댓값을 초과할 수 있는 문제가 발생한다. 즉, 등록과 수정 간의 검증 조건의 충돌이 발생한다. 이 문제는 groups를 통해 해결할 수 있다.

1.5. Bean Validation - groups

동일한 모델 객체를 등록할 때와 수정할 때 각각 다르게 검증하는 방법에 대해 알아보자.

  1. Beanvalidation의 groups 기능을 사용한다.
  2. Item을 직접 사용하지 않고, 별도의 폼을 위한 DTO 객체를 만들어서 사용한다.
<code />
package hello.itemservice.domain.item; 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 { @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; } }
  • 다음과 같이 Item 클래스에 적용된 검증 애노테이션 별로 groups를 지정한다.groups에 지정한 인터페이스들은 빈 인터페이스로 단순히 명시용이다.
<code />
@PostMapping("/add") public String addItem2(@Validated(SaveCheck.class) @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}"; } @PostMapping("/{itemId}/edit") public String edit2(@PathVariable Long itemId, @Validated(UpdateCheck.class) @ModelAttribute Item item, BindingResult bindingResult) { // 특정 필드가 아닌 복합 룰 검증 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/editForm"; } itemRepository.update(itemId, item); return "redirect:/validation/v3/items/{itemId}"; }
  • 컨트롤러에서 등록과 수정 메서드안에서 @Validated(인터페이스명)을 넣어서 구현할 수 있다.

groups기능을 사용해서 등록과 수정 시에 다르게 검증할 수 있었다. 그런데, Item은 물론이고, 전반적으로 복잡도가 올라갔다. 사실 groups는 실제 잘 사용되지는 않는데, 그 이유는 실무에서는 주로 다음에 등장하는 등록용 폼 객체와 수정용 폼 객체를 분리해서 사용하기 때문이다.

1.6. Form 전송 객체 분리

groups를 실무에서 잘 사용하지 않는 이유는 기능 별 복잡한 폼의 데이터를 처리하기 때문에 별도의 전용 객체를 만들어서 @ModelAttribute를 통해 전달하기 때문이다.

1.6.1. 폼 데이터 전달에 Item 도메인 객체 사용

HTML Form - Item - Controller - Item - Repository

  • 장점 : Item 도메인 객체를 컨트롤러, 레포지토리까지 직접 전달해서 중간에 Item을 만드는 과정이 없기 때문에 간단하다.
  • 단점 : 간단한 경우에만 적용할 수 있으며, 수정 시 검증이 중복될 수 있고, groups를 사용해야 한다.

1.6.2. 폼 데이터 전달을 위한 별도의 객체 사용

HTML Form - ItemSaveFrom - Controller - Item 생성 - Repository

  • 장점 : 전송하는 폼 데이터가 복잡해도 별도의 폼 객체를 사용해서 데이터를 전달 받을 수 있다. 별도의 폼 객체를 만들기 때문에 검증이 중복되지 않는다.
  • 단점 : 폼 데이터를 기반으로 컨트롤러에서 Item 객체를 생성하는 변환 과정이 추가된다.
<code />
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; }
  • 등록을 위한 Item 폼 객체 생성
<code />
package hello.itemservice.web.validation.form; import lombok.Data; import org.hibernate.validator.constraints.Range; 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; }
  • 수정을 위한 Item 폼 객체 생성
<code />
@PostMapping("/add") public String addItem2(@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}"; } @PostMapping("/{itemId}/edit") public String edit2(@PathVariable Long itemId, @Validated @ModelAttribute("item") ItemUpdateForm form, BindingResult bindingResult) { // 특정 필드가 아닌 복합 룰 검증 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 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}"; }
  • 컨트롤러에서 별도의 객체를 바인딩할 때 주의할 점은 @ModelAttribute에서 기본적으로 생성되는 네임이 있으므로 뷰 단에서 적용한 이름을 맞춰줘야 한다. (아니면 모두 수정 해야한다.) 또한 기존 모델에 필요한 값을 넣어 레포지토리에 넣어주면 된다.

1.7. Bean Validation - HTTP 메시지 컨버터

@ModelAttributeHTTP 요청 파라미터(URL 쿼리 스트링, POST Form)를 다룰 때 사용한다.

@RequestBodyHTTP Body의 데이터를 객체로 변환할 때 사용한다. 주로 API JSON 요청을 다룰 때 사용한다.

@Valid, @ValidatedHttpMessageConverter(@RequestBody)에도 적용할 수 있다.

 

API의 경우 3가지 경우를 나누어 생각해볼 수 있다.

  1. 성공 요청
  2. 실패 요청 : JSON을 객체로 생성하는 것 자체가 실패
  3. 검증 오류 요청 : JSON 객체로 생성은 했으나, 검증에서 실패

 

image

  • Postman에서 Body - raw -JSON을 선택한 후 Post로 데이터를 전송했을 때 성공 결과

 

<code />
Resolved [org.springframework.http.converter.HttpMessageNotReadableException: JSON parse error: Cannot deserialize value of type `java.lang.Integer` from String "A": not a valid Integer value; nested exception is com.fasterxml.jackson.databind.exc.InvalidFormatException: Cannot deserialize value of type `java.lang.Integer` from String "A": not a valid Integer value at [Source: (PushbackInputStream); line: 1, column: 30] (through reference chain: hello.itemservice.web.validation.form.ItemSaveForm["price"])]
  • price에 문자값을 넣어보면 JSON 객체를 생성하는 것을 실패한 로그가 나온다. 즉, HttpMessageConverter가 객체를 만들지 못하는 경우이다. 이 경우는 Item객체를 만들지 못하기 때문에 컨트롤러 자체가 호출되지 않고 그 전에 예외가 발생하기 때문에 Validator도 실행되지 않는다.
<code />
Field error in object 'itemSaveForm' on field 'quantity': rejected value [1000000]; codes [Max.itemSaveForm.quantity,Max.quantity,Max.java.lang.Integer,Max]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [itemSaveForm.quantity,quantity]; arguments []; default message [quantity],9999]; default message [9999 이하여야 합니다]
  • 객체는 생성했지만 검증에 실패한 경우 bindingResultObjectErrorFieldError에 오류 결과를 보관한다.

 

1.7.1. @ModelAttribute vs @RequestBody

HTTP 요청 파라미터를 처리하는 @ModelAttribute는 각각의 필드 단위로 세밀하게 적용된다. 그래서 특정 필드에 타입이 맞지 않는 오류가 발생해도 나머지 필드는 정상 처리할 수 있었다. 즉, 정상 바인딩 된 필드는 검증을 적용할 수 있다.

HttpMessageConverter를 사용하여 객체 단위로 처리하는 @RequestBody 필드 단위 적용이 아니기 때문에 Item객체를 만들어야 @Valid , @Validated를 적용할 수 있다.

 


본 포스팅은 인프런 - 김영한님의 '스프링 MVC 2편 - 백엔드 웹 개발 활용 기술'을 참고하였습니다.

반응형
profile

제육's 휘발성 코딩

@sasca37

포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요! 맞구독은 언제나 환영입니다^^