관리 메뉴

나구리의 개발공부기록

검증1 - Validation, 검증 요구사항 및 프로젝트 설정 V1, 검증 직접 처리(소개 및 개발), 프로젝트 준비 V2, BindingResult1, BindingResult2, FieldError, ObjectError 본문

인프런 - 스프링 완전정복 코스 로드맵/스프링 MVC 2편 - 백엔드 웹 개발 핵심 기술

검증1 - Validation, 검증 요구사항 및 프로젝트 설정 V1, 검증 직접 처리(소개 및 개발), 프로젝트 준비 V2, BindingResult1, BindingResult2, FieldError, ObjectError

소소한나구리 2024. 9. 3. 20:29

  출처 : 인프런 - 스프링 MVC 2편 - 백엔드 웹 개발 핵심 기술 (유료) / 김영한님  
  유료 강의이므로 정리에 초점을 두고 코드는 일부만 인용  

https://inf.run/GMo43


1.  검증 요구사항 및 프로젝트 설정 V1

1) 요구사항 : 검증 로직 추가

(1) 타입 검증

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

(2) 필드 검증

  • 상품명 : 필수, 공백 x
  • 가격 : 1000원 이상, 1백만원 이하
  • 수량 : 최대 9999

(3) 특정 필드의 범위를 넘어서는 검증

  • 가격 * 수량의 합은 10,000원 이상

2) 현재까지 만든 웹 애플리케이션의 문제점

  • 폼 입력시 숫자를 문자로 작성하여 검증 오류가 발생하면 오류 화면으로 바로 이동해서 사용자가 처음부터 해당 폼으로 다시 이동해서 입력해야함
  • 값을 넣지 않아도 상품이 등록되어버림
  • 이러한 서비스라면 사용자는 금방 떠나갈 것이 분명하므로 웹 서비스는 폼 입력시 오류가 발생하면 고객이 입력한 데이터를 유지한 상태로 어떤 오류가 발생했는지 친절하게 알려주어야 함
  • 컨트롤러의 중요한 역할 중 하나는 HTTP 요청이 정상인지 검증하는 것, 그리고 정상 로직보다 이런 검증 로직을 잘 개발하는 것이 어쩌면 더 어려울 수 있음

** 참고 : 클라이언트 검증, 서버 검증

  • 클라이언트 검증은 조작할 수 있으므로 보안에 취약함 (postman 등으로 쉽게 조작이 가능)
  • 서버만으로 검증하면 즉각적인 고객 피드백이 부족함(사용성 부족)
  • 둘을 적절히 섞어서 사용하고 최종적인 서버 검증은 필수
  • API 방식을 사용하면 API 스펙을 잘 정의해서 검증 오류를 API 응답 결과에 잘 남겨주어야 함

3) 프로젝트 설정

(1) 프로젝트 

  • 제공된 소스의 validation-start 디렉토리를 사용하였음
  • Gradle
  • Java 11
  • Spring Boot 2.4.4

(2) Metadata

  • Group: hello
  • Artifact: validation
  • Packaging: Jar

(3) Dependencies

  • 스프링 웹
  • 타임리프
  • 롬복

2. 검증 직접 처리(소개 및 개발)

1) 상품 저장 성공과 실패

(1) 프로세스 소개

우) 상품 저장 성공, 좌)상품 저장 검증 실패

  • 사용자가 상품 등록 폼에서 정상 범위의 데이터를 입력하면 서버에서는 검증 로직이 통과하고, 상품을 저장하고, 상품 상세 화면으로 redirect 함
  • 하지만 검증 오류가 발생되면 서버 검증 로직이 실패해야 하는데, 이렇게 검증에 실패한 경우 다시 상품 등록 폼을 보여주고 어떤 값이 잘못 입력했는지 친절하게 알려주어야 한다
  • 즉, 컨트롤러에서 이미 사용자가 입력한 값들을 모델에 데이터를 모두 담아서 다시 전달해야 한다는 뜻

2) 상품 등록 검증 - 직접 개발

