본문 바로가기

Programming/스프링

[김영한 스프링 MVC 2] 검증2 - Bean Validation

 

소개
  • Bean Validation
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;
  //...
}

 

어노테이션으로 검증 로직 편리하게 적용 가능

 

Bean Validation

=> 특정한 구현체 X, Bean Validation 2.0(JSR-380)이라는 기술 표준 O

=> 검증 애노테이션과 여러 인터페이스의 모음 (일반적으로 사용하는 구현체 => 하이버네이트 Validator)

 

 

  • 관련 링크

공식 사이트: http://hibernate.org/validator/
공식 메뉴얼: https://docs.jboss.org/hibernate/validator/6.2/reference/en-US/html_single/
검증 애노테이션 모음: https://docs.jboss.org/hibernate/validator/6.2/reference/en-US/html_single/#validator-defineconstraints-spec

 

 

 

 

시작

 

스프링과 통합하지 않고 Bean Validation부터 사용해보기

 

 

  • 의존관계 추가 (build.gradle)
implementation 'org.springframework.boot:spring-boot-starter-validation'

 

jakarta.validation-api : Bean Validation 인터페이스
hibernate-validator : 구현체

 

 

 

  • 테스트 코드
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 {
 private Long id;

 @NotBlank // 빈값 + 공백 허용 X
 private String itemName;

 @NotNull // null 허용 X
 @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;
 }
}

 

 

javax.validation.constraints.NotNull
org.hibernate.validator.constraints.Range

 

javax.validation 으로 시작 => 특정 구현에 관계없이 제공되는 표준 인터페이스
org.hibernate.validator 로 시작 => 하이버네이트 validator 구현체를 사용할 때만 제공 (실무에서 대부분 하이버네이트 validator를 사용하므로 자유롭게 사용 가능)

 

 

 

 

스프링 적용

 

public class ValidationItemControllerV3 {

    private final ItemRepository itemRepository;
    // 기존 itemValidator 코드 삭제

    @GetMapping
    public String items(Model model) {
        List<Item> items = itemRepository.findAll();
        model.addAttribute("items", items);
        return "validation/v3/items";
    }

    @GetMapping("/{itemId}")
    public String item(@PathVariable long itemId, Model model) {
        Item item = itemRepository.findById(itemId);
        model.addAttribute("item", item);
        return "validation/v3/item";
    }

    @GetMapping("/add")
    public String addForm(Model model) {
        model.addAttribute("item", new Item());
        return "validation/v3/addForm";
    }

    @PostMapping("/add")
    public String addItem(@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}";
    }

    @GetMapping("/{itemId}/edit")
    public String editForm(@PathVariable Long itemId, Model model) {
        Item item = itemRepository.findById(itemId);
        model.addAttribute("item", item);
        return "validation/v3/editForm";
    }

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

        //특정 필드가 아닌 복합 룰 검증a
        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}";
    }

}

 

 

  • 스프링 MVC & Bean Validator

스프링부트에 spring-boot-starter-validation 라이브러리 적용 => 자동으로 Bean Validator - 스프링 통합됨

 

LocalValidatorFactoryBean을 글로벌 Validator로 등록함

컨트롤러에 @Valid 또는 @Validated 어노테이션만 적용하면 검증 오류 발생시 BindingResult에 담음

 

※ 직접 글로벌 Validator을 작성해 등록 시 스프링부트는 Bean Validator을 글로벌 Validator로 등록 X

@SpringBootApplication
public class ItemServiceApplication implements WebMvcConfigurer {
  // 글로벌 검증기 추가
  @Override
  public Validator getValidator() {
    return new ItemValidator();
  }
  // ...
}

 

※ @Valid, @Validated

javax.validation.@Valid => 
@Validated : 스프링 전용 검증 애노테이션 / 내부에 groups 기능 포함

@Valid : 자바 표준 검증 애노테이션 -> build.gradle 의존관계 추가 필요

 

 

 

  • 검증 순서

1. @ModelAttribute 각 필드에 타입 변환 시도

2. 성공 시 다음으로 / 실패 시 typeMismatch로 Fielderror 추가

3. Validator 적용

 

모델 객체에 바인딩 실패 시 Bean Validation 적용 X (타입 변환 성공해야 Bean Validation 적용)

 

 

 

 

