Spring

[인프런/스프링 MVC 2편] 4. 검증, 오류처리 (1) - Validation

주니어주니 2023. 5. 21. 22:15

 

 

검증 요구사항 

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

 

검증 처리 과정

 

상품 저장 성공시

  • 상품 저장 요청 -> 검증 로직 통과 -> 상품 저장 -> 상품 상세 화면으로 redirect

 

 

상품 저장 검증 실패 시

  • 상품 저장 요청 -> 검증 로직 실패 -> Model에 검증 오류 결과 포함해서 담은 채로 상품 등록 폼  

 


 

 

1. 검증 처리 (1) 

 

 

1) 컨트롤러 Controller

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

 

 

 

(1) 검증 오류 보관

Map<String, String> errors = new HashMap<>();
  • 검증시 오류 발생하면 errors에 담음
  • 어떤 필드에서 오류가 발생했는지 구분하기 위해 오류가 발생한 필드명을 key로 사용

 

 

(2) 필드 검증 로직 

if (!StringUtils.hasText(item.getItemName())) {
	errors.put("itemName", "상품 이름은 필수입니다.");
}

 

 

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

// 특정 필드가 아닌 복합 룰 검증
if (item.getPrice() != null && item.getQuantity() != null) {
    int resultPrice = item.getPrice() * item.getQuantity();
    if (resultPrice < 10000) {
        errors.put("globalError", "가격 * 수량의 합은 10,000원 이상이어야 합니다. 현재 값 = " + resultPrice);
    }
}
  • 특정 필드를 넘어서는 오류 : 특정 필드 이름을 넣을 수 없으므로 globalError라는 key 사용

 

 

(4) 검증 실패시 다시 입력폼

// 검증에 실패하면 다시 입력 폼으로 (에러가 있으면)
if (!errors.isEmpty()) {
    log.info("errors = {}", errors);
    model.addAttribute("errors", errors);
    return "validation/v1/addForm";
}
  • 오류 메시지가 하나라도 있으면 오류 메시지 출력을 위해 model에 errors 담고, 입력폼으로 보냄
    (!errors.isEmpty() -> errors가 비어있지 않으면 -> errors가 하나라도 있으면)

 

(5) 검증 실패 후 입력폼에 데이터 그대로 보여주기

  • @ModelAttribute Item item
    -> model에 item을 담음
    -> 검증 실패 후 등록 폼으로 되돌아왔을 때 model에 item이 담겨있음
    -> 등록 폼에서 ${item}을 쓸 때 itemName, price, quantity에 검증 실패 후 입력했던 데이터가 그대로 담겨있음   

 

 

2) 등록 폼 addForm 

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

        <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>

 

 

(1) css 추가

.field-error {
    border-color: #dc3545;
    color: #dc3545;
}
  • 오류 메시지 빨간색으로 강조 

 

 

(2) 글로벌 오류 메시지 

<div th:if="${errors?.containsKey('globalError')}">
    <p class="field-error" th:text="${errors['globalError']}">오류 메시지</p>
</div>
  • th:if : errors에 내용이 있을 때 -> 'globalError' 라는 key를 포함하고 있으면 'globalError' 메시지 출력

 

💡 errors?.containsKey()
등록폼에 처음 들어갈 때는 errors가 null
-> errors.containsKey() 호출 -> null.containsKey() 
-> NullPointerException 발생

errors?. : errors가 null일 때 NullPointerException이 발생하는 대신, 아예 null 반환
-> th:if 실행 X -> 오류 메시지 출력 X 

 

 

(3) 필드 오류 처리

<input type="text" id="itemName" th:field="*{itemName}"
       th:class="${errors?.containsKey('itemName')} ? 'form-control field-error' : 'form-control'"
       class="form-control" placeholder="이름을 입력하세요">
<input type="text" 
	th:classappend="${errors?.containsKey('itemName')} ? 'field-error' : _"
 	class="form-control">
  • 필드 오류 처리
    • 위 둘중 하나 사용 ( th:class ~~ / th:classappend ~~ )
    • 해당 필드에 오류가 있으면 field-error 클래스 -> 폼의 색깔을 빨간색으로 강조,
      오류가 없으면 기본 폼 ( _ : (No-Operation) 아무것도 X )

 