(1) ValidationItemControlelrV1 - addItem() 수정

  • error가 발생하면 errors에 정보를 담아두고 발생한 필드명을 key로 사용해서 이후 뷰에서 이를 이용해 고객에게 오류 메시지를 출력하기 위해 활용
  • 컨트롤러만 수정하면 뷰에서는 아무 변화가 없어 보이지만(@ModelAttribute로 다시 입력폼으로 데이터를 담아서 보냈기 때문) log를 찍어보면 에러 메시지가 계속 출력되는 것을 확인 할 수 있음
@PostMapping("/add")
public String addItem(@ModelAttribute Item item, RedirectAttributes redirectAttributes, Model model) {

    // 검증 오류 결과를 보관
    Map<String, String> errors = new HashMap<>();

    // 검증 로직 - org.springframework
    // itemName에 글자가 없으면 에러 메시지를 출력
    if (!StringUtils.hasText(item.getItemName())) {
        errors.put("itemName", "상품 이름은 필수 입니다");
    }

    // 가격이 null or 1000원 미만, 100만원 초과면 에러 메시지 출력
    if (item.getPrice() == null || item.getPrice() < 1000 || item.getPrice() > 1000000) {
        errors.put("price", "가격은 1,000 ~ 1,000,000원 까지 허용합니다");
    }

    // 수량이 9999개 넘어가면 에러메시지 출력
    if (item.getQuantity() == null || item.getQuantity() > 9999 || item.getQuantity() < 1) {
        errors.put("quantity", "수량은 1 ~ 9,999 까지 허용합니다");
    }

    // 특정 필드가 아닌 복합 룰 검증
    // 가격 * 수량의 합이 10,000원 미만일 경우 에러메시지 출력
    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}";
}

 

(2) addForm.html

  • 뷰페이지에 에러메시지를 보여주기 위해 수정
  • 오류 메시지는 errors에 내용이 있을 때만 출력하면 되므로 타임리프의 th:if를 사용하여 조건에 만족할 때 해당 HTML 태그를 출력하도록 작성
  • css추가: .field-error를 추가하여 해당 부분을 빨간색으로 강조
<!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: red;
            color: red;
        }
    </style>
</head>
<body>

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

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

<!--        errors의 Key에 globalError라는 값이 있으면 에러메시지 출력, ?.의 의미는 null일경우 무시-->
    <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 테두리도 빨간색으로 하기  -->
        <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>
        <!-- classappend 사용 했을 때 -> 문법이 조금 더 간단해짐-->
        <input type="text" id="price" th:field="*{price}"
               th:classappend="${errors?.containsKey('price')} ? 'field-error' : _"
               class="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}"
               th:class="${errors?.containsKey('quantity')} ? 'form-control field-error' : 'form-control'"
               class="form-control" placeholder="수량을 입력하세요">
        <div class="field-error" th:if="${errors?.containsKey('quantity')}" th:text="${errors['quantity']}">수량 오류</div>
    </div>

 

** 참고: Safe Navigation Operator

  • 최초 get 요청으로 등록 폼에 진입 시점에는 errors가 없기 때문에(null) errors.containsKey()를 호출하는 순간 NullPointerException이 발생하게 됨
  • errors?. 를 활용하면 errors가 null일때 NullPointerException대신 null을 반환하게 되고, th:if 에서 null은 실패로 처리가 되므로 오류 메시지가 출력되지 않게 됨
  • SpringEL이 제공하는 문법이므로 자세한 내용은 공식 문서를 참고하면 좋음!
  • https://docs.spring.io/spring-framework/reference/core/expressions/language-ref/operator-safe-navigation.html

3) 정리 및 남은 문제점

(1) 정리

  • 검증 오류가 발생하명 입력 폼을 다시 보여줌
  • 검증 오류들을 고객에게 친절하게 안내해서 다시 입력할 수 있게 함
  • 검증 오류가 발생해도 고객이 입력한 데이터가 유지됨

(2) 남은 문제점

  • 뷰 템플릿에서 중복 처리가 많고 뭔가 비슷함
  • 타입 오류 처리가 안됨, Item의 price, quantity 같은 숫자 필드는 타입이 Integer이므로 문자 타입으로 설정하는 것이 불가능
  • 스프링에서 MVC에서 컨트롤러에 진입하기도 전에 예외가 발생하기 때문에 컨트롤러가 호출되지도 않고 400 예외가 발생해버림
  • 타입 오류가 발생해도 고객이 입력한 문자를 화면에 남겨야 하는데 컨트롤러가 호출 된다고 해도 애초에 타입이 Integer이므로 문자를 보관할 수 없어 고객이 입력한 문자가 사라지게 됨 -> 결국 어딘가에 별도로 관리가 되어야 함
  • 스프링이 제공하는 검증 방법으로 하나씩 해결!

