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

Bean Validation

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

implementation 'org.springframework.boot:spring-boot-starter-validation'
  • build.gradle에 라이브러리를 추가하자. 해당 라이브러리를 등록하면 스프링 부트는 자동으로 글로벌 Validator로 등록한다. 단, 사용할 때 해당 메서드 파라미터에 @Validated 또는 @Valid 를 넣어줘야 한다. 검증 오류가 발생하면 FieldError, ObjectError를 생성해서 BindingResult에 담아준다.
  • 이후에 나올 groups 기능을 사용하려면 스프링 전용 검증 애노테이션인 @Validated를 사용해야 한다.
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을 적용할 수 있다. 애노테이션이 직관적이기 때문에 별도의 설명은 넘어간다.
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. @ModelAttribute 각각의 필드에 타입 변환 시도
    1. 성공하면 다음으로
    2. 실패하면 typeMismatchFieldError 추가
  2. Validator 적용

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

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

 

Bean Validation - 에러 코드

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

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

image

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

Bean Validation - 오브젝트 오류

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

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

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;
    }
}
  • 실행해보면 정상 수행되는 것을 확인할 수 있다. 다만, 실제 사용하기에 제약이 많고 복잡하므로 직접 자바 코드로 작성하는 것을 권장한다.
@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}";
}
  • 기존 방식대로 오브젝트와 관련된 검증은 직접 처리하는 것이 바람직하다.

Bean Validation - 수정에 적용

@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를 추가하고, 오브젝트 에러와 에러 존재 여부 판단 조건을 넣어준다.
<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를 적용시켜서 오류 발생 시에 화면의 변화를 줄 수 있도록 한다.

Bean Validation 한계

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

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

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를 통해 해결할 수 있다.

Bean Validation - groups

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

  1. Beanvalidation의 groups 기능을 사용한다.
  2. Item을 직접 사용하지 않고, 별도의 폼을 위한 DTO 객체를 만들어서 사용한다.
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에 지정한 인터페이스들은 빈 인터페이스로 단순히 명시용이다.
@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는 실제 잘 사용되지는 않는데, 그 이유는 실무에서는 주로 다음에 등장하는 등록용 폼 객체와 수정용 폼 객체를 분리해서 사용하기 때문이다.

Form 전송 객체 분리

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

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

HTML Form - Item - Controller - Item - Repository

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

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

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

  • 장점 : 전송하는 폼 데이터가 복잡해도 별도의 폼 객체를 사용해서 데이터를 전달 받을 수 있다. 별도의 폼 객체를 만들기 때문에 검증이 중복되지 않는다.
  • 단점 : 폼 데이터를 기반으로 컨트롤러에서 Item 객체를 생성하는 변환 과정이 추가된다.
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 폼 객체 생성
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 폼 객체 생성
@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에서 기본적으로 생성되는 네임이 있으므로 뷰 단에서 적용한 이름을 맞춰줘야 한다. (아니면 모두 수정 해야한다.) 또한 기존 모델에 필요한 값을 넣어 레포지토리에 넣어주면 된다.

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로 데이터를 전송했을 때 성공 결과

 

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도 실행되지 않는다.
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에 오류 결과를 보관한다.

 

@ModelAttribute vs @RequestBody

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

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

 


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

반응형
profile

제육's 휘발성 코딩

@sasca37

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