5 분 소요

사용자가 어떤 입력 폼에 적절하지 않은 값을 넣고 다음 버튼을 눌렀을 때 오류를 확인할 수 있도록 기능을 만들려고
한다. 즉, 검증 로직을 어떻게 만들어야 하는지에 대한 내용을 다룬다.

검증 로직

HashMap을 이용해서 에러를 담고 처리하도록 짠다면 다음과 같이 변수를 선언해주고 검증 과정을 거친 뒤
에러가 발생했다면 기존 웹사이트 정보를 다시 불러오고, 발생하지 않았다면 다음 사이트로 넘어간다.

@PostMapping("/add")
public String addItem(@ModelAttribute Item item, RedirectAttributes redirectAttributes, Model model) {
 
  //에러 담을 변수 선언
  Map<String, String> errors = new HashMap<>();
 
  /*
    검증 로직
    예) 가격/수량 범위 체크
  */

  //검증 실패
  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}";
}

하지만 이 방식은 스프링이 제공하는 검증 오류 처리 방법이 아니다. BindingResult를 사용해보자!
BindingResult 파라미터 위치는 반드시 @ModelAttribute Item item 다음에 와야한다.
검증 로직에서 FieldError로 에러를 생성하고 bindingResult에 넣어준다.
복합 조건 검증 로직에서는 FieldError가 아니라 ObjectError로 만들고 넣어준다.

@PostMapping("/add")
public String addItemV1(@ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes) {
 
  /*
    검증 로직
    예) 가격/수량 범위 체크
  */
  if (!StringUtils.hasText(item.getItemName())) {
    bindingResult.addError(new FieldError("item", "itemName", "상품 이름은필수입니다."));
  }
  ...
  //복합 조건 처리
  if (item.getPrice() != null && item.getQuantity() != null) {
    int resultPrice = item.getPrice() * item.getQuantity();
    if (resultPrice < 10000) {
     bindingResult.addError(new ObjectError("item", "가격 * 수량의 합은 10,000원 이상이어야 합니다. 현재 값 = " + resultPrice));
    }
  }
  
  if (!errors.isEmpty()) {
    model.addAttribute("errors", errors);
    return "validation/v1/addForm";
  }

  //검증 실패
  if (bindingResult.hasErrors()) {
    return "validation/v2/addForm";
  }

  //성공 로직 (위 코드와 같음)
}

FieldError, ObjectError 생성자는 다음과 같다.

public FieldError(String objectName, String field, String defaultMessage) {}
public ObjectError(String objectName, String defaultMessage) {}

BindingResult가 없었을 땐 타입 오류 발생 시 컨트롤러도 호출을 못하고 오류 페이지로 이동하는데,
BindingResult 사용 후에는 타입 오류가 발생해도 컨트롤러를 호출한다는 점이 중요하다!
@ModelAttribute 객체에 타입 오류로 바인딩 실패 시 스프링이 FieldError를 생성해서 BindingResult
넣어주거나, 직접 넣어주는 방법이 있다. 뒤에서 설명할 Validator로도 가능하다!
참고로 BindingResultErrors 인터페이스를 상속받아서 BindingResult 대신 Errors를 사용해도 된다.

하지만 위 코드에선 오류 발생 시 고객이 입력했던 내용이 다 사라진다는 문제점이 존재한다.
그래서 기존값 유지를 위해 다른 FieldError 생성자를 이용하면 된다. 다음은 정의와 예시이다.
타입 오류로 바인딩 실패 시 스프링이 FieldError 생성하면서 기존값을 넣고 오류를 BindingResult에 담아
컨트롤러를 호출한다. 즉, 바인딩 실패 시에도 오류 메시지를 출력할 수 있게된다.

/*
  정의
  - rejectedValue : 오류 발생 시 기존값 저장 필드
  - bindingFailure : 타입 오류처럼 바인딩이 실패했는지 여부
  - codes : 오류 메시지
  - arguments : codes에 들어갈 인자 정보
  - defaultMessage : 기본 오류 메시지(codes가 null이면 출력)
*/
public FieldError(String objectName, String field, String defaultMessage);
public FieldError(String objectName, String field, @Nullable Object 
rejectedValue, boolean bindingFailure, @Nullable String[] codes, @Nullable
Object[] arguments, @Nullable String defaultMessage);

//예시
new FieldError("item", "price", item.getPrice(), false, null, null, 
"가격은 1,000 ~ 1,000,000 까지 허용합니다.");

오류 코드와 메시지 처리

오류 메시지를 따로 관리하기위해 errors.properties라는 파일을 만들고 스프링 부트가 인식할 수 있게
다음 설정을 추가해주자. 그 아래는 errors.properties 내용이다.

