본문 바로가기

SPRING

김영한 - 스프링 MVC 2편 - 백엔드 웹 개발 핵심 기술 5일차

Bean Validation

Bean Validation 이란 ?

Bean Validation 2.0이라는 기술 표준이다.

검증 애노테이션과 여러 인터페이스의 모음이다.

 

Bean Validation을 사용하려면 의존관계를 추가해야 한다.

 

implementation 'org.springframework.boot:spring-boot-starter-validation'

 

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

검증 애노테이션

@NotBlank : 빈값 + 공백만 있는 경우를 허용하지 않는다.

@NotNull : null을 허용하지 않는다.

@Range(min = 1000, max = 1000000) : 범위 안의 값이어야 한다.

@Max(9999) : 최대 9999까지만 허용한다.

 

Bean Validation - 스프링 적용

package hello.itemservice.web.validation;

import hello.itemservice.domain.item.Item;
import hello.itemservice.domain.item.ItemRepository;
import hello.itemservice.domain.item.SaveCheck;
import hello.itemservice.domain.item.UpdateCheck;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.util.StringUtils;
import org.springframework.validation.BindingResult;
import org.springframework.validation.FieldError;
import org.springframework.validation.ObjectError;
import org.springframework.validation.ValidationUtils;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.WebDataBinder;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;

import java.util.List;

@Slf4j
@Controller
@RequestMapping("/validation/v3/items")
@RequiredArgsConstructor
public class ValidationItemControllerV3 {

    private final ItemRepository itemRepository;

    @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) {

        //특정 필드가 아닌 복합 룰 검증
        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("/add")
    public String addItemV2(@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}";
    }

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

}

스프링 부트는 자동으로 글로벌 Validator로 등록한다.

LocalValidatorFactoryBean 을 글로벌 Validator로 등록한다. 이 Validator는 @NotNull 같은 애노테이션을 보고

검증을 수행한다. 이렇게 글로벌 Validator가 적용되어 있기 때문에, @Valid , @Validated만 적용하면 된다.

검증 오류가 발생하면 FieldError , ObjectError를 생성해서 BindingResult에 담아준다.

 

Bean Validation - 에러 코드

 

BeanValidation이 기본으로 제공하는 오류 메시지를 좀 더 자세히 변경하고 싶은 경우

 

Bean Validation을 적용하고 bindingResult에 등록된 검증 오류 코드롤 보면

오류코드가 애노테이션 이름으로 등록된다. 마치 typeMismatch와 유사하다.

 

@NotBlank

NotBlank.item.itemName

NotBlank.itemName

NotBlank.java.lang.String

NotBlank

 

@Range

Range.item.price

Range.price

Range.java.lang.Integer

Range

 

메시지 등록

 

errors.properties

#Bean Validation 추가
NotBlank={0} 공백X
Range={0}, {2} ~ {1} 허용
Max={0}, 최대 {1}

 

 

실행해보면 등록한 메시지가 적용되는 것을 볼 수 있다.

 

BeanValidation 메시지 찾는 순서

 

생성된 메시지 코드 순서대로 messageSource 에서 메시지 찾기

애노테이션 message 속성 사용 -> @NotBlank

라이브러리가 제공하는 기본 값 사용 -> 널이어서는 안됩니다.

 

애노테이션 message 사용 예

@NotBlank(message = "공백은 입력할 수 없습니다.")
private String itemName;

 

Bean Validation - 오브젝트 오류

 

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

@ScriptAssert()를 사용하면 된다.

 

@Data
@ScriptAssert(lang = "javascript", script = "_this.price * _this.quantity >= 10000", message = "총합이 10000원 넘게 입력해주세요.")
public class Item {
//...
}

실제 사용해보면 제약이 많고 복잡하다. 

실무에서는 검증 기능이 해당 객체의 범위를 넘어서는 경우들도 종종 등장하는데, 그런 경우 대응이 어렵다.

따라서 오브젝트 오류의 경우 @ScriptAssert를 억지로 사용하는 것 보다는 직접 자바 코드로 작성하는 것이 좋다.

 

Bean Validation - 한계

 

데이터를 등록할 때 와 수정할 때 요구사항이 다를 수 있다.

 

예를 들면 등록시에는 수량을 최대 9999까지 등록할 수 있지만 수정시에는 수량을 무제한으로 변경할 수 있다.

등록시에는 id 값이 없어도 되지만, 수정시에는 id 값이 필수이다.

 

@Data
public class Item {

    @NotNull
    private Long id;
    
    //...
}

 

이렇게 id에 @NotNull을 추가한다면 등록시에는 id값이 없으므로 에러가 날 것이다.

 

이러한 문제를 해결하는 방법으로 2가지가 있다.

 

1. BeanValidation의 groups 기능을 사용한다.

2. Item을 직접 사용하지 않고 , ItemSaveForm , ItemUpdateForm 같은 폼전송을 위한 별도의 모델객체를 만들어서 

사용한다.

 

BeanValidation groups 기능 사용

 

저장용 groups 생성 

package hello.itemservice.domain.item;

public interface SaveCheck {
}

 

수정용groups 생성

 

package hello.itemservice.domain.item;

public interface UpdateCheck {
}

 

 

Item - groups 적용

 

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
public class Item {

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


 

컨트롤러에 groups 적용

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

 

@Valid에는 groups를 적용할 수 있느 기능이 없다. groups를 사용하려면 @Validated를 사용해야 한다.

 

Form 전송 객체 분리 - 프로젝트 준비 V4

실무에서는 groups를 잘 사용하지 않는데, 그 이유가 다른 곳에 있다. 

등록시 폼에서 전달하는 데이터가 Item 도메인 객체와 딱 맞지 않기 때문이다.

보통 Item을 직접 전달받는 것이 아니라, 복잡한 폼의 데이터를 컨트롤러까지 전달할 별도의 객체를 만들어서 전달한다.

예를 들면 ItemSaveForm이라는 폼을 전달받는 전용 객체를 만들어서 @ModelAttribute로 사용한다.

이것을 통해 컨트롤러에서 폼 데이터를 전달 받고, 이후 컨트롤러에서 필요한 데이터를 사용해서 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.NotNull;

@Data
public class ItemSaveForm {

    @NotNull
    private String itemName;

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

    @NotNull
    @Max(value = 9999)
    private Integer quantity;

}
package hello.itemservice.web.validation.form;

import lombok.Data;
import org.hibernate.validator.constraints.Range;

import javax.validation.constraints.Max;
import javax.validation.constraints.NotNull;

@Data
public class ItemUpdateForm {

    @NotNull
    private Long id;

    @NotNull
    private String itemName;

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

    //수정시에는 자유롭게 변경할 수 있다.
    private Integer quantity;

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

 

Bean Validation - HTTP 메시지 컨버터

 

@Valid , @Validated는 HttpMessageConverter에도 적용할 수 있다.

 

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

Postman을 사용해서 테스트 해봤다.

 

 

 

 

HttpMessageConverter 에서 요청 JSON을 Item 객체로 생성하는데 실패한다.

이 경우는 Item 객체를 만들지 못하기 때문에 컨트롤러 자체가 호출되지 않고 그 전에 예외가 발생한다.

Validator도 실행되지 않는다.

 

 

return bindingResult.getAllErrors();는 ObjectError 와 FieldError를 반환한다.

스프링이 이 객체를 JSON으로 변환해서 클라이언트에 전달했다.

 

출처

https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81-mvc-2