EP4. 검증2 - Bean Validation
Web/MVC2

EP4. 검증2 - Bean Validation

 

 

EP3. 검증1 에서 검증로직 구현은 개발자가 직접 작성했습니다.

 

사실 이 검증 로직은 여러 프로젝트에서 공통적으로 쓰이는 로직입니다.

 

그래서 검증 로직을 모든 프로젝트에서 공통적으로 쓰일 수 있게 공통화하고, 표준화 한 것이 바로

 

"Bean Validation"

입니다.

 

 

 

Bean Validation 이란?

Bean Validation 자체는 어떠한 구현체가 아니라 '기술 표준'입니다.

그래서 우리는 일반적으로 Bean Validation을 구현한 구현체 "하이버네이트 Validator"를 사용합니다.

(앞에 붙은 하이버네이트는 ORM과는 관련이 없는 이름입니다.)

 

(마치 기술 표준 JPA가 있고 구현체로 Hibernate 를 사용하는 것과 같은 맥락입니다.)

 

 

 

 

 

Bean Validation 적용

 

1. validation 라이브러리 넣기

dependencies {
	...
	implementation 'org.springframework.boot:spring-boot-starter-validation'
	...
}

 

2. 검증 하고싶은 필드에 애노테이션 넣기

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
    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;
    }
}

 

 

3. 검증하고 싶은 타겟객체에 @Validated 넣기, 바로 뒤에 파라미터 BindingResult 넣기

 

 

끝.  너무나 간단합니다.

 

 

 

Bean Validation 작동원리

 

  1. 스프링 부트는 자동으로 LocalValidatorFactoryBean을 글로벌 Validator로 등록합니다.
  2. Validator는 @NotNull 같은 애노테이션을 보고 검증을 수행합니다.
  3. 검증 오류가 발생하면 FieldError, ObjectError를 생성해서 BindingResult에 담아줍니다.

한마디로, 애노테이션 기반으로 EP3. 검증1 로직을 자동으로 수행해 줍니다.

 

 

LocalValidator 작동원리 파악을 위한 테스트코드

 

package hello.itemservice.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
    public void beanValidation() throws Exception {
        //given
        ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
        Validator validator = factory.getValidator();

        //when
        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.getMessage() = " + violation.getMessage());
        }
        //then
    }
}

 

 

 

 

검증 순서

  1. @ModelAttribute로 각각의 필드에 타입변환을 시도합니다.
    - 성공시 : 다음으로 넘어감
    - 실패시 : 'typeMismatch'로 'FieldError'를 추가합니다.
  2. Validator를 적용합니다.

* 타입 변환이 성공한 필드에만 검증을 적용합니다.

 

 

 

 

오류 메세지 사용자화

지금은 errors.properties에 오류메세지를 정의하지 않았는데도 알아서 오류메세지를 출력합니다.

 

typeMismatch 때와 마찬가지로 이미 스프링에서는 에러 메세지를 찾기 위한 String 배열을 생성하지만 저희가 정의해주지 않았기 때문에 기본으로 정해진 메세지를 사용하고 있는 것입니다.

 

@NotBlank

  1. NotBlank.item.itemName
  2. NotBlank.itemName
  3. NotBlank.java.lang.String
  4. NotBlank

@Range

  1. Range.item.price
  2. Range.price
  3. Range.java.lang.Integer
  4. Range

 

그래서 더 자세한 레벨의 String을 정해주면 그 레벨의 에러메세지를 출력할 수 있습니다.

 

 

Bean Validation이 메세지 찾는 순서

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

 

 

 

Bean Validation - 오브젝트 오류

지금까지는 필드에다가 애노테이션을 적는 필드오류를 해결했습니다.

 

그럼 복합적으로 검사하는 "오브젝트 오류"는 어떻게 해결할까요?

 

사실 원래 사용하던 방식이 제일 낫습니다.

 

컨트롤러에서 자바코드로 복합 룰 검증하는 코드를 적어주는 방식을 사용하면 됩니다.

    @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}";
    }

 

 

 

 

 

Bean Validation의 한계

 

지금은 등록과 수정에 다른 검증을 적용할 수 없습니다.

 

등록시

  • id값이 생성되어 들어가기 때문에 id값이 없어도된다.
  • quantity 는 최대 999개

수정시

  • id값이 필수이다.
  • quantity 수량을 무제한으로 변경

 

이런식으로 등록과 수정에 서로 다른 검증이 필요합니다.

 

하지만 수정 요구사항을 위해 필드에서 수정검증에 맞게 애노테이션을 변경하면 등록시에 문제가 발생합니다.

 

 

 

 

해결방법 - Bean Validation groups

 

1. groups 사용을 위한 인터페이스를 만들어줍니다.

package hello.itemservice.domain.item;

public interface SaveCheck {
}
package hello.itemservice.domain.item;

public interface UpdateCheck {
}

 

 

2. 필드마다 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;

    public Item() {
    }

    public Item(String itemName, Integer price, Integer quantity) {
        this.itemName = itemName;
        this.price = price;
        this.quantity = quantity;
    }
}

 

3. 등록할땐 SaveCheck.class, 수정할땐 UpdateCheck.class를 @Validated 안에 넣어줍니다.

 

 

 

 

 

