검증(Vaildation)
프로그램의 입력 값은 예측할 수 없다. 만약 잘못된 값이 들어온다면 프로그램에 문제가 생긴다. 따라서 사용자 입력 등의 프로그램 입력을 받는다면 항상 입력 값을 검증하고 잘못된 값이 들어올 시 처리 로직을 만들어야 한다. 여기서 입력 값이 올바른지 확인하는 과정을 검증이라한다.
검증에는 클라이언트 검증과 서버 검증이 있다. 클라이언트 검증은 클라이언트 측에서 검증하는 것이며 서버 검증은 서버 측에서 검증한다.
- 클라이언트 검증은 입력을 받으면서 검증할 수 있어 사용자 편의가 증가하지만 사용자 값을 조작해 서버로 보낼 수 있다. 따라서 최종적으로 서버 검증이 필요하다. 하지만 서버 검증만 제공한다면 사용자 편의가 떨어진다.
- 둘을 적절히 섞어서 사용하되, 최종적으로 서버 검증은 꼭 필요하다.
- API 서버인 경우 API 스펙에 따라 검증 오류를 응답에 잘 남겨야한다.
검증 시나리오
아래는 상품 저장을 성공적으로 수행하는 시나리오이다. PRG 기법으로 클라이언트에서 POST 메서드를 중복 호출하지 않도록 방지하고 있다.
위 시나리오에서 POST 메서드로 전달한 데이터가 잘못된 형식이거나 허용되지 않는 값이면 어떻게 해야할까? 서버 측에서는 입력값에 대한 검증 로직을 수행한다. 검증이 실패하면 다시 상품 등록 폼(화면)을 보여주고 해당 화면에서 어떤 값을 잘못 입력했는지 오류 메시지를 출력하는 식으로 검증 실패 처리 로직을 구현할 수 있을 것이다.
검증 로직
ValidationItemControllerV1
직접 검증 로직을 작성한 것이다. 또한 검증에 실패했을경우 검증 오류를 다시 입력 폼과 함께 보여주는 식으로 처리 로직을 구현한다.
@PostMapping("/add")
public String addItem(@ModelAttribute Item item, RedirectAttributes redirectAttributes, Model model) {
//검증 오류 결과를 보관
Map<String, String> errors = new HashMap<>();
//검증 로직
if (!StringUtils.hasText(item.getItemName())) {
errors.put("itemName", "상품 이름은 필수입니다."); }
if (item.getPrice() == null || item.getPrice() < 1000 || item.getPrice() > 1000000) {
errors.put("price", "가격은 1,000 ~ 1,000,000 까지 허용합니다.");
}
if (item.getQuantity() == null || item.getQuantity() >= 9999) {
errors.put("quantity", "수량은 최대 9,999 까지 허용합니다.");
}
//특정 필드가 아닌 복합 룰 검증
if (item.getPrice() != null && item.getQuantity() != null) {
int resultPrice = item.getPrice() * item.getQuantity();
if (resultPrice < 10000) {
errors.put("globalError", "가격 * 수량의 합은 10,000원 이상이어야 합니다. 현재 값 = " + resultPrice);
}
}
//검증에 실패하면 다시 입력 폼으로
if (!errors.isEmpty()) {
model.addAttribute("errors", errors);
return "validation/v1/addForm";
}
//성공 로직
Item savedItem = itemRepository.save(item);
redirectAttributes.addAttribute("itemId", savedItem.getId());
redirectAttributes.addAttribute("status", true);
return "redirect:/validation/v1/items/{itemId}";
}
문제점
- 커맨드 객체에 바인딩 시 타입 에러가 발생하면 컨트롤러에 진입하기도 전에 예외가 발생하면서 400 오류 페이지를 띄어준다.
- 사용자가 400 오류 페이지를 받으면 자신이 뭘 잘못했는지 알 수가 없다.
ValidationItemControllerV2 - 1
스프링이 제공하는 검증 오류 처리 방법을 알아보자. 공식 문서에 따르면 BindingResult는 커맨드 객체(@ModelAttribute 인자)의 검증 오류와 바인딩 오류 또는 @RequestBody, @RequestPart 인자의 검증 에러를 저장하고 접근하기 위한 인터페이스라고 나온다. 주의할점은 BindingResult는 반드시 검증을 수행할 인자의 바로 뒤에 와야한다.
필드 에러가 있으면 FieldError 객체를 생성해서 bindingResult에 담으며, 특정 필드를 넘어서는 에러가 있으면 ObjectError 객체를 생성해 bindingResult에 담는다.
@PostMapping("/add")
public String addItemV1(@ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes) {
if (!StringUtils.hasText(item.getItemName())) {
bindingResult.addError(new FieldError("item", "itemName", item.getItemName(), false, null, null, "상품 이름은 필수입니다."));
}
if (item.getPrice() == null || item.getPrice() < 1000 || item.getPrice() > 1000000) {
bindingResult.addError(new FieldError("item", "price", item.getPrice(), false, null, null, "가격은 1,000 ~ 1,000,000 까지 허용합니다."));
}
if (item.getQuantity() == null || item.getQuantity() > 10000) {
bindingResult.addError(new FieldError("item", "quantity", item.getQuantity(), false, null, null, "수량은 최대 9,999 까지 허용합니다."));
}
//특정 필드 예외가 아닌 전체 예외
if (item.getPrice() != null && item.getQuantity() != null) {
int resultPrice = item.getPrice() * item.getQuantity();
if (resultPrice < 10000) {
bindingResult.addError(new ObjectError("item", null, null, "가격 * 수량의 합은 10,000원 이상이어야 합니다. 현재 값 = " + resultPrice));
}
}
if (bindingResult.hasErrors()) {
log.info("errors={}", bindingResult);
return "validation/v2/addForm";
}
//성공 로직
Item savedItem = itemRepository.save(item);
redirectAttributes.addAttribute("itemId", savedItem.getId());
redirectAttributes.addAttribute("status", true);
return "redirect:/validation/v2/items/{itemId}";
}
- 커맨드 객체에 바인딩 시 타입 오류가 발생하면 스프링이 오류 정보(FieldError)를 BindingResult에 넣어주고 컨트롤러를 정상 호출한다.
- 컨트롤러에서는 다시 입력 폼과 함께 오류 메시지를 출력해 사용자가 뭘 잘못했는지 알려준다.
FieldError의 생성자는 아래와 같다. ObjectErorr의 생성자도 이와 유사하다.
public FieldError(String objectName,
String field,
@Nullable Object rejectedValue,
boolean bindingFailure,
@Nullable String[] codes,
@Nullable Object[] arguments,
@Nullable String defaultMessage )
- objectName : 오류가 발생한 객체 이름
- field : 오류가 발생한 필드 이름
- rejectedValue : 사용자가 입력한 값(거절된 값)
- bindingFailure : 바인딩 실패인지, 검증 실패인지 구분 값
- codes : 오류 코드
- arguments : 오류 메시지에서 사용하는 인자
- defaultMessage : 기본 오류 메세지
ValidationItemControllerV2 - 2
FieldError, ObjectError의 생성자에는 codes와 arguments 인자가 있다. 이들을 활용하면 오류 코드를 통해 MessageSource로부터 오류 메시지를 출력할 수 있다. 즉 오류 메시지를 한 곳에 모아 관리할 수 있다.
@PostMapping("/add")
public String addItemV2(@ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes) {
if (!StringUtils.hasText(item.getItemName())) {
bindingResult.addError(new FieldError("item", "itemName", item.getItemName(), false, new String[]{"required.item.itemName"}, null, null));
}
if (item.getPrice() == null || item.getPrice() < 1000 || item.getPrice() > 1000000) {
bindingResult.addError(new FieldError("item", "price", item.getPrice(), false, new String[]{"range.item.price"}, new Object[]{1000, 1000000}, null));
}
if (item.getQuantity() == null || item.getQuantity() > 10000) {
bindingResult.addError(new FieldError("item", "quantity", item.getQuantity(), false, new String[]{"max.item.quantity"}, new Object[]{9999}, null));
}
//특정 필드 예외가 아닌 전체 예외
if (item.getPrice() != null && item.getQuantity() != null) {
int resultPrice = item.getPrice() * item.getQuantity();
if (resultPrice < 10000) {
bindingResult.addError(new ObjectError("item", new String[]{"totalPriceMin"}, new Object[]{10000, resultPrice}, null));
}
}
if (bindingResult.hasErrors()) {
log.info("errors={}", bindingResult);
return "validation/v2/addForm";
}
//성공 로직
Item savedItem = itemRepository.save(item);
redirectAttributes.addAttribute("itemId", savedItem.getId());
redirectAttributes.addAttribute("status", true);
return "redirect:/validation/v2/items/{itemId}";
}
ValidationItemControllerV2 - 3
BindingResult가 제공하는 rejectValue, reject 메서드를 활용하면 더 쉽게 BindingResult에 FieldError, ObjectError 객체를 담을 수 있다.
@PostMapping("/add")
public String addItemV3(@ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes) {
if (!StringUtils.hasText(item.getItemName())) {
bindingResult.rejectValue("itemName", "required");
}
if (item.getPrice() == null || item.getPrice() < 1000 || item.getPrice() > 1000000) {
bindingResult.rejectValue("price", "range", new Object[]{1000, 1000000}, null);
}
if (item.getQuantity() == null || item.getQuantity() > 10000) {
bindingResult.rejectValue("quantity", "max", new Object[]{9999}, null);
}
//특정 필드 예외가 아닌 전체 예외
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/v2/addForm";
}
//성공 로직
Item savedItem = itemRepository.save(item);
redirectAttributes.addAttribute("itemId", savedItem.getId());
redirectAttributes.addAttribute("status", true);
return "redirect:/validation/v2/items/{itemId}";
}
rejectValue() , reject()는 내부에서 MessageCodesResolver 인터페이스를 사용해 오류 코드를 생성한다. 사용자가 매개변수로 오류 코드를 지정하면 (예 - required) MessageCodesResolver 구현체가 오류 코드를 완성시킨다. 이를 통해 약속된 오류 코드를 만들어낼 수 있다. 위 예제에서 어떻게 오류 코드가 생성되는지 궁금하다면 MessageCodesResolver 인터페이스와 기본 구현체인 DefaultMessageCodesResolver를 살펴보자.
ValidationItemControllerV2 - 4
컨트롤러에 검증 로직이 존재해 컨트롤러가 너무 많은 책임을 맡는다. 따라서 검증은 검증 객체가 수행하도록 분리하자. 스프링은 검증 객체를 위해 Validator 인터페이스를 제공한다.
ItemValidator 검증 객체
package hello.itemservice.web.validation;
import hello.itemservice.domain.item.Item;
import org.springframework.stereotype.Component;
import org.springframework.validation.Errors;
import org.springframework.validation.ValidationUtils;
import org.springframework.validation.Validator;
@Component
public class ItemValidator implements Validator {
@Override
public boolean supports(Class<?> clazz) {
return Item.class.isAssignableFrom(clazz);
}
@Override
public void validate(Object target, Errors errors) {
Item item = (Item) target;
ValidationUtils.rejectIfEmptyOrWhitespace(errors, "itemName", "required");
if (item.getPrice() == null || item.getPrice() < 1000 || item.getPrice() > 1000000) {
errors.rejectValue("price", "range", new Object[]{1000, 1000000}, null);
}
if (item.getQuantity() == null || item.getQuantity() > 10000) {
errors.rejectValue("quantity", "max", new Object[]{9999}, null);
}
//특정 필드 예외가 아닌 전체 예외
if (item.getPrice() != null && item.getQuantity() != null) {
int resultPrice = item.getPrice() * item.getQuantity();
if (resultPrice < 10000) {
errors.reject("totalPriceMin", new Object[]{10000, resultPrice}, null);
}
}
}
}
ValidationItemControllerV2 - 4
컨트롤러에서 검증 객체를 주입 받아 사용할 수 있지만 Validator 인터페이스를 구현한 검증기(검증 객체)의 경우 더 편리하게 사용하도록 스프링이 도움을 준다.
WebDataBinder에 검증기를 추가할 경우 해당 컨트롤러에서는 검증기를 자동으로 적용할 수 있다. (@InitBinder 해당 컨트롤러에만 영향을 준다. 글로벌 설정은 별도로 해야한다.)
@Validated는 검증기를 실행하라는 애노테이션이다. 이 애노테이션이 붙으면 앞서 WebDataBinder에 등록한 검증기를 찾아서 실행한다. 이때 어떤 검증기가 실행될지 구분이 필요한데 검증기의 supports() 메서드로 구분 할 수 있다.
@InitBinder
public void init(WebDataBinder dataBinder) {
log.info("init binder {}", dataBinder);
dataBinder.addValidators(itemValidator);
}
@PostMapping("/add")
public String addItemV4(@Validated @ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes) {
if (bindingResult.hasErrors()) {
log.info("errors={}", bindingResult);
return "validation/v2/addForm";
}
//성공 로직
Item savedItem = itemRepository.save(item);
redirectAttributes.addAttribute("itemId", savedItem.getId());
redirectAttributes.addAttribute("status", true);
return "redirect:/validation/v2/items/{itemId}";
}
'Spring > Spring MVC' 카테고리의 다른 글
스프링 Filter, Interceptor (0) | 2023.04.14 |
---|---|
스프링 검증 (2) (0) | 2023.04.02 |
스프링 MVC 기본 기능 (0) | 2023.02.28 |
스프링 MVC (2) (0) | 2023.02.21 |
스프링 MVC (1) (0) | 2023.02.17 |