스프링/MVC

[MVC 기초] Bean Validation (Spring 기능 적극 활용)

nomoreFt 2022. 5. 26. 17:20

Bean Validation


Controller단에서 Validation을 구현하는 것은, 소스코드도 복잡해지고 한 눈에 들어오기 어려워진다.
그래서 Spring에서는 Bean Validation을 사용하도록 권장한다.

Bean ValidationJPA처럼 추상 표준기술이다. 많은 구현체들이 존재하고, 대표적으로
하이버네이트 Validation이 있다.


Gradle 설정

implementation 'org.springframework.boot:spring-boot-starter-validation' 을 추가해줘야 한다.

  • jakarta.validation:jakarta.validation-api 그래들이 들어온 모습
  • 객체에 @Max, @NotNull 등을 사용할 때 있는 어노테이션이 여기 들어있다.

Screen Shot 2022-05-25 at 11 26 12 AM

실 사용 모습

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() {
    }

스프링을 제외한 Bean Validation Test Code 동작 원리

  • ValidatorFactory, Validator를 생성한다.
import javax.validation.Validator;
import javax.validation.ValidatorFactory;

    ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
    Validator validator = factory.getValidator();
  • Bean Validation을 의도적으로 위반한 객체를 생성한다.
    Item item = new Item();
    item.setItemName(" ");//공백
    item.setPrice(0); //가격은 0일 수 없다.
    item.setQuantity(10000); //양은 9999까지
  • validate를 시행하고, 결과를 출력한다.
    Set<ConstraintViolation<Item>> violations = validator.validate(item);
    for (ConstraintViolation<Item> violation : violations) {
      System.out.println("violation = " + violation);
      System.out.println("violation.getMessage() = " + violation.getMessage());
    }

결과

Screen Shot 2022-05-25 at 1 49 25 PM


BeanValidation에도 원하는 ErrorCode 설정하기

  • BeanValidation은 Annotation과 동일한 이름으로 등록된다.
    ex) [NotNull.item.price,NotNull.price,NotNull.java.lang.Integer,NotNull]
  • 따라서 그냥 properties에 해당 이름을 따서 삽입해주면 된다.

BeanValidation 메시지 적용 순서

  1. 기본 생성 에러 코드 순서대로 errors.properties(허용된 properties)에서 찾는다.
    ex) [NotNull.item.price,NotNull.price,NotNull.java.lang.Integer,NotNull]
  2. 어노테이션의 message 속성으로 찾는다. -> @NotBlank(message="공백 {0}")
  3. 라이브러리가 제공하는 기본 값 사용.

BeanValidationObjectError
추가적으로 @ScriptAssert란 기능이 구현되어 있는데, 복잡하고 비효율적이다.
FieldError는 그냥 어노테이션으로 해결하고, ObjectError는 기존 bindingResult.reject로 해결하면 된다.


BeanValidation 한계극복 (모든 CRUD 페이지가 같은 어노테이션이 적용된 객체를 공유할 경우)


동일한 객체로 수정과 추가를 사용할 경우, 각각 다른 검증 조건을 시행해야 할 경우

방법 2가지

  1. groups로 나눠서 분리하는 기능 사용
  2. 별도 From별로 전송 객체를 생성하는 방법 사용

  1. groups로 나눠서 분리하는 기능 사용

공백 인터페이스를 생성해주고, 객체에 groups를 분리하여 설정해준다. 그리고 @Validated의 Value값으로 넣어주면
해당 컨트롤러가 실행될 때 분리해서 Validation을 시행한다.

  • Interface 생성 (의미 없는 그냥 이름만)
public interface UpdateCheck {
}

public interface SaveCheck {
}

  • 객체에 있는 validation Annotation에 groups 채워주기
@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;
}
  • 작동하는 Controller 메서드의 @Validated의 Value 인자 채워주기 (* @Valid는 안된다)
  • 아이템이 추가할 때는 Item 객체에 SaveCheck.class 그룹들만, 수정시에는 UpdateCheck.class그룹만 된다.

@PostMapping("/add")
public String addItemV6(@Validated(SaveCheck.class) @ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes){
        }


  @PostMapping("/{itemId}/edit")
    public String edit(@PathVariable Long itemId, @Validated(UpdateCheck.class) @ModelAttribute Item item, BindingResult bindingResult){
          }

  1. 별도 Form별로 전송 객체를 생성하는 방법 사용

등록, 수정 등 Form 별로 Java 객체를 생성해서 사용하는 방법이다.

  • 객체 저장시에 쓰는 Form 생성
@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) {
   }
   }
  • 객체 업데이트시에 쓰는 Form 생성
@Data
public class ItemUpdateForm {

    @NotNull
    private Long id;

    @NotBlank
    private String itemName;
    @NotNull
    @Range(min = 1000, max = 1000000)
    private Integer price;

    //수정에서는 수량은 자유롭게 변경 가능하다.
    private Integer quantity;

}

   @PostMapping("/{itemId}/edit")
   public String edit(@PathVariable Long itemId, @Validated @ModelAttribute("item") ItemUpdateForm form, BindingResult bindingResult) {
   }

수정과 생성 다른 조건으로 검증할 수 있게 했다. 보통 실무에서 사용하는 방법.


RestAPI 시에 사용되는 Validation

@ModelAttribute vs @RequestBody

  • @ModelAttribute는 HTTP 요청 파라미터*(URL 쿼리 스트링, POST Form)을 다룰 떄 사용한다.
  • @RequestBody는 HTTP Body의 데이터를 객체로 변환할 때 사용(API JSON)

API 형변환 오류시 Controller 아예 작동 안하는 이유

@ModelAttribute 는 변수 단위로 작동하기 때문에, 타입 오류가 날 때, 나머지는 작동을 시킬 수 있다.
그런데 @RequestBody붙은 컨트롤러에서는 JSON으로 통짜로 받아서 @ModelAttribute로 변환시켜야 하기 때문에,
그 사전에 HttpMessageConverter에서 자바객체로 변환시에 오류가 나서 아예 Controller가 작동하지 않는다.

HttpMessageConverter변환단계 이후, Validation에서 오류 발생시 API Retrun 메세지

  • Controller
@RestController //이후 모든 메서드에 @ResponseBody 붙여준다.(return 되는 값을 Json으로 변환해줌)
@RequestMapping("/validation/api/items")
public class ValidationItemApiController {

    @PostMapping("/add")
    public Object addItem(@RequestBody @Validated ItemSaveForm form, BindingResult bindingResult) {
        log.info("API 컨트롤러 호출");

        if (bindingResult.hasErrors()) {
            log.info("검증 오류 발생 errors={}", bindingResult);
            return bindingResult.getAllErrors();
        }
        log.info("성공 로직 실행");
        return form;
    }
}
  • JSon Validation에 걸리게 잘못된 값 전송시
[
    {
        "codes": [
            "Max.itemSaveForm.quantity",
            "Max.quantity",
            "Max.java.lang.Integer",
            "Max"
        ],
        "arguments": [
            {
                "codes": [
                    "itemSaveForm.quantity",
                    "quantity"
                ],
                "arguments": null,
                "defaultMessage": "quantity",
                "code": "quantity"
            },
            9999
        ],
        "defaultMessage": "must be less than or equal to 9999",
        "objectName": "itemSaveForm",
        "field": "quantity",
        "rejectedValue": 999999,
        "bindingFailure": false,
        "code": "Max"
    }
]

추후 @ResponseBody를 통해 BindingResult 결과값들을 반환한 이 결과값들을 수정해줄 것이다.