Spring validation

bw1611·2023년 12월 5일
0
post-thumbnail

Spring validation 이란 무엇일까?


올바르지 않은 데이터를 걸러내고 보안을 유지하기 위해 데이터 검증은 여러 계층에 걸쳐 진행된다.
만약 검증을 거치지 않으면 클라이언트 부분에서 조작이 쉬울 뿐더러 정상적인 데이터가 들어가지 않을 수도 있다. 그래서 항상 검증은 클라이언트 뿐만 아니라 서버에서도 데이터 유효성을 검사해줘야 한다.

- V0 (if)

    @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", "가격은 1000원 이상 100만원 이하까지 허용합니다.");
        }

        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", "가격 * 수량의 수는 1만원 이상이어야 합니다.");
            }
        }

        if (!errors.isEmpty()){
            log.info("에러 퍼이지");
            model.addAttribute("errors", errors);
            // redirect
            return "validation/v1/addForm";
        }

        // 성공 로직
        Item savedItem = itemRepository.save(item);
        redirectAttributes.addAttribute("itemId", savedItem.getId());
        redirectAttributes.addAttribute("status", true);
        return "redirect:/validation/v1/items/{itemId}";
    }

V1 버전에서는 if문을 통해 validation을 검증하고 있다. 물론 로직은 잘 작동하겠지만 여기서의 단점은 무엇일까?
1, 가독성이 상당히 좋지 못하다.
2, 사용자가 기존에 기입한 문구와 예외처리 메시지를 봐야 어떤 부분이 잘못 입력됐는지 파악이 가능하다.
3, 문자는 바인딩이 불가능하기 때문에 고객은 무엇이 문제가 되는지 모른다.

- V1, V2 (BindingResult)

스프링이 제공하는 BindingResult를 사용하여 예외처리를 진행한다.
BindingResult는 Errors 인터페이스를 상속받고 있는 인터페이스이다.
실제로 사용하는 구현체는 BeanPropertyBindingResult 클래스이므로 BindingResult와 Errors 모두 사용해도 되지만 Errors 인터페이스는 단순한 오류 저장과 조회 기능만 제공하고, BindingResult는 추가적인 기능을 제공한다(addError 메서드 등). 따라서 주로 BindingResult를 많이 사용한다.

  • BindingResult가 ModelAttribute로 바인딩 된 객체 뒤에 나오는 이유!

@ModelAttribute는 Spring이 자동으로 해당 객체를 생성하고 요청 파라미터를 해당 객체에 바인딩하는 기능을 제공합니다. 이후 검증 결과는 BindingResult에 저장된다.
BindingResult를 @ModelAttribute 다음에 위치시키면 Spring은 해당 객체에 대한 검증 결과를 저장할 곳을 알 수 있습니다.

  • validation 다이어그램 상속도

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


        // 검증 로직
        if (!StringUtils.hasText(item.getItemName())) {
            bindingResult.addError(new FieldError("item", "itemName", "상품 이름은 필수 입니다.")); // objectName, fieldName, Defaultmessage 순서
        }

        if (item.getPrice() == null || item.getPrice() < 1000 || item.getPrice() > 1000000){
            bindingResult.addError(new FieldError("item", "price", "가격은 1000원 이상 100만원 이하까지 허용합니다."));
        }

        if (item.getQuantity() == null || item.getQuantity() >= 9999){
            bindingResult.addError(new FieldError("item", "quantity", "수량은 최대 9,999 까지 가능합니다."));
        }

        // 특정 필드가 아닌 복합 롤 검증
        if (item.getPrice() != null && item.getQuantity() != null){
            int resultPrice = item.getPrice() * item.getQuantity();
            if (resultPrice < 10000){
                bindingResult.addError(new ObjectError("item", "가격 * 수량의 수는 1만원 이상이어야 합니다."));
            }
        }

        if (bindingResult.hasErrors()){
            log.info("에러 퍼이지");
            // bindingResult 모델이 담지 않더라도 스프링이 알아서 model로 보내준다.
            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의 생성자 요약

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

objectName : @ModelAttribute 이름
field : 오류가 발생한 필드 이름
defaultMessage : 오류 기본 메시지

즉 BindingResult란 스프링이 제공하는 검증 오류를 보관하는 객체이며, @ModelAttribute에 데이터 바인딩 시 오류가 발생하면 컨트롤러가 호출이 된다. (FieldError를 BindingResult에 담는 것이라고 보면 편하다.)

위의 코드의 단점은 사용자가 잘못된 결과를 넣으면 데이터가 유지가 되지 않는 다는 것이다. 그럴 경우 FieldError의 다른 생성자를 사용하면 된다.

파라미터 목록

  • objectName : 오류가 발생한 객체 이름
  • field : 오류 필드
  • rejectedValue : 사용자가 입력한 값(거절된 값)
  • bindingFailure : 타입 오류 같은 바인딩 실패인지, 검증 실패인지 구분 값
  • codes : 메시지 코드
  • arguments : 메시지에서 사용하는 인자
  • defaultMessage : 기본 오류 메시지

이 부분에서 rejectedValue 가 바로 오류 발생시 사용자 입력 값을 저장하는 필드다.
거절된 값을 다시 넣어줌으로써 원래 데이터를 유지할 수 있다.

        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, "가격은 1000원 이상 100만원 이하까지 허용합니다."));
        }

        if (item.getQuantity() == null || item.getQuantity() >= 9999){
            bindingResult.addError(new FieldError("item", "quantity", item.getQuantity(), false, null, null, "수량은 최대 9,999 까지 가능합니다."));
        }

