검증 1

강한친구·2022년 6월 16일
0

Spring

목록 보기
19/27

validation의 필요성

Validation은 엄청 중요한 기능중의 하나이다.
예를 들어, 가격 수량에 문자가 들어오면 안되는데 들어온다던가, 상품명이 지원하지 않는 포멧을 가지고 있다던가 글자수 제한을 넘긴다던가 하는 validation이 존재할 것이고 이를 지켜야 사이트 오류를 막을 수 있다.

이런 검증은 클라이언트 검증과 서버검증 두 방식이 있다.
다만 클라이언트 검증은 유저가 조작할 수 있는 문제가 있다.

반대로 서버검증은 이러한 부분은 안전하지만, 사용자가 즉각적으로 알아차리기가 힘들어진다.

따라서 이중으로 검증하는것이 중요하다.
또한 검증오류 로그를 잘 남겨야한다.

직접 검증 처리

상품저장은 PRG 흐름으로 작동한다.
1. get으로 등록 form을 불러온다
2. post로 add한다
3. 상품 상세 page로 redirect 한다.
4. 상품상세 페이지를 get한다.

만약 post add 과정에서 model 검증에서 오류가 난다면, 상품등록 form을 다시 그대로 보여주면서, 오류 메시지를 보여줘야 한다.

검증사항과 validate

  • 타입 검증
    • 가격, 수량에 문자가 들어가면 검증 오류 처리
  • 필드 검증
    • 상품명: 필수, 공백X
    • 가격: 1000원 이상, 1백만원 이하
    • 수량: 최대 9999
  • 특정 필드의 범위를 넘어서는 검증
    • 가격 * 수량의 합은 10,000원 이상

다음과 같은 검증을 어떤식으로 구현해야 할까?

V1

        // 오류 저장 맵
        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 result = item.getPrice(); * item.getQuantity();
            if (result < 10000) {
                errors.put("globalError", "가격 * 수량은 10000 이상이여야 합니다.");
            }
        }

        if (!errors.isEmpty()) {
            model.addAttribute("errors", errors);
            return "validation/v1/addForm";
        }

이렇게 구현을 하면 다양한 검증은 물론, 검증에 실패했을 때, 이를 다시 원래 호출되었던 form으로 돌려주는 역할을 한다.

error 메시지의 경우, model에 담긴 error를 보고 thymeleaf에서 처리해주는 방식이다.

이런 방식의 문제점?

  1. 뷰 템플릿을보면 중복되는 부분이 많다.
  2. 타입오류는 아직 처리하지 못했다. 가격에 문자가 들어간다.
  3. String 입력값이 들어온것도 계속 남겨줘야 한다.

이러한 문제들은 사실 스프링 프레임워크에 다 정리되어있다.

BindingResult

스프링의 검증 오류 방식이다.
스프링이 제공하는 검증오류를 보관하는 객체이다. bindingresult가 있으면, ModelAttribute에 바인딩하다가 오류가 나도 컨트롤러가 호출이 돼서 오류가 발생하게 된다.

검증 오류 3가지 방법

  1. @ModelAttribute 의 객체에 타입 오류 등으로 바인딩이 실패하는 경우 스프링이 FieldError 생성해서
    BindingResult 에 넣어준다.

  2. 개발자가 직접 넣어주는 경우

  3. validator 사용

// 검증 로직
        if (!StringUtils.hasText(item.getItemName())) {
            bindingResult.addError(new FieldError("item", "itemName", "상품이름은 필수입니다.");
        }
        if (item.getPrice() == null || item.getPrice() < 1000 || item.getPrice() > 1000000) {
            bindingResult.addError(new FieldError("item", "price", "가격은 1,000 ~ 1,000,000 까지 허용합니다.");
        }
        if (item.getQuantity() == null || item.getQuantity() >= 9999) {
            bindingResult.addError(new FieldError("item", "quantity", "수량은 최대 9,999 까지 허용합니다.");
        }

        // 복합 검증
        if (item.getPrice() != null && item.getQuantity() != null) {
            int result = item.getPrice() * item.getQuantity();
            if (result < 10000) {
                bindingResult.addError(new ObjectError("item", "가격 * 수량은 10000 이상이여야 합니다."));
            }
        }

        if (bindingResult.hasErrors()) {
            model.addAttribute("errors", bindingResult);
            return "validation/v2/addForm";
        }

bindingresult는 반드시 @ModelAttribute 뒤에 와야한다
binding result는 기존의 map errors를 대체하는 역할을 하는 스프링 기능중 하나이다.

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

그 후, thymeleaf로 필드에러와 오브젝트 에러를 처리해주는 코드를 추가하면 된다.

하지만 이런 방식도 아직 문제가 있는데, 오류 발생한 데이터가 유지가 되지 않는점이다.

값을 유지하는 법

필드에러에는 구현메소드가 사실 2개가 있다. 여기서 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, "가격은 1,000 ~ 1,000,000 까지 허용합니다."));
        }
        if (item.getQuantity() == null || item.getQuantity() >= 9999) {
            bindingResult.addError(new FieldError("item", "quantity", item.getQuantity(), false, null, null, "수량은 최대 9,999 까지 허용합니다."));
        }