에러 코드

 

  • Bean Validation이 제공하는 기본 오류 메시지

@NotBlank
NotBlank.item.itemName
NotBlank.itemName
NotBlank.java.lang.String
NotBlank

 

 

 

  • 메시지 수정 (error.properties)

NotBlank={0} 공백X 
Range={0}, {2} ~ {1} 허용
Max={0}, 최대 {1}

 

※ {0} : 필드명

 

 

 

  • Bean Validation 메시지 탐색 순서

1. 생성된 메시지 코드 순서대로 messageSource 에서 메시지 찾기
2. 애노테이션의 message 속성 사용 ex. @NotBlank(message = "공백! {0}")
3. 라이브러리가 제공하는 기본 값 ex. 공백일 수 없습니다

 

 

 

 

오브젝트 오류

 

  • @ScriptAssert()
@Data
@ScriptAssert(lang="javascript", script = "_this.price * _this.quantity >= 10000", 
message = "총합이 10000 이상이어야 합니다")
public class Item {
...

 

메시지 코드 =>

ScriptAssert.item
ScriptAssert

 

but 실제로 사용 시 제약 많고 복잡

검증 기능이 객체의 범위 넘어서는 경우 대응 어려움

∴ 오브젝트(글로벌) 오류 => 직접 자바 코드 작성 권장

 

 

    public String addItem(@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);
            }
        }
		//...
    }

 

 

 

 

수정에 검증 기능 추가 => 한계

 

  • ValidationItemControllerV3
	////@Validated 추가
	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}"; /////
    }

 

 

  • editForm.html
    <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>

 

검증 태그 추가

 

 

 

  • 수정 요구사항

등록: quantity 수량을 최대 9999까지 등록 / 수정: 수량을 무제한으로 변경 가능
등록: id에 값 없어도 됨 / 수정: id 값 필수

 

 

  • 요구사항 반영
@Data
public class Item {
  @NotNull /////
  private Long id;
  
  @NotBlank
  private String itemName;
  
  @NotNull
  @Range(min = 1000, max = 1000000)
  private Integer price;

  @NotNull
  //////@Max(9999)
  private Integer quantity;
  //...
}

 

=> 등록시 id에 값이 없으므로 notNull 오류 발생, 수량 제한도 사라짐

 

등록 & 수정에서 검증 조건의 충돌 발생

 

 

 

 

groups

 

검증 충돌 해결 방법

1. Bean Validation의 groups 기능 사용

2. Item 직접 사용하는 대신 폼 전송 위한 별도 모델 객체 생성

 

 

  • 저장/수정용 groups 생성
public interface SaveCheck {
}

 

public interface UpdateCheck {
}

 

 

  • Item
@Data
public class Item {
    @NotNull(groups = UpdateCheck.class) ////
    private Long id;

    @NotBlank(groups = {SaveCheck.class, UpdateCheck.class}, message = "공백 X") ////
    private String itemName;

    @NotNull(groups = {SaveCheck.class,UpdateCheck.class}) ////
    @Range(min = 1000, max = 1000000)
    private Integer price;

    @NotNull(groups = {SaveCheck.class,UpdateCheck.class}) ////
    @Max(9999)
    private Integer quantity;
	
    //...

 

 

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

 

Validated 어노테이션에 적용할 groups 클래스 작성

 

 

=> 전반적으로 복잡도 증가

=> 실무에서는 주로 등록/수정용 폼 객체 분리해 사용

 

 

 

Form 전송 객체 분리

 

실무에서는 groups 잘 사용 안함 => 등록 시 폼에서 전달하는 데이터가 도메인 객체와 딱 맞지 않기 때문

ex. 회원 등록 => 회원 관련 데이터 + 약관 정보 등 Item과 관련없는 데이터 넘어옴

 

=> Item 직접 전달받는 대신 복잡한 폼의 데이터를 컨트롤러까지 전달할 별도 객체 만들어 전달

ex. ItemSaveForm 이라는 폼 전용 객체 생성해 @ModelAttribute로 사용

 

 

