Validation - 1 (~BindingResult 까지)

나무·2023년 11월 17일

스프링 MVC

목록 보기
2/12
post-thumbnail

1. Validation (검증) 이란?

사용자는 하루에도 웹사이트 수많은 데이터들을 입력하고 저장한다. 이 때, 서버로 전달되는 데이터들은 반드시 검증 단계를 거쳐야 한다.

핸드폰 번호를 입력해야하는데 이름을 입력하거나, 사이트 정책상 비밀번호를 10자리 이상 등록 해야하는데 9자리만 등록하거나 등 수많은 잘못된 형식의 데이터들이 입력될 수 있기 때문에 검증은 필수적이다.

그리고 검증뿐만 아니라 데이터가 잘못 입력되었다는게 확인됐을 경우 그 이후 처리도 중요하다. 회원이 데이터 타입을 잘못 입력했다고 해서 컴파일러 에러 메시지를 그대로 사용자에게 전달 할 순 없지 않는가?

2. 검증의 책임은 누가?

입력된 데이터의 검증은 과연 클라이언트 단에서 처리 돼야 할까? 아니면 서버에서 처리해야 할 까?

결론 부터 말하자면 둘 다 해야한다.

서버 측 에서는 해킹과 보안 이슈가 있기 때문에 사실상 필수로 검증을 해줘야하며,

클라이언트 측 에서는 사용자가 어디가 틀렸는지, 내가 뭘 입력해서 틀렸는지 와 같은 사용성 향상을 위해서 검증을 해줘야한다.

사용자가 클릭 한번 잘못했다가 이런 에러를 맞이했다고 생각해보자,,, 대략 난감이다.

3. 검증 요구사항 확인

  • 타입 검증
    가격, 수량 에 문자가 들어가면 검증 오류 처리

  • 필드 검증
    상품명 : 필수로 입력, 공백X
    가격 : 1000원 이상, 1백만원 이하
    수량 : 최대 9999

  • 특정 필드의 범위를 넘어서는 검증
    가격 * 수량 의 합은 10,000원 이상

  • 사용자 입력 기억
    사용자가 잘못 입력 했을 경우 오류 메시지와 함께 이전의 입력이 무엇이였는지 보여줘야함.

4. V1 : 순수 자바

Controller

	@GetMapping("/add")
    public String addForm(Model model) {
        model.addAttribute("item", new Item());
        return "validation/v1/addForm";
    }
    /////////////////////////////////////////////////
	@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()) {
            log.info("errors = {} ", errors);
            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}";
    }

뷰 (타임리프)

<form> 태그

<form action="item.html" th:action th:object="${item}" method="post">

글로벌 에러

<div th:if="${errors?.containsKey('globalError')}">
            <p class="field-error" th:text="${errors['globalError']}">전체 오류 메시지</p>
</div>

필드 에러

<div>
  <label for="itemName" th:text="#{label.item.itemName}">상품명</label>
  <input type="text" id="itemName" th:field="*{itemName}"
                   th:class="${errors?.containsKey('itemName')} ? 'form-control field-error' : 'form-control'"
                   class="form-control" placeholder="이름을 입력하세요">
  <div class="field-error" th:if="${errors?.containsKey('itemName')}" th:text="${errors['itemName']}">
    상품명 오류
  </div>
</div>
<!-------------------------------------------------->
<div>
  <label for="price" th:text="#{label.item.price}">가격</label>
  <input type="text" id="price" th:field="*{price}"
                   th:class="${errors?.containsKey('price')} ? 'form-control field-error' : 'form-control'"
                   class="form-control" placeholder="가격을 입력하세요">
  <div class="field-error" th:if="${errors?.containsKey('price')}" th:text="${errors['price']}">
    가격 오류
  </div>
</div>

타임리프 양식의 통일

여기서 중요한 부분은 GET "/add" 컨트롤러 가 Item 객체를 모델에 담아 반환하는것이다.

이 경우 2가지 장점이 생기는데

1) 타임리프 th:object 사용이 가능
2) 1) 덕분에 POST "/add" 컨트롤러와 템플릿 재사용이 가능

POST "/add" 검증 실패 로직GET "/add" 모두 동일한 validation/v1/addForm (HTML 뷰) 를 반환하는 것을 볼 수 있다.

이것이 가능한 이유는 두 요청이 모두 양식을 통일 시키고 있기 때문이다. GETPOST 모두 뷰에다가 ItemModel 에 담아 전달 한다.

이 덕분에 굳이 두 GET요청 따로, POST 요청 따로 서로 다른 HTML 파일을 만들 필요없이 같은 HTML 파일을 사용 할 수 있는 것이다.

문제점