3. 프로젝트 준비 V2

1) 프로젝트 준비 V2

  • 앞서 만든 기능을 유지하기 위해 컨트롤러와 템플릿 파일을 복사해서 사용할 예정

(1) ValidationItemControllerV2 컨트롤러 생성

  • validationItemControllerV1을 복사 붙혀넣기 해서 V2로 변경
  • URL경로를 validation/v1/ -> validation/v2/로 변경
  • 인텔리제이의 커맨드 + r 을하면 변경 기능을 활용할 수 있음

(2) 템플릿 파일 복사

  • ~/validation/v1 디렉토리의 모든 템플릿을 ~/validation/v2디렉토리로 복사
  • 복사된 ~/validation/v2/ 하위 4개 파일의 URL 경로를 validation/v2/로 변경
  • 폴더를 눌러서 커맨드 + 쉬프트 + r을 하면 하위경로의 파일의 내용을 모두 바꿀 수 있음

4. BindingResult1

1) 핵심은 BindingResult

(1) ValidationItemControllerV2 - addItemV1 수정

  • FieldError: 필드에 오류가 있을 때 사용
  • ObjectError: 글로벌 오류에 사용 (FieldError는 ObjectError의 자식)
  • BindingResult bindingResult파라미터의 위치는 꼭 @ModelAttribute Item item 다음에 와야하며 그렇지 않으면 동작 안함
  • FieldError 생성자
    - objectName:
    @ModelAttribuete 이름
    - field: 오류가 발생한 필드 이름
    - defaultMessage: 오류 기본 메시지
  • ObjectError 생성자도 objectName이 없을 뿐 위와 같음
  • bindingResult.hasErrors()로 error()가 있는지 확인
// BindingResult 파라미터의 위치는 @ModelAttribute Item item 다음에 와야함 - 중요! 다른곳에 있으면 작동하지 않음
@PostMapping("/add")
public String addItemV1(@ModelAttribute Item item, BindingResult bindingResult,
                      RedirectAttributes redirectAttributes) {

    // objectName은 ModelAttribute의 item을 입력하면 됨
    // 특정 필드 에러는 FieldError
    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 || item.getQuantity() < 1) {
        bindingResult.addError(
                new FieldError("item", "quantity", "수량은 1 ~ 9,999 까지 허용합니다"));
    }

    // 특정 필드 오류가 아닐 때 (globalError -> ObjectError)
    if (item.getPrice() != null && item.getQuantity() != null) {
        int resultPrice = item.getPrice() * item.getQuantity();
        if (resultPrice < 10000) {
            bindingResult.addError(
                    new ObjectError("item",
                            "가격 * 수량의 합은 10,000원 이상 이어야 합니다. 현재 값 = " + resultPrice));
        }
    }

    // bindingResult에 에러가 있으면
    if (bindingResult.hasErrors()) {
        log.info("errors = {}", bindingResult);    // 에러 찍어보기
        // BindingResult는 addAttribute에 담지 않아도 알아서 담김
        return "validation/v2/addForm";
    }

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

 

(2) v2/addForm.html 수정

  • 타임리프는 스프링의 BindingResult를 활용해서 편리하게 검증 오류를 표현하는 기능을 제공함
  • #fields: #fields로 BindingResult가 제공하는 검증 오류에 접근할 수 있음
  • th:errors: 해당 필드에 오류가 있는 경우에 태그를 출력함 (th:if의 편의 버전)
  • th:errorclass: th:field에서 지정한 필드에 오류가 있으면 class 정보를 추가함
  • th:each: 해당 문법으로 여러개의 에러메시지가 있으면 한번에 출력할 수 있음
  • 검증과 오류 메시지 공식 메뉴얼 참고
  • https://www.thymeleaf.org/doc/tutorials/3.0/thymeleafspring.html#validation-and-error-messages