  • 폼 데이터 전달 방법

1. Item 도메인 객체 사용

HTML Form -> Item -> Controller -> Item -> Repository

 

장점: Item 도메인 객체를 컨트롤러, 리포지토리까지 직접 전달 -> 중간에 Item을 생성 안 해서 단순
단점: 간단한 경우에만 적용 가능 (수정시 검증이 중복될 수 있고, groups를 사용해야 함)

 

2. 폼 데이터 전달 전용 객체 사용

HTML Form -> ItemSaveForm -> Controller -> Item 생성 -> Repository

장점: 맞춤형 폼 객체를 사용해서 복잡한 데이터 전달 가능, 등록/수정용 폼 객체를 별도로 만들어 검증 중복 X
단점: 폼 데이터를 기반으로 컨트롤러에서 Item 객체를 생성하는 변환 과정이 추가

 

 

 

  • Form 전송 객체 분리

 

  • Item
@Data
public class Item {
  private Long id;
  private String itemName;
  private Integer price;
  private Integer quantity;
}

 

 

  • Item 저장용 폼 (ItemSaveForm)
@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 수정용 폼 (ItemUpdateForm)
@Data
public class ItemUpdateForm {

    @NotNull
    private Long id;

    @NotBlank
    private String itemName;

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

    //수정시 수량 자유롭게 변경 가능
    private Integer quantity;
}

 

 

  • ValidationItemControllerV4
    // 저장
    @PostMapping("/add")
    // 폼으로부터 Item 대신 ItemSaveForm 전달받음 
    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 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 edit(@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("item")

item 추가 설정 안 하면 itemSaveForm이라는 이름으로 MVC Model에 담기게 됨

-> 뷰 템플릿 th:object 값 변경 필요하게 됨

 

 

 

 

Bean Validation - HTTP 메시지 컨버터

 

@Valid , @Validated => HttpMessageConverter (@RequestBody)에도 적용 가능

 

@ModelAttribute : HTTP 요청 파라미터(URL 쿼리 스트링, POST Form) 다룰 때 사용
@RequestBody : HTTP Body 데이터를 객체로 변환할 때 사용 ex) API JSON 요청

 

 

  • @RestController, @RequestBody 로 데이터 전송
  • ValidationItemApiController
@Slf4j
@RestController
@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("검증 오류 발생 = {}", bindingResult);
            return bindingResult.getAllErrors();
        }

        log.info("성공 로직 실행");
        return form;
    }
}

 

 

 

  • 실패

1. JSON을 객체로 생성 실패 (Controller 호출 실패)

POST http://localhost:8080/validation/api/items/add
{"itemName":"hello", "price":"A", "quantity": 10}

price 에 숫자가 아닌 문자 전달

 

=>

 

HTTP 메시지
{
 "timestamp": "2021-04-20T00:00:00.000+00:00",
 "status": 400,
 "error": "Bad Request",
 "message": "",
 "path": "/validation/api/items/add"
}

로그
[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.domain.item.Item["price"])]

 

Json -> ItemSaveForm 객체 생성 실패 -> 컨트롤러 호출 X

 

 

2. JSON 객체 생성 후 검증에서 실패

POST http://localhost:8080/validation/api/items/add
{"itemName":"hello", "price":1000, "quantity": 10000}

Bean Validation @Max에 걸림

 

=>

 

[
 {
 "codes": [
 "Max.itemSaveForm.quantity",
 "Max.quantity",
 "Max.java.lang.Integer",
 "Max"
 ],
 "arguments": [
 {
 "codes": [
 "itemSaveForm.quantity",
 "quantity"
 ],
 "arguments": null,
 "defaultMessage": "quantity",
 "code": "quantity"
 },
 9999
 ],
 "defaultMessage": "9999 이하여야 합니다",
 "objectName": "itemSaveForm",
 "field": "quantity",
 "rejectedValue": 10000,
 "bindingFailure": false,
 "code": "Max"
 }
]

 

실제 개발 시 객체 그대로 사용하지 않고,

필요한 데이터만 뽑아 별도 API 스펙 정의하고 해당 객체 만들어 반환해야 함

 

 

 

  • @ModelAttribute vs @RequestBody

@ModelAttribute : 각각의 필드 단위로 세밀하게 적용 -> 특정 필드에 타입 오류가 발생해도 나머지 필드는 정상 처리 가능
HttpMessageConverter : 전체 객체 단위로 적용 -> JSON 데이터를 객체로 변경 못 하면 이후 단계 진행 X, 예외 발생 (컨트롤러 호출 X, Validator 적용 X)