<div class="field-error" th:if="${errors?.containsKey('itemName')}" th:text="${errors['itemName']}">
    상품명 오류
</div>
  • 필드 오류 처리 - 메시지
    • errors에 'itemName'이라는 키가 있으면 itemName의 오류메시지 출력

 

 

3) 실행

 

 

로그 출력 (오류 메시지)

 

 

근데 위 방법대로 하면 문제점이 多

- 뷰 템플릿에서 중복되는 부분, 좀 복잡함

- 타입 오류 처리 안됨 (숫자 타입에 문자 들어갈 경우)

등등

 

 


 

2. 검증 처리 (2) BindingResult 

 

1) BindingResult

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

 

예) 숫자 타입에 문자를 넣었을 때

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

 

 

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

  • @ModelAttribute의 객체에 타입 오류 등으로 바인딩 자체가 실패하는 경우 스프링이 FieldError를 따로 생성해서 BindingResult에 넣어줌
  • 개발자가 직접 넣어줌 (아래 컨트롤러 로직)
  • Validator 사용 -> 나중에 

 

 

* BindingResult 파라미터의 위치

  • 반드시 @ModelAttribute 다음에 와야 함 !
  • ModelAttribute의 객체의 바인딩 결과를 담고 있기 때문
  • BindingResult는 Model에 자동으로 포함 

 

 

 

2) 컨트롤러 Controller

@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", "상품명은 필수입니다."));
    }

    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 resultPrice = item.getPrice() * item.getQuantity();
        if (resultPrice < 10000) {
            bindingResult.addError(new ObjectError("item", "가격 * 수량의 합은 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}";
}

 

 

(1) 필드 오류 - FieldError 

if (!StringUtils.hasText(item.getItemName())) {
    bindingResult.addError(new FieldError("item", "itemName", "상품명은 필수입니다."));
}

 

  • FieldError 생성자 요약
public FieldError(String objectName, String field, String defaultMessage) {}

 

  • 필드에 오류가 있으면 FieldError 객체를 생성해서 bindingResult에 담아둠
  • objectname : @ModelAttribute 이름
  • field : 오류가 발생한 필드 이름
  • defaultMessage : 오류 기본 메시지

 

 

(2) 글로벌 오류 - 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));
    }
}

 

  • ObjectError 생성자 요약
public ObjectError(String objectName, String defaultMessage) {}

 

  • 특정 필드를 넘어서는 오류가 있으면 ObjectError 객체를 생성해서 bindingResult에 담아둠
  • objectName : @ModelAttribute 이름
  • defaultMessage : 오류 기본 메시지

 

 

 

3) 등록 폼 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="${#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>

        <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/v2/items}'|"
                        type="button" th:text="#{button.cancel}">취소</button>
            </div>
        </div>

    </form>

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

 

 

타임리프 스프링 검증 오류 통합 기능

  • BindingResult를 활용해서 편리하게 검증 오류 표현

 

(1) 글로벌 오류 처리 

<div th:if="${#fields.hasGlobalErrors()}">
    <p class="field-error" th:each="err : ${#fields.globalErrors()}" th:text="${err}">글로벌 오류 메시지</p>
</div>
  • #fields : BindingResult가 제공하는 검증 오류가 접근
  • 글로벌 오류가 여러개일 수도 있으니 th:each 로 여러개 출력

 

 

(2) 필드 오류 처리

<input type="text" id="itemName" th:field="*{itemName}"
       th:errorclass="field-error" class="form-control" placeholder="이름을 입력하세요">
<div class="field-error" th:errors="*{itemName}">
    상품명 오류
</div>
  • th:errors : 해당 필드에 오류가 있는 경우에 태그 출력
  • th:errorclass : th:field에서 지정한 필드에 오류가 있으면 class 정보 추가

 

 

 