//application.properties
spring.messages.basename=messages,errors

//errors.properties
required.item.itemName=상품 이름은 필수입니다.
range.item.price=가격은 {0} ~ {1} 까지 허용합니다.
max.item.quantity=수량은 최대 {0} 까지 허용합니다.
totalPriceMin=가격 * 수량의 합은 {0} 이상이어야 합니다. 현재  = {1}

위에서 등록한 내용을 사용하도록 코드를 수정한다면 다음과 같다.

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

하지만 여기서 FieldError, ObjectError를 모두 다루면 코드가 복잡해지므로 BindingResult의 함수
rejectValue(), reject()로 코드를 간단히 만들 수 있다. "item" 객체에 대해 처리한다는 정보를
넣지 않아도 되는 이유가 BindingResult는 그 대상(target)을 이미 알고 있어서이다!

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.getPrice() != null && item.getQuantity() != null) {
  int resultPrice = item.getPrice() * item.getQuantity();
  if (resultPrice < 10000) {
    bindingResult.reject("totalPriceMin", new Object[]{10000, resultPrice}, null);
  }
}

오류 메시지를 찾을 때 required.item.itemName, required가 있다면 전자가 더 자세하고 후자가 더 간단하다.
전자처럼 자세할 때 우선순위가 더욱 높아져서 세밀한 오류 메시지를 적을 때 쓰고, 후자는 더 범용적인 내용으로
구성한다. 그니까 구체적인 것에서 덜 구체적인 것으로 이동한다!.
스프링은 MessageCodesResolver라는 것으로 이 기능을 지원한다.

그리고 타입 오류같은건 스프링에서 기본으로 지원해서 오류 메시지를 지원하는데 직접 설정해줄 수도 있다.
typeMismatch.item.price, typeMismatch.price, typeMismatch.java.lang.Integer, typeMismatch
대한 오류 메시지를 errors.properties에 적어주면 된다.

typeMismatch.java.lang.Integer=숫자를 입력해주세요.
typeMismatch=타입 오류입니다.

Validator

기존 코드에 검증 로직까지 포함하니 내용이 너무 많아지므로 분리해서 사용해보자.
기존 코드에서 ItemValidator를 호출해서 사용할거니까 @Component을 붙여서 스프링 컨테이너에서 관리될 수
있게 만들어주자.

@Component
public class ItemValidator implements Validator {

    //해당 검증기 지원 여부 확인
    @Override
    public boolean supports(Class<?> clazz) {
        //item == clazz OR item == subItem
        return Item.class.isAssignableFrom(clazz);
    }

    //검증 로직에서 호출할 함수
    @Override
    public void validate(Object target, Errors errors) {
        Item item = (Item) target;
        // Errors는 BindingResult의 부모여서 캐스팅 가능

        //검증 로직
        if(!StringUtils.hasText(item.getItemName())) {
            errors.rejectValue("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() > 9999) {
            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);
            }
        }
    }
}

기존 코드에서는 검증 로직을 ItemValidator.validate()를 쓰면서 다음과 같이 수정할 수 있다.

private final ItemValidator itemValidator;

@PostMapping("/add")
public String addItemV5(@ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes) {

  //검증 로직 <- 간단해짐!
  itemValidator.validate(item, bindingResult);

  //검증 실패, 성공 로직 생략
}

위에서 ItemValidator 정의를 보면 Validator를 구현하고 있다.
이렇게 만든 검증기는 스프링의 추가적인 도움을 받아 코드를 더 간단히 만들 수 있다!
단, 이 코드는 해당 컨트롤러에서만 유효하다.

@Controller
@RequestMapping("/validation/v2/items")
@RequiredArgsConstructor
public class ValidationItemControllerV2 {

  private final ItemRepository itemRepository;
  private final ItemValidator itemValidator;

  //추가!
  //이 컨트롤러에 속한 모든 함수에 대해 검증기 자동 적용
  @InitBinder
  public void init(WebDataBinder dataBinder) {
      dataBinder.addValidators(itemValidator);
  }
  ...

  //@Validated 꼭 추가하기!
  @PostMapping("/add")
  public String addItemV6(@Validated @ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes) {

      //검증 로직 별도 작성 필요 없음!
      //검증 실패, 성공 로직 생략
  }
}

참고

검증 시 @Validated, @Valid 상관없이 모두 사용 가능하다!
(@Validated는 스프링 전용, @Valid는 자바 표준 검증 어노테이션이다)
다만 @Valid 사용 시 build.gradle에 다음 내용을 추가해줘야 한다.

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

카테고리:

업데이트:

댓글남기기