주녁, DevNote
article thumbnail
Published 2023. 1. 23. 17:35
Spring - 검증(Validation) Backend

개요

Spring에서는 입력값의 오류를 검증하기 위한 다양한 방법을 지원한다.

이를 통해 서비스 로직과 오류 검증 로직을 분리할 수 있다.

 

목표

if/else를 통한 입력값 검증을 Spring에서 지원하는 방법으로 대체한다.


여정

검증하기 - if/else

에러를 문자열 Map 형태로 처리하고 있어, 자칫 Human Error를 발생시킬 수도 있는 상황이다.

오타가 발생하거나 복사-붙여넣기 등으로 같은 에러를 중복하여 발생시킨다면

앞의 값이 덮어씌워져 모르는 사이에 에러가 하나 줄어들어 출력될 수도 있다.

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

 

이를 해결하기 위한 첫 번째 방법은 Binding Result이다.


검증하기 - Binding Result

 

아래 코드를 통해 어떻게 코드가 변경되었는지 가볍게 훑어만 보자

Error를 단순 Map<String, String> 형태가 아닌 Binding Result 형태로 다루고 있다.
(사실 Binding Result도 내부 구현을 보면 Map<String, Object> 형태를 반환하게 되어 있다.)

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

Binding Result는 다음과 같은 특징을 가진다.

  • Error를 처리하는 인터페이스로 Error 인터페이스를 상속한다. 
    • 즉, ObjectError 클래스를 다룰 수 있다.  → Map<String, String> 형태일 때 보다 다양한 역할을 할 수 있다.
    • ObjectError와 FieldError(자식 클래스) 파라미터 목록으로 기능을 유추해볼 수 있다.
      1. objectName : 오류가 발생한 객체 이름 
      2. field : 오류 필드
      3. rejectedValue : 사용자가 입력한 값(거절된 값)
      4. bindingFailure : 바인딩 실패인지, 검증실패인지 구분할 수 있는 값 
      5. codes : 메시지 코드
      6. arguments : 메시지에서 사용하는 인자 
      7. defaultMessage : 기본 오류메시지
  • 실제 매개변수로 전달되는 구현체는 BeanPropertyBindingResult이다.
    • BindingResult, Error 인터페이스를 둘 다 구현해놓았기 때문에 Error만 사용해도 무방하다
    • 대신, 기능이 더욱 단순해진다.
  • @ModelAttribute로 변환 중 에러가 발생해도, Binding Result가 컨트롤러를 호출하고 에러를 전달한다.
    • Binding Result가 매개변수에 없다면, 컨트롤러는 에러 페이지를 호출한다.
  • 에러 내용을 @Model에 담아 반환하지 않아도 알아서 반환된다.
  • Binding Result는 @ModelAttribute 다음 순서에 와야한다.
    • 이러한 이유는 Binding Result가 검증할 Target이 바로 앞에 오는 대상으로 지정되기 때문이다.
    • BindingResult.reject() 함수를 사용할때, Target이 자동으로 지정되는 이유가 이 때문이다.

Binding Result를 사용한다면 적어도 문자열에서 발생하는 Human Error를 최소화하고

입력값 저장, 다양한 에러 표현 지원 등 편의성을 증대시킬 수 있다.

 

 

 

하지만, 입력 값 검증 로직과 서비스 로직이 섞여 서로 영향을 주고 있는 상태이다.

예를 들어, 가격 허용값 혹은 수량 최대값이 변경된다면

서비스 로직인 addItem() 뿐만 아니라 같은 변수를 참조하는 모든 서비스 로직에 변경이 발생할 것이다.

검증 로직을 분리할 수는 없을까?


검증하기 - Validator

스프링은 검증 기능을 분리하기 위한 인터페이스로 Validator 인터페이스를 제공한다.

public interface Validator {
    boolean supports(Class<?> clazz);
    void validate(Object target, Errors errors); 
}