4) 실행 

-> 근데 입력한 데이터가 유지되지 않음 

 

 


 

3. 검증 처리 (3) FieldError, ObjectError 

 

  • 사용자 입력 오류 데이터가 화면에 남도록 유지 

 

 

1) 컨트롤러 

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

 

 

 

(1) FieldError 생성자 ( 두 가지 생성자 제공 ) ≒ ObjectError 생성자 

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

 

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

 

 

(2) 오류 발생시 사용자 입력 값 유지 

  • FieldError : 오류 발생시 사용자 입력 값을 저장하는 객체
  • rejectedValue : 오류 발생시 사용자 입력 값을 저장하는 필드

 

 

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

th:field="*{price}"
  • th:field : 정상 상황 -> 모델 객체의 값 출력
                      오류 발생 -> FieldError에서 보관한 값 출력

 

 

값 유지 (검증 실패)

 

 

 

 

(4) 스프링의 바인딩 오류 처리

  • 타입 오류 -> 바인딩 실패 -> 스프링이 FieldError 생성 -> FieldError의 rejectedValue 필드에 사용자가 입력한 값 저장 -> 해당 오류를 BindingResult에 담아서 컨트롤러 호출 -> 사용자 입력 값 유지 + 오류 메시지 정상 출력

 

 

값 유지 (바인딩 실패)

 

 

 


 

4. 오류 코드와 메시지 처리 1 - FieldError 직접 사용

 

 

1) FieldError 생성자 확인 ( 두 가지 생성자 제공 ) ≒ ObjectError 생성자 

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

 

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

 

--> codes, arguments 제공 : 오류 발생시 오류코드로 메시지 찾기 

 

 

 

2) errors 메시지 파일 생성 

 

(1) errors.properties 추가 

 

* src/main/resources/errors.properties

required.item.itemName=상품 이름은 필수입니다.
range.item.price=가격은 {0} ~ {1} 까지 허용합니다.
max.item.quantity=수량은 최대 {0} 까지 허용합니다.
totalPriceMin=가격 * 수량의 합은 {0}원 이상이어야 합니다. 현재 값 = {1}
  • messages.properties에 추가해도 되지만, 오류 메시지를 구분하기 쉽게 별도의 파일로 관리
  • errors_en.properties 파일을 생성하면 오류 메시지도 국제화 처리 가능

 

 

(2) 스프링 부트 메시지 설정 추가

 

* application.properties

spring.messages.basename=messages,errors
  • 스프링 부트가 해당 메시지 파일을 인식할 수 있도록 설정
  • messages.properties, errors.properties 두 파일 모두 인식
    (생략하면 messages.properties를 기본으로 인식)

 

 

 

3) errors에 등록한 메시지 사용 

 

(1) 컨트롤러 

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

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

    // 검증에 실패하면 다시 입력 폼으로 (에러가 있으면)
    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}";
}

 

 bindingResult.addError(new FieldError("item", "price", item.getPrice(), false, new String[]{"range.item.price"}, new Object[]{1000, 1000000}, null));

 

  • ctrl + p --> 매개변수 확인
  • errors 파일 내용
    ex) range.item.price=가격은 {0} ~ {1} 까지 허용합니다.
  • 매개변수 codes 부분 (new String[] 을 받음)
    • errors 파일에서 설정한 "range.item.price" 메시지 코드 입력
    • 배열로 여러 값 전달 할 수 있음
      new String[]{"required.item.itemName", "required.message"} 이런식으로 해놓으면
      프로퍼티 파일에서 순서대로 찾아서 매칭되는 메시지 사용
  • 매개변수 arguments 부분 (new Object[] 를 받음)
    • 메시지 코드의 파라미터로 치환할 값 전달

 

 

-> 근데 FieldError, ObjectError는 다루기 좀 복잡해

 

 

 


 

 

5. 오류 코드와 메시지 처리 2 - rejectValue(), reject() 사용

 

 

1) BindingResult의 역할 

  • 검증해야 할 target 바로 다음에 위치 
    -> 본인이 검증해야 할 객체인 target을 알고 있음

 