타입 오류 처리 불가능
타입 오류가 발생한다는것은 Integer 타입의 변수에 String 데이터를 저장하려는 상황에 발생한다.

즉, 컴파일에러가 발생하는것이기 때문에 아예 서버가 다운 돼버린다.

중복 코드
뷰 템플릿에 코드가 중복이 많고 너무 길어진다.

// 에러가 있는지 확인하고 있으면 클래스에 "field-error" 추가
th:class="${errors?.containsKey('itemName')} ? 
	'form-control field-error' : 'form-control'"

// "상품명" 에 대한 에러인지 확인하고 맞다면 "상품명"에 대한 에러 문구 ``<div>`` 생성
class="field-error" th:if="${errors?.containsKey('itemName')}" th:text="${errors['itemName']}">
    상품명 오류
  

5. Ver. 2 (BindingResult 도입)

Controller

// @GetMapping 컨트롤러는 V1과 동일

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

        //검증 로직
        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 까지 허용합니다."));
        }

        //특정 필드가 아닌 복합 룰 검증
        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
이름 그대로 Item 객체에 있는 필드에 대한 에러를 저장한다

ObjectError
특정 필드에만 관련된 것이 아닌 전역적인 오류(글로벌 에러) 를 저장한다.

뷰 (타임리프)

글로벌 에러

<div th:if="${#fields.hasGlobalErrors()}">
  <p class="field-error" th:each="err : ${#fields.globalErrors()}" th:text="${err}">글로벌 오류 메시지</p>
</div>

필드 에러 : 상품명

<div>
  <label for="itemName" th:text="#{label.item.itemName}">상품명</label>
  <input type="text" id="itemName" th:field="*{itemName}"
         th:errorclass="field-error" class="form-control" placeholder="이름을 입력하세요">
  <div class="field-error" th:errors="*{itemName}">
    상품명 오류
  </div>
</div>

${#fields.hasGlobalErrors()}
글로벌 오류가 발생하면 컨트롤러에서 ObjectError 에 담았던 모든 에러를 꺼내준다.

th:errors="*{필드명}"
필드 오류가 발생하면 매핑되어있는 FiledError 에서 에러메시지를 꺼낸다.

th:errorclass
에러 클래스를 등록 하는 속성이다. 필드 오류가 발생할경우 자동으로 타임리프가 errorclass("field-error")의 값을 class에 추가해준다.

전체적으로 V1 에 비해 한결 코드가 깔끔해진걸 확인할 수 있다.

BindingResult 란?

BindingResult 는 스프링 MVC에서 폼 데이터 바인딩 과정 중에 발생하는 검증 오류를 수집하고 처리하는 객체이다. 폼 데이터를 모델 객체에 바인딩하고 검증을 수행하는 동안 발생한 오류 정보를 저장하고 제공해준다.

V1 에서는 에러와 에러메시지를 Map 에 담아 관리를 뷰에 전달해주었다. 하지만 V2 에서는 BindingResult 가 그 역할을 대체한다.

BindingResult 를 사용하면 스프링과 타임리프가 제공해주는 편의 기능을 사용 할 수 있다.

타입 오류 검증

타입 오류는 앞에서 설명했듯이 컴파일 에러가 발생하는것이기 때문에 서버가 셧다운 돼버리는 바람에 컨트롤러가 호출이 안 돼서 기존의 방식으론 검증하기가 불가능했다.

하지만 BindingResult 가 이를 해결 해준다.

예를들어, 사용자가 숫자입력 칸에 문자열을 입력하는 경우

1. 특정 필드에 값이 바인딩 되는 과정에서 오류가 발생
2. BindingResult 가 이 부분을 캐치해서 타입 에러를 담는다.
3. 그 다음 컨트롤러를 호출해준다.

Rejected Value

FieldError 의 생성자를 보면 다음과 같다.

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

여기서 rejectedValue 필드가 존재하는데 이 값을 초기화 해주지 않는다면 사용자가 입력했던값이 유지가 되지 않는다.

ObjectError 의 경우 당연히 특정 필드에 대한 오류가 아니므로 rejectedValue 필드가 없다.

문제점

V1 에 비해 굉장히 개선이 많이 되었다. 타입 오류에 대한 요구사항도 지켰으며, 입력 값 유지도 그대로 된다.

하지만, 타입 오류 발생시 오류 메시지가 너무 개발자스럽다;;

이 때 빛을 발하는게 바로 메시지 이다. 이제 오류 메시지에 대한 문구들을 관리하는 메시지 소스를 생성해보자.

본 포스트는
김영한의 스프링 MVC 2편 - 백엔드 웹 개발 활용 기술 강의 를 보고 정리했습니다.

profile
🍀 개발을 통해 지속 가능한 미래를 만드는데 기여하고 싶습니다 🍀

0개의 댓글