이런식으로 작성하면 값이 유지가 된다.
또한, bindingresult를 사용하면, type오류가 발생하는것도 잡아서 이를 처리해줄 수 있게 된다.

오류코드와 메시지 처리

messages 처리

messages 로 국제화를 했던것처럼, error.properties를 만들어서, 이 메시지들을 각 오류생성객체에 바인딩해주면 된다.

이렇게 어플리케이션 프로퍼티에 메시지로 등록을 한 다음,

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

이런식으로 바인딩해주면

이렇게 설정한 오류메시지가 나온다.

reject, rejectvalue

reject, rejectvalue를 사용하면, fieldError나 objectError을 만들지 않고도 오류처리를 할 수 있다.

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

이전보다 훨씬 코드가 간결해진것을 알 수 있다.
자세히 보면, 기존의 코드처럼 정확하게 required.item.itemName 같이 안쓰고 required만 써도 오류코드를 알아서 찾아오는 것을 볼 수 있다.

MessageCodesResolver

오류메시지를 디테일하게 쓸 때도 있지만, 간단하게 쓸 때도 있다.

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

required=필수값입니다.
range=범위는 {0} ~ {1}까지 입니다.
max=최대 {0} 까지 허용됩니다.

보편적인 방법은 범용적으로 쓰다가, 디테일한 메시지가 필요한 곳에서 세부적인 오류코드를 쓰는 방법이다.

위에서 작성한 코드는 required를 오류메시지 키로 잡고 있다. 이때 스프링은 required를 가진 모든 오류 메시지를 찾는다. 그리고 그 중 가장 디테일한 값을 가진 오류메시지를 우선순위로 판단, 오류메시지로 선택하게 되고 이를 오류로 출력한다.

MessageCodesResolverTest

    MessageCodesResolver codesResolver = new DefaultMessageCodesResolver();

MessageCodesResolver는 오류메시지를 처리해주는 스프링 메서드의 하나이다.

이를 통해 메시지를 생성해보면

  1. Object의 경우
        String[] messageCodes = codesResolver.resolveMessageCodes("required", "item");

required 에러코드를 가진, item 오브젝트 메시지를 전부 생성하여
messageCode = required.item
messageCode = required
가 생성된다.

  1. Field의 경우
        String[] messageCodes = codesResolver.resolveMessageCodes("required", "item", "itemName", String.class);

required 에러코드를 가진, item 오브젝트, itemName 필드를 가진 메시지를 전부 생성하여
messageCode = required.item.itemName
messageCode = required.itemName
messageCode = required.java.lang.String
messageCode = required

가 생성된다.

이 resolver에는 생성 규칙이 존재한다.

객체 오류

1.: code + "." + object name
2.: code

예) 오류 코드: required, object name: item
1.: required.item
2.: required
필드 오류

1.: code + "." + object name + "." + field
2.: code + "." + field
3.: code + "." + field type
4.: code

예) 오류 코드: typeMismatch, object name "user", field "age", field type: int
1. "typeMismatch.user.age"
2. "typeMismatch.age"
3. "typeMismatch.int"
4. "typeMismatch"

이렇게 생성된 코드를 가지고 있다가 요청에 맞춰 errors.properties에서 찾고, 그 안에서 찾아서 보내준다.

핵심은 구체적인것부터 덜 구체적인것으로 넘어가는것이다.

Validator 분리

지금까지는 오류검증로직을 컨트롤러에 떠넘겨서 처리했었다. 그래서 이 오류검증 로직들을 처리하는 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;
        // 검증 로직
        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() > 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);
            }
        }
    }
}

Errors는 bindingResult의 부모객체이다. 따라서 bindingResult를 쓸 수 있다.

    @PostMapping("/add")
    public String addItemV5(@ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model) {
        itemValidator.validate(item, bindingResult);
        
        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}";
    }

이렇게 검증부분을 validator로 넘겨서 처리하면 코드가 더 깔끔해진다.

WebDataBinder

웹 데이터 바인더에 검증기를 추가하면 해당 컨트롤러에서는 @Validated 어노테이션으로 검증기를 자동 적용 할 수 있다.

    @InitBinder
    public void init(WebDataBinder dataBinder) {
        log.info("init binder {}", dataBinder); dataBinder.addValidators(itemValidator);
    }
@PostMapping("/add")
    public String addItemV6(@Validated @ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model) {

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

0개의 댓글