컨트롤러에서 확인

log.info("objectName={}", bindingResult.getObjectName());
log.info("target={}", bindingResult.getTarget());

 

출력결과 

objectName=item 						// @ModelAttribute name
target=Item(id=null, itemName=상품, price=100, quantity=1234)	// target 객체

--> BindingResult는 이미 검증해야 할 객체를 알고 있음

 

 

 

2) rejectValue() , reject()

 

  • BindingResult는 검증해야 할 객체를 알고 있음 
    -> FieldError, ObjectError를 직접 생성하지 않고, 검증 오류를 쉽게 다룰 수 있음
    • BindingResult의 reject() : 검증해야 할 객체 (Object)
    • BindingResult의 rejectValue() : 검증해야 할 객체의 필드

 

 

3) 기존 코드 단순화 

 

 

* 컨트롤러 

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

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

        // 특정 필드가 아닌 복합 룰 검증
        if (item.getPrice() != null && item.getQuantity() != null) {
            int resultPrice = item.getPrice() * item.getQuantity();
            if (resultPrice < 10000) {
                bindingResult.reject("totalPriceMin", new Object[]{10000, resultPrice}, null);
            }
        }

        // 검증에 실패하면 다시 입력 폼으로 (에러가 있으면)
        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}";
    }

 

 

(1) rejectValue() -> 필드 

void rejectValue(@Nullable String field, String errorCode, @Nullable Object[] errorArgs, @Nullable String defaultMessage);
bindingResult.rejectValue("price", "range", new Object[]{1000, 1000000}, null);

 

  • field : 오류 필드명
    (BindingResult는 어떤 객체를 대상으로 검증하는지 target을 알고 있음 -> 객체 없이 필드만 적어도 됨)
  • errorCode : 오류 코드
    (error 메시지에 등록된 코드 : "range.item.price"
     매개변수에 작성하는 코드 : "range"
      -> 축약 오류 코드 (object + 필드를 합쳐서 뭔가 규칙에 따라 정해짐 -> 뒤에 설명 MessageCodesResolver))
  • errorArgs : 오류 메시지에서 프로퍼티를 치환할 값
  • defaultMessage : 오류 메시지를 찾을 수 없을 때 사용하는 기본 메시지

 

 

(2) reject() -> 객체

void reject(String errorCode, @Nullable Object[] errorArgs, @Nullable String defaultMessage);
bindingResult.reject("totalPriceMin", new Object[]{10000, resultPrice}, null);

 

  • 가격 * 수량 -> 특정 필드 대상 X -> reject()
  • errorCode : 축약 코드
  • errorAgrs : 프로퍼티

 

 

-> errors 파일 오류코드 : "required.item.itemName" 

-> FieldError() 를 직접 다룰 때 오류코드 :  "required.item.itemName" 그대로 모두 입력했음

-> rejectValue() 를 사용할 때 오류코드 : "required"만 적어도 오류 메시지를 잘 찾아서 출력함

-> 왜 그러냐???

-> MessageCodesResolver 때문

 


 

6. 오류 코드와 메시지 처리 3 - MessageCodesResolver

 

1) 테스트 코드로 MessageCodesResolver 알아보기

 

* MessageCodesResolverTest

package hello.itemservice.validation;

import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.Test;
import org.springframework.validation.DefaultMessageCodesResolver;
import org.springframework.validation.MessageCodesResolver;

import javax.print.DocFlavor;

import static org.assertj.core.api.Assertions.*;

public class MessageCodesResolverTest {

    MessageCodesResolver codesResolver = new DefaultMessageCodesResolver();

    @Test
    void messageCodesResolverObject() {
        String[] messageCodes = codesResolver.resolveMessageCodes("required", "item");
        for (String messageCode : messageCodes) {
            System.out.println("messageCode = " + messageCode);
        }
        assertThat(messageCodes).containsExactly("required.item", "required");
    }

