오봉이와 함께하는 개발 블로그

스프링 MVC 2 - 검증 직접 처리 본문

BE/Spring

스프링 MVC 2 - 검증 직접 처리

오봉봉이 2022. 8. 19. 00:39
728x90

검증 직접 처리 - 소개

성공

사용자가 상품 등록 폼에서 정상적인 범위의 데이터를 입력하면, 서버에서는 검증 로직이 통과하고, 상품을 저장 후 상품 상세 화면으로 redirect 한다.

실패

고객이 검증 범위를 넘어서면 서버 검증 로직이 실패해야 한다.
이렇게 검증이 실패한 경우 고객에게 다시 상품 등록 폼을 보여주고, 고객이 입력헀던 값을 다시 model에 담아 전달해서 값을 다시 입력해놔야 한다. 어떤 값을 잘못 입력했는지 알려주어야 한다.

검증 직접 처리 - 개발

상품 등록 검증 코드를 개발하자.

ValidationItemControllerV1 - addItem() 수정

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

검증 오류 보관
Map<String, String> errors = new HashMap<>();
검증 오루 발생시 어떤 검증에서 오류가 발생했는지 정보를 담을 객체

검증이 실패하면 다시 입력 폼으로

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

해당 로직을 적용하고 실행해서 검증에 실패했을 때 return "validation/v1/addForm";를 다시 요청하는데 전에 입력했던 모든 데이터가 그대로 남아있다.

@ModelAttribute Item item가 있어서 그렇다.

@GetMapping("/add")
public String addForm(Model model) {
    model.addAttribute("item", new Item());
    return "validation/v1/addForm";
}

위 코드를 통해 Item 객체를 만들어서 넘겨주고, 사용자가 입력 후 검증에 실패 했다.
그럼 return "validation/v1/addForm";를 실행하게 되고 다시 입력 폼으로 이동하는데, @ModelAttribute Item item가 자동으로 model.addAttribute("item", item)을 호출해서 입력했던 값 그대로 다시 넘어가게 된다.

addForm.html

<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="utf-8">
    <link th:href="@{/css/bootstrap.min.css}"
          href="../css/bootstrap.min.css" rel="stylesheet">
    <style>
        .container {
            max-width: 560px;
        }
        .field-error {
            border-color: #dc3545;
            color: #dc3545;
        }
    </style>
</head>
<body>

<div class="container">

    <div class="py-5 text-center">
        <h2 th:text="#{page.addItem}">상품 등록</h2>
    </div>

    <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}" class="form-control" th:class="${errors?.containsKey('itemName')} ? 'form-control field-error' : '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}" class="form-control" th:class="${errors?.containsKey('price')} ? 'form-control field-error' : 'form-control'"
                   placeholder="가격을 입력하세요">
            <div class="field-error" th:if="${errors?.containsKey('price')}" th:text="${errors['price']}">
                가격 오류
            </div>
        </div>
        <div>
            <label for="quantity" th:text="#{label.item.quantity}">수량</label>
            <input type="text" id="quantity" th:field="*{quantity}" class="form-control" th:class="${errors?.containsKey('quantity')} ? 'form-control field-error' : 'form-control'"
                   placeholder="수량을 입력하세요">
            <div class="field-error" th:if="${errors?.containsKey('quantity')}" th:text="${errors['quantity']}">
                수량 오류
            </div>
        </div>

        <hr class="my-4">

        <div class="row">
            <div class="col">
                <button class="w-100 btn btn-primary btn-lg" type="submit" th:text="#{button.save}">상품 등록</button>
            </div>
            <div class="col">
                <button class="w-100 btn btn-secondary btn-lg"
                        onclick="location.href='items.html'"
                        th:onclick="|location.href='@{/validation/v1/items}'|"
                        type="button" th:text="#{button.cancel}">취소</button>
            </div>
        </div>

    </form>

</div> <!-- /container -->
</body>
</html>

글로벌 오류 메시지

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

오류 메시지는 errors에 내용이 있을 때만 출력하면 된다.
th:if="${errors?.containsKey('globalError')}"를 통해 errors에 key값으로 globalError가 있는지 검사한다.
있으면 th:text="${errors['globalError']}"를 통해 value에 접근해서 값을 출력한다.

참고 Safe Navigation Operator

만약 errorsnull이라면?
처음 등록 폼에 진입한 시점에는 errors가 없기 때문에 errors.containsKey()를 호출하는 순간 NullPointerException가 발생한다.

errors?.errorsnull일 때 NPE이 발생하는 대신 null을 반환하는 문법이다.
th:if에서 null은 실패로 처리되므로 오류 메시지가 출력되지 않는다.

SprinEL이 제공하는 문법이다.

필드 오류 처리

<!-- v1 -->
<input type="text" id="quantity" th:field="*{quantity}" class="form-control" th:class="${errors?.containsKey('quantity')} ? 'form-control field-error' : 'form-control'" placeholder="수량을 입력하세요">
<!-- v2(개선) -->
<input type="text" th:classappend="${errors?.containsKey('itemName')} ? 'field-error' : _" class="form-control">

v1은 클래스 이름을 바꾸는 코드고, v2classappend를 사용해서 앞에 일므을 추가하는 코드다.
만약 값이 없으면 _(No-Operation)을 사용해서 아무것도 하지 않는다.

필드 오류 처리 - 메시지

<div class="field-error" th:if="${errors?.containsKey('itemName')}" th:text="${errors['itemName']}">
  상품명 오류
</div>

글로벌 오류 메시지와 동일하다.

정리

  • 만약 검증 오류가 발생하면 입력 폼을 다시 보여준다.
  • 검증 오류들을 고객에게 친절하게 안내해서 다시 입력할 수 있게 한다.
  • 검증 오류가 발생해도 고객이 입력한 데이터가 유지된다.

남은 문제

  • 뷰 템플릿에서 중복 처리가 많다.
    • 코드들이 다 비슷하다
  • 타입 오류 처리가 안 된다.
    • price, quantity같은 숫자 필드는 타입이 Integer이므로 문자 타입으로 설정하는 것이 불가능하다.
    • 숫자 타입에 문자가 들어오면 오류가 발생하지만, 스프링 MVC에서 컨트롤러에 진입하기도 전에 예외가 발생하기 때문에 컨트롤러 호출도 안 되며 400 에러가 떨어지고 오류 페이지를 띄운다.
  • Itemprice에 문자를 입력하는 것 처럼 타입 오류가 발생해도 고객이 입력한 문자를 화면에 남겨야 한다.
    • 만약 컨트롤러가 호출된다고 가정해도 ItempriceInteger이므로 문자를 보관할 수가 없다.
    • 결국 문자 바인딩이 불가능하므로 입력한 문자가 사라지고, 어떤 내용을 입력해서 오류가 발생했는지 이해하기 어렵다.
  • 결국 입력한 값도 어딘가에 별도로 관리되어야 한다.
출처 : 인프런 김영한 지식공유자님 강의 스프링 MVC 2편 - 백엔드 웹 개발 활용 기술
728x90
Comments