처음 작성했던 검증 로직을 인터페이스 구현체로 가져와 작성하게 되면 아래와 같다.

@Component
public class ProductValidator implements Validator {
    private final int MIN_PRICE = 1000;
    private final int MAX_PRICE = 1000000;
    private final int MAX_QUANTITY = 9999;
    private final int MIN_TOTAL_PRICE = 10000;
    @Override
    public boolean supports(Class<?> clazz) {
        return ProductController.ProductRequestDto.class.isAssignableFrom(clazz);
    }
    @Override
    public void validate(Object target, Errors errors) {
        ProductController.ProductRequestDto product = (ProductController.ProductRequestDto) target;
        ValidationUtils.rejectIfEmptyOrWhitespace(errors, "itemName", "required");

        if (isInvalidPrice(product)) {
            errors.rejectValue("price", "range", new Object[]{MIN_PRICE, MAX_PRICE}, null);
        }
        if (isInvalidQuantity(product)) {
            errors.rejectValue("quantity", "max", new Object[]{MAX_QUANTITY}, null);
        }
        //특정 필드 예외가 아닌 전체 예외
        if (isInvalidTotalPrice(product)) {
            errors.reject("totalPriceMin", new Object[]{MIN_TOTAL_PRICE, getTotalPrice(product)}, null);
        }
    }
    private boolean isInvalidPrice(ProductController.ProductRequestDto product) {
        return product.price() == null || product.price() < MIN_PRICE || product.price() > MAX_PRICE;
    }
    private boolean isInvalidQuantity(ProductController.ProductRequestDto product) {
        return product.quantity() == null || product.quantity() > MAX_QUANTITY;
    }
    private boolean isInvalidTotalPrice(ProductController.ProductRequestDto product) {
        return getTotalPrice(product) < MIN_TOTAL_PRICE;
    }
    private int getTotalPrice(ProductController.ProductRequestDto product) {
        return product.price() * product.quantity();
    }
}

그리고 컨트롤러에서는 @InitBinder 어노테이션으로 Validator를 등록하고,

@Validate 어노테이션으로 사용할 수 있다.

@RestController
public class ProductController {
    private final ProductService productService;
    private final ProductValidator productValidator;
    @InitBinder // Validator 등록
    public void init(WebDataBinder dataBinder) {
        log.info("init binder {}", dataBinder);
        dataBinder.addValidators(productValidator);
    }
    @PostMapping("/product")
    public ApiEntity<?> addProduct(@Validated @RequestBody ProductRequestDto product, BindingResult bindingResult) throws ProductException {
        if(bindingResult.hasErrors()){
            log.info("errors={}", bindingResult);
            return ApiResult.error(HttpStatus.BAD_REQUEST, String.valueOf(bindingResult));
        }
        return ApiResult.success(productService.addProduct(product.toServiceDto()));
    }
}

Q & A

  • @Valid와 @Validate 어노테이션의 차이는 무엇인가요?
@Valid 어노테이션이 javax.validation 패키지에 속하는 반면, 
@Validated 어노테이션은 org.springframework.validation.annotation에 속합니다.
즉, @Validated는 @Valid의 기능을 포함하면서, 추가적으로 스프링 프레임워크에서 유효성을 검증할 옵션에 대한 그룹을 지정할 수 있는 기능이 있다는 차이점이 있습니다.

 


마무리

오늘은 if/else 부터 시작해서 Binding Result, Validtor까지

검증로직을 분리하는 과정을 배워보았다.

 

이렇게 하면 서비스에서는 서비스 로직만 명확하게 관심사로 남길 수 있어서

좋은 코드를 작성할 수 있을 것이다.


참고자료

스프링 MVC 2편 - 백엔드 웹 개발 활용 기술 - 김영한님 강의

'Backend' 카테고리의 다른 글

Spring Batch로 Log 내용 DB에 적재하기  (0) 2023.07.03
Spring - 메시지, 국제화  (0) 2023.01.21
profile

주녁, DevNote

@junwork

포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!