    @Test
    void messageCodesResolverField() {
        String[] messageCodes = codesResolver.resolveMessageCodes("required", "item", "itemName", String.class);
        for (String messageCode : messageCodes) {
            System.out.println("messageCode = " + messageCode);
        }
        assertThat(messageCodes).containsExactly(
                "required.item.itemName",
                "required.itemName",
                "required.java.lang.String",
                "required"
        );
    }
}

 

 

(1) MessageCodesResolver

MessageCodesResolver codesResolver = new DefaultMessageCodesResolver();
  • 스프링이 제공
  • 검증 오류 코드 (ex - "required") 로 메시지 코드들을 만들어줌
  • MessageCodesResolver - 인터페이스
    DefaultMessageCodesResolver - 기본 구현체

 

 

(2) '객체' 오류

@Test
void messageCodesResolverObject() {
    String[] messageCodes = codesResolver.resolveMessageCodes("required", "item");
    for (String messageCode : messageCodes) {
        System.out.println("messageCode = " + messageCode);
    }
    assertThat(messageCodes).containsExactly("required.item", "required");
}

출력 결과

messageCode = required.item
messageCode = required
  • MessageCodesResolver가 오류코드("required"), 객체("item")를 가지고 메시지 코드 생성

 

 

(3) '필드' 오류 

@Test
void messageCodesResolverField() {
    String[] messageCodes = codesResolver.resolveMessageCodes("required", "item", "itemName", String.class);
    for (String messageCode : messageCodes) {
        System.out.println("messageCode = " + messageCode);
    }
    assertThat(messageCodes).containsExactly(
            "required.item.itemName",
            "required.itemName",
            "required.java.lang.String",
            "required"
    );
}

출력 결과

messageCode = required.item.itemName
messageCode = required.itemName
messageCode = required.java.lang.String
messageCode = required
  • MessageCodesResolver가 오류코드("required"), 객체("item"), 필드("itemName")를 가지고 메시지 코드 생성

 

 

 

2) DefaultMessageCodesResolver의 기본 메시지 생성 규칙

 

 

(1) 객체 오류 

객체 오류의 경우 다음 순서로 2가지 생성
1.: code + "." + object name
2.: code

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

 

 

(2) 필드 오류

필드 오류의 경우 다음 순서로 4가지 메시지 코드 생성
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"

 

 

 

3) 동작 방식

 

  1. bindingResultrejectValue() / reject() 실행
    (예 - bindingResult.rejectValue("price", "range", new Object[]{1000, 1000000}, null))

  2. rejectValue, reject 안에서 MessageCodesResolver 호출

  3. codesResolver를 실행해서 메시지 코드 생성
    (예 - String[] messageCodes = codesResolver.resolveMessageCodes("required","item", "itemName", String.class))

  4. 메시지 코드가 배열로 뽑혀나옴 (구체적인 것부터)
    (예 - required.item.itemName / required.itemName / required.java.lang.String / required)

  5. new FieldError, new ObjectError를 만들어서 codes 매개변수에 뽑혀나온 메시지 코드를 배열로 넣음

 

 

* 컨트롤러 로그로 확인 (codes [ ~~~ ])

// 검증에 실패하면 다시 입력 폼으로 (에러가 있으면)
if (bindingResult.hasErrors()) {
    log.info("errors = {}", bindingResult);
    return "validation/v2/addForm";
}
Field error in object 'item' on field 'itemName': rejected value []; codes [required.item.itemName,required.itemName,required.java.lang.String,required]; arguments []; default message [null]

 

 

 

4) 결론 - 컨트롤러 ( 축약 코드만 적어도 됐던 이유 )

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

        // 검증 로직
        if (!StringUtils.hasText(item.getItemName())) {
            bindingResult.rejectValue("itemName", "required");
        }
 ...
        // 특정 필드가 아닌 복합 룰 검증
        if (item.getPrice() != null && item.getQuantity() != null) {
            int resultPrice = item.getPrice() * item.getQuantity();
            if (resultPrice < 10000) {
                bindingResult.reject("totalPriceMin", new Object[]{10000, resultPrice}, null);
            }
        }

 