- V3 (오류 코드와 메시지 출력)

BindingResult는 검증해야할 target 바로 다음에 위치한다. 이런 이유 때문에 본인이 검증해야할 target을 알고 있다.
FieldError를 사용하지 않고 rejectValue를 이용하여 더 깔끔하게 구현할 수 있다.
rejectValue()는 필드 에러를 처리하는 메서드로 FieldError를 생성하고 모델에 해당 에러를 저장하는 기능을 제공합니다.

  • rejectValue

파라미터 목록

  • field : 오류 필드명
  • errorCode : 오류 코드(이 오류 코드는 메시지에 등록된 코드가 아니다. 뒤에서 설명할 messageResolver를 위한 오류 코드이다.)
  • errorArgs : 오류 메시지에서 {0} 을 치환하기 위한 값
  • defaultMessage : 오류 메시지를 찾을 수 없을 때 사용하는 기본 메시지
void rejectValue(@Nullable String field, String errorCode,
@Nullable Object[] errorArgs, @Nullable String defaultMessage);
        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() >= 9999){
            bindingResult.rejectValue("quantity", "max", new Object[]{9999}, null);
        }

MessageCodesResolver가 required의 설정에 맞는 것을 디테일을 우선적으로 탐색하고 그 다음으로 범용적인 부분을 탐색하여 값을 넣어주는 역할을 한다.

아래는 erros.properteis 설정 파일이다.

#Level1
totalPriceMin.item=상품의 가격 * 수량의 합은 {0}원 이상이어야 합니다. 현재 값 = {1}

#Level2 - 생략
totalPriceMin=전체 가격은 {0}원 이상이어야 합니다. 현재 값 = {1}

#==FieldError==
#Level1
required.item.itemName=상품 이름은 필수입니다.
range.item.price=가격은 {0} ~ {1} 까지 허용합니다.
max.item.quantity=수량은 최대 {0} 까지 허용합니다.

#Level2 - 생략

#Level3
required.java.lang.String = 필수 문자입니다.
required.java.lang.Integer = 필수 숫자입니다.
min.java.lang.String = {0} 이상의 문자를 입력해주세요.
min.java.lang.Integer = {0} 이상의 숫자를 입력해주세요.
range.java.lang.String = {0} ~ {1} 까지의 문자를 입력해주세요.
range.java.lang.Integer = {0} ~ {1} 까지의 숫자를 입력해주세요.
max.java.lang.String = {0} 까지의 문자를 허용합니다.
max.java.lang.Integer = {0} 까지의 숫자를 허용합니다.

#Level4
required = 필수 값 입니다.
min= {0} 이상이어야 합니다.
range= {0} ~ {1} 범위를 허용합니다.
max= {0} 까지 허용합니다.
    @Test
    void messageCodesResolverField() {
        String[] messageCodes = resolver.resolveMessageCodes("required", "item", "itemName", String.class);
        for (int i = 0; i < messageCodes.length; i++) {
            System.out.println((i + 1) +" 번쨰 우선순위 : " + messageCodes[i]);
        }
        Assertions.assertThat(messageCodes).containsExactly("required.item.itemName", "required.itemName", "required.java.lang.String", "required");
    }

동작 방식

  • rejectValue() , reject() 는 내부에서 MessageCodesResolver 를 사용한다. 여기에서 메시지 코드들을 생성한다.
  • FieldError , ObjectError 의 생성자를 보면, 오류 코드를 하나가 아니라 여러 오류 코드를 가질 수 있다.
  • MessageCodesResolver 를 통해서 생성된 순서대로 오류 코드를 보관한다

- V4 (Validate 분리)

현재 컨트롤러 부분에 너무 많은 검증로직이 있기 때문에 가독성과 코드의 유지 보수성을 위해서 검증과 컨트롤러를 분리한다.
Spring이 제공하는 Validator를 상속받아서 진행한다.

@Component
public class ItemValidator implements Validator {
    @Override
    public boolean supports(Class<?> clazz) {
        // item == clazz
        // item == subItem
        return Item.class.isAssignableFrom(clazz);
    }

    @Override
    public void validate(Object target, Errors errors) {
        Item item = (Item) target; // 인터페이스가 Object라 다운캐스팅을 해준다.

        // 검증 로직
        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);
            }
        }
    }
}

- V4 (Validate 분리)

WebDataBinder는 스프링의 파라미터 바인딩 역할을 해주고 검증 기능을 내부에 포함하고 있다.

    @InitBinder
    public void init(WebDataBinder binder){
        binder.addValidators(itemValidator);
    }

controller가 호출될 때 마다 WebDataBinder가 내부적으로 생성이 되고 검증기를 적용할 수 있는 형태가 된다.

@PostMapping("/add")
    public String addItemV6(@Validated @ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model) {

        if (bindingResult.hasErrors()){
            log.info("에러 퍼이지");
            // bindingResult 모델이 담지 않더라도 스프링이 알아서 model로 보내준다.
            return "validation/v2/addForm";
        }
}

@Validated을 통해 Item을 WebDataBinder에 등록할 수 있다.

    @Override
    public boolean supports(Class<?> clazz) {
        // item == clazz
        // item == subItem
        return Item.class.isAssignableFrom(clazz);
    }

binder.addValidators(itemValidator) 을 통해 Item 클래스가 validator를 상속받은 supports메서드의 Class<?> clazz에 들어가게 된다.

profile
Java BackEnd Developer

0개의 댓글

관련 채용 정보