Form 전송 객체 분리

사실 실무에서는 도메인 객체를 view나 컨트롤러에서 그대로 사용하지 않습니다.

 

  1. 엔티티를 그대로 view에 노출하는 것은 보안상 문제가 있고,
  2. 등록과 수정 폼에서 사용하는 데이터는 대부분 다르기 때문입니다.

 

 

Item 등록 객체 - ItemSaveForm

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(9999)
    private Integer quantity;
}

 

 

Item 수정 객체 - ItemUpdateForm

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대신 새로 만든 객체를 넣어주면 됩니다.

대신에, model (view)에서는 "item"이라는 객체 이름을 그대로 사용하므로 @ModelAttribute에 ("item")을 넣어줍니다.

 

  • 넣어주지 않을시 : model.addAttribute("itemSaveForm", itemSaveForm);
  • 넣어줄 시 : model.addAttribute("item", itemSaveForm);
@PostMapping("/add")
public String addItem(@Validated @ModelAttribute("item") ItemSaveForm form, BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model) {

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

 

 

 

 

참고

검증 애노테이션 메뉴얼

  • 웬만한 검증 다 있음.
  • CreditCard, Mail, Url .. 등등 많음

 

 

 

 

 

HTTP 메시지 컨버터 - API

 

지금까지 사용한 @ModelAttibute는 지금처럼 HTTP 요청 파라미터(URL 쿼리 스트링, POST form)을 다룰 때 사용합니다.

 

하지만 HTTP body의 데이터를 객체로 변환해야하는 API JSON 요청을 다룰 때는 @RequestBody 를 사용해야 합니다.

 

  • JSON 데이터를 객체로 변경해야 하기때문에 @ModelAttribute를 사용하지 않고 @RequestBody를 사용합니다.
  • 검증오류발생시에는 bindingResult.getAllErrors()를 하면 가지고있는 모든 Error를 반환합니다.

ValidationItemApiController

package hello.itemservice.web.validation;

import hello.itemservice.web.validation.form.ItemSaveForm;
import lombok.extern.slf4j.Slf4j;
import org.springframework.validation.BindingResult;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@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("검증 오류 발생 errors = {}", bindingResult);
            return bindingResult.getAllErrors();
        }

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

 

정상데이터

호출

{
    "itemName" : "hello",
    "price" : 1000,
    "quantity" : 100
}

결과 (일단은 그대로 반환하게 해놓음)

{
    "itemName": "hello",
    "price": 1000,
    "quantity": 100
}

 

검증 오류 데이터

호출

{
    "itemName" : "hello",
    "price" : 1,
    "quantity" : 1
}

결과

[
    {
        "codes": [
            "Range.itemSaveForm.price",
            "Range.price",
            "Range.java.lang.Integer",
            "Range"
        ],
        "arguments": [
            {
                "codes": [
                    "itemSaveForm.price",
                    "price"
                ],
                "arguments": null,
                "defaultMessage": "price",
                "code": "price"
            },
            1000000,
            1000
        ],
        "defaultMessage": "1000에서 1000000 사이여야 합니다",
        "objectName": "itemSaveForm",
        "field": "price",
        "rejectedValue": 1,
        "bindingFailure": false,
        "code": "Range"
    }
]

 

바인딩 오류 데이터

호출

{
    "itemName" : "hello",
    "price" : "AAA",
    "quantity" : 1
}

결과 (에러페이지)

{
    "timestamp": "2021-10-20T14:13:00.628+00:00",
    "status": 400,
    "error": "Bad Request",
    "message": "",
    "path": "/validation/api/items/add"
}

 

 

 

@ModelAttribute  VS  @RequestBody

  • @ModelAttibute는 각각의 필드 단위로 적용됩니다. 그래서 특정 필드가 바인딩 되지 않아도 (ex. 가격에 "aa"문자를 넣음) 나머지 필드는 정상 바인딩 되고, Validator를 사용한 검증도 적용할 수 있습니다.
  • @RequestBody는 Http메시지컨버터 단계에서 JSON 데이터를 객체로 변경하지 못하면 이후 단계가 진행되지 않고 바로 예외가 발생합니다. 그래서 컨트롤러가 아예 호출이 되지 않아서 Validator도 적용할 수 없습니다.

 

 

 

 

 

 

최종 정리

JSON 요청 데이터를 처리하는 API개발이 아닐 때,

 

HTTP 요청 파라미터(URL 쿼리 스트링, POST form)을 다룰 때 순서 검증걸기 순서

  1. implementation 'org.springframework.boot:spring-boot-starter-validation' 추가
  2. 등록객체와 수정객체를 따로 만들기. (어노테이션으로 각 필드에 검증 걸기)
  3. 컨트롤러에서 @ModelAttribute 앞에 @Validated 넣어주기
  4. @ModelAttribute 뒤에 BindingResult 넣어주기
  5. 오브젝트 오류검증은 컨트롤러 안에서 자바코드로 넣어주기

 

'Web > MVC2' 카테고리의 다른 글

EP3. 검증1 - Validation  (0) 2021.10.10
EP2. 메세지와 국제화  (0) 2021.08.20
EP1. 타임리프 Thymeleaf  (0) 2021.07.26