(1) rejectValue("itemName", "required") 실행 - FieldError

다음 4가지 오류 코드 자동으로 생성

  • required.item.itemName
  • required.itemName
  • required.java.lang.String
  • required

 

(2) reject("totalPriceMin") 실행 - ObjectError

다음 2가지 오류 코드 자동으로 생성

  • totalPriceMin.item
  • totalPriceMin

 

 

5) 오류 메시지 출력 - 뷰 템플릿

<div class="field-error" th:errors="*{itemName}">
    상품명 오류
</div>

타임리프 화면 렌더링 -> th:errors 실행 -> 오류가 있으면 위에서 생성된 오류 메시지 코드를 순서대로 돌아가면서 메시지 찾음 -> 없으면 디폴트 메시지 출력

 

 

 

 

6) 프로젝트에 적용 

컨트롤러, 뷰 템플릿은 수정 필요 X

 

 

(1) 에러메시지 설정

 

* errors.properties 

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

#==ObjectError==
#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} 까지 허용합니다.

 

  • 메시지 생성 순서
    1. Level1 : 구체적 (required.item.itemName)
    2. Level2 : 그 다음 구체적 (required.itemName)
    3. Level3 : 타입 (required.java.lang.String)
    4. Level4 : 범용적 (required)
  • 위 순서대로 MessageSource에서 메시지 찾음 

 

 

 

(2) 실행 

 

* Level1 주석 -> Level3 에러메시지 출력

 

 

 

* Level2, 3 주석 -> Level4 에러메시지 출력

 

 

* Level4 주석 -> 없으면 코드에 적은 defaultMessage 적용

 

if (!StringUtils.hasText(item.getItemName())) {
    bindingResult.rejectValue("itemName", "required", "기본 : 상품 이름은 필수입니다.");
}

 

 

 

7) ValidationUtils - empty, 공백 방지

 

원래 코드 

if (!StringUtils.hasText(item.getItemName())) {
    bindingResult.rejectValue("itemName", "required", "기본 : 상품 이름은 필수입니다.");
}

 

↓ 다음 한줄로 가능

 

ValidationUtils 적용

ValidationUtils.rejectIfEmptyOrWhitespace(bindingResult, "itemName", "required");

 

 

 

8) 정리 

  • 구체적인 것 -> 덜 구체적인 것
    • MessageCodesResolver는 구체적인 것부터 만들고, 덜 구체적인 것을 나중에 만듦
  • 복잡하게 사용하는 이유 
    • 모든 오류 코드에 대해 메시지를 다 정의하면 관리하기 너무 힘듦
    • 크게 중요하지 않은 메시지 -> 범용성 있는 "required" 
      중요한 메시지 -> 구체적으로 "required.item.itemName" 
  • 순서
    1. rejectValue() / reject() 호출
    2. MessageCondesResolver 사용 -> 검증 오류 코드로 메시지 코드 생성
    3. new FieldError() / new ObjectError() 생성 -> codes 매개변수에 메시지 코드들 저장
    4. th:errors에서 메시지 코드들로 메시지를 순서대로 찾아서 출력

 

 


 

 

7. 오류 코드와 메시지 처리 4 - 스프링이 직접 만든 오류 메시지 처리 

 

1) 검증 오류 코드 2가지

  • 개발자가 직접 설정한 오류 코드 -> rejectValue() 직접 호출
  • 스프링이 직접 검증 오류에 추가 (주로 타입 정보가 맞지 않는 경우)

 

 

2) 스프링이 직접 만든 오류 메시지 처리

 

* 가격 필드 (타입 - Integer)에 문자 입력할 경우

 

 

로그

codes [typeMismatch.item.price,typeMismatch.price,typeMismatch.java.lang.Integer,typeMismatch]
 default message [Failed to convert property value of type 'java.lang.String' to required type 'java.lang.Integer' for property 'price'; nested exception is java.lang.NumberFormatException: For input string: "ㅂㅂㅂ"]

 

 