<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>
<div>
    <label for="price" th:text="#{label.item.price}">가격</label>
    <input type="text" id="price" th:field="*{price}"
           th:errorclass="field-error"
           class="form-control" placeholder="가격을 입력하세요">
    <div class="field-error" th:errors="*{price}">가격 오류</div>
</div>
<div>
    <label for="quantity" th:text="#{label.item.quantity}">수량</label>
    <input type="text" id="quantity" th:field="*{quantity}"
           th:errorclass="field-error"
           class="form-control" placeholder="수량을 입력하세요">
    <div class="field-error" th:errors="*{quantity}">수량 오류</div>
</div>

5.BindingResult2

  • 스프링이 제공하는 검증 오류를 보관하는 객체이며 검증 오류가 발생하면 여기에 보관하면 됨
  • BindingResult가 있으면 @ModelAttribute에 데이터 바인딩 시 오류가 발생해도 컨트롤러가 호출 됨

(1) @ModelAttribute에 바인딩 시 타입 오류가 발생하면

  • BindingResult가 없으면 -> 400 오류가 발생하면서 컨트롤러가 호출되지 않고 오류페이지로 이동
  • BindingResult가 있으면 -> 오류 정보(FieldError)를 BindingResult에 담아서 컨트롤러를 정상 호출 함

(2) BindingResult에 검증 오류를 적용하는 3가지 방법

  • @ModelAttribute의 객체에 타입 오류 등으로 바인딩이 실패하면 스프링이 FieldError를 생성해서 BindingResult에 넣어줌
    -> 바인딩 자체가 실패
  • 개발자가 직접 넣어줌 -> 비즈니스와 관련된 검증 오류
  • Validator 사용 -> 뒤에서 설명

(3) 순서

  • 위에도 설명 했지만 BindingResult는 검증할 대상 바로 다음에 와야함(순서가 중요)
  • @ModelAttribute Item item 바로 다음에 BindingResult가 와야하며, BindingResult는 Model에 자동으로 포함됨

(4) BindingResult와 Errors

  • 둘다 인터페이스이고 BindingResult가 Errors를 상속 받고 있음
  • 실제 구현체는 BeanPropertyBindingResult이며 둘다 구현하고 있으므로 BindingResult대신 Errors를 사용해도 가능
  • 하지만 Errors는 단순한 오류 저장과 조회기능을 제공하고 BindingResult는 추가적인 기능들을 많이 제공함(addError() 등)
  • 주로 관례상 BindingResult를 많이 사용함

6. FieldError, ObjectError

1) 문제해결

  • BindingResult로 오류 메시지를 처리 했지만 오류가 발생하면 고객이 입력한 내용이 사라지는 문제를 해결해야함

(1) ValidationItemControllerV2 - addItemV2 추가

// 고객이 입력한 내용을 사라지지 않게 하는 v2 버전 -> 값을 보존 해야함
// rejectedValue값 작성: item.getItemName(), false, null, null,
@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() > 9999 || item.getQuantity() < 1) {
        bindingResult.addError(
                new FieldError("item", "quantity",
                        item.getQuantity(), false, null, null,
                        "수량은 1 ~ 9,999 까지 허용합니다"));
    }

    // ObjectError는 별도의 필드값이 넘어오는 것이 아니기 때문에 해당사항 없음
    // 생성자가 넘어오는 값을 찍어보기 위해 null을 추가해보기
    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}";
}

 

(2) FieldError 생성자 (ObjectError도 유사하게 생성자를 제공하니 참고)

public FieldError(String objectName, String field, String defaultMessage);

public FieldError(String objectName, String field, @Nullable Object rejectedValue,
	boolean bindingFailure, @Nullable String[] codes, @Nullable Object[] arguments,
	@Nullable String defaultMessage)

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

(3) 오류 발생 시 사용자 입력값 유지되는 이유

  • 타입 오류와 같은 바인딩 되는 시점에 오류가 발생하면 FieldError가 오류 발생 시 사용자 입력 값을 저장하는 기능을 제공함
  • rejectedValue에 오류 메시지 담겨진 후 컨트롤러에 호출이 되기 때문에 그대로 값을 사용할 수 있음

(4) 타임리프의 사용자 입력 값 유지

  • 타임리프의 th:field가 정상 상황에서는 모델 객체의 값을 사용하지만, 오류가 발생하면 FieldError에서 보관한 값을 사용해서 값을 출력함