(1) 메시지 코드 확인

 

 

* 4가지 메시지 코드 

  • typeMismatch.item.price
  • typeMismatch.price
  • typeMismatch.java.lang.Integer
  • typeMismatch

 

-> 타입 오류 발생 -> 스프링이 typeMismatch 오류 코드 사용 -> MessageCodesResolver로 메시지 코드 생성

 

 

 

(2) 에러 메시지 변경하기

 

 

* 스프링이 생성한 기본 메시지

Failed to convert property value of type java.lang.String to required type 
java.lang.Integer for property price; nested exception is 
java.lang.NumberFormatException: For input string: "ㅂㅂㅂ"

 

 

* error.properties 추가 

#추가
typeMismatch.java.lang.Integer=숫자를 입력해주세요.
typeMismatch=타입 오류입니다.

 

 

 

 

 


 

8. Validator 분리 1

 

현재 컨트롤러에서 검증 로직하는 부분이 너무 긺 -> 검증 로직 부분 분리

 

 

1) 검증 로직 객체 생성 

 

* ItemValidator 

 

package hello.itemservice.web.validation;

import hello.itemservice.domain.item.Item;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.validation.Errors;
import org.springframework.validation.Validator;

@Component
public class ItemValidator implements Validator {

    @Override
    public boolean supports(Class<?> clazz) {
        return Item.class.isAssignableFrom(clazz);  // item == clazz인지, item == item의 자식클래스인지
    }

    @Override
    public void validate(Object target, Errors errors) {
    
    	// 검증 대상 객체 (target을 검증 대상 객체로 형변환)
        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() >= 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);
            }
        }
    }
}

 

 

(1) Validator 인터페이스 

public interface Validator {
	boolean supports(Class<?> clazz);
	void validate(Object target, Errors errors);
}
  • 검증을 체계적으로 하기 위해 스프링이 제공하는 인터페이스
  • supports() : 해당 검증기를 지원하는지 여부 확인 (뒤에서 설명)
  • validate(Object target, Errors errors) : target - 검증 대상 객체 , errors - BindingResult

 

(2) @Component

  • 스프링 빈으로 등록 -> 컨트롤러에서 주입받아서 사용하기 위함

 

 

2) 컨트롤러에서 ItemValidator 호출

 

// 의존성 주입
private final ItemValidator itemValidator;


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

 

  • ItemValidator를 스프링 빈으로 주입받아서 직접 호출
  • itemValidator.validate 호출 (검증 대상 객체, 에러)

 

 


 

9. Validator 분리 2 - WebDataBinder 사용

 

1) WebDataBinder

 

스프링의 파라미터 바인딩의 역할 + 검증 기능 

 

 

2) 컨트롤러 

private final ItemValidator itemValidator;

@InitBinder
public void init(WebDataBinder dataBinder) {
    dataBinder.addValidators(itemValidator);
}
  • WebDataBinder에 검증기 추가 -> 해당 컨트롤러에서 검증기 자동 적용 (직접 호출할 필요 X)

 

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

 

  • 검증 대상 앞에 @Validated 추가
    • 검증기를 실행하라는 어노테이션
    • @ModelAttribute 객체에 대해 검증 , bindingResult에 에러 담음
    • validator 직접 호출할 필요 X
    • WebDataBinder에 등록한 검증기를 찾아서 실행 
      (검증기가 여러개 등록되어 있을 때 -> 구분을 위해 supports() 사용)

 

* 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) {...}
    
}

 

  • supports
    (supports(Item.class) 호출 -> clazz에 item이 담기면 -> Item에 item이 담길 수 있으면 true -> 이 때 validate 호출)

 

 

* 글로벌 설정 - 모든 컨트롤러에 다 적용 (잘 안씀) 

@SpringBootApplication
public class ItemServiceApplication implements WebMvcConfigurer {

    public static void main(String[] args) {
    	SpringApplication.run(ItemServiceApplication.class, args);
    }
    
    @Override
    public Validator getValidator() {
    	return new ItemValidator();
    }
    
}