Spring/MVC

[Spring/MVC] 11. Validation

lumana 2025. 1. 19. 17:22

 

11. Validation


주의) 이번 챕터의 정리 글은 매우 깁니다~


정리

폼 입력 시 숫자를 문자로 작성하거나 해서 검증 오류가 발생하면 오류 화면으로 바로 이동한다. 이렇게 되면 사용자는 처음부터 해당 폼으로 다시 이동해서 입력을 해야 한다. 웹 서비스는 폼 입력 시 오류가 발생하면, 고객이 입력한 데이터를 유지한 상태로 어떤 오류가 발생했는지 친절하게 알려주어야 한다.


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

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

상품 저장 Validation 실패

사용자가 상품 등록 폼에 입력한 데이터에 대해 검증이 실패하면, 다시 상품 등록 폼을 보여주고, 어느 부분에서 값을 잘못 입력했는지 알려주도록 로직을 추가해보자.


addItem


검증 오류 보관

Map<String, String> errors = new HashMap<>();

만약 검증 시 오류가 발생하면 어떤 검증에서 오류가 발생했는지 정보를 담아둔다.
{“오류 종류”, “메시지”} 형식으로 담아둔다. 마지막으로 Model에 에러 정보를 추가한 뒤 해당 정보를 보여주자.


errors가 비어있다면, 성공한 것이고, 비어있지 않다면 오류가 발생한 것이다. 이에 맞게 view를 반환해주자.

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

전에 말했던 것 처럼 @ModelAttribute로 바인딩한 아이템은 자동적으로 Model에 담기게 된다. 만약 바인딩은 정상적으로 처리되었지만 검증 로직에서 걸렸다면, 모델에 담긴 item 객체의 값을 유지시킬 수 있다.


상품 추가 폼 수정

필드에 해당하는 오류는 해당 필드의

에서 보여주도록 하고, 복합 룰 검증의 경우 필드 위에서 처리할 수 있도록 한다.

글로벌 오류(복합 룰 검증)

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

타임리프의 th:if를 사용하면 조건에 만족할 때만 해당 HTML 태그를 출력할 수 있다.


참고: Safe Navigation Operator


만약 여기에서 errorsnull이라면 어떻게 될까? 생각해보면 등록폼에 진입한 시점(Get)에는 errors가 없다. 따라서 errors.containsKey()를 호출하는 순간 NullPointerException이 발생한다. (null.xxx가 되버림)


errors?.errorsnull일 때 NullPointerException이 발생하는 대신, null을 반환하는 문법이다. th:if에서 null은 실패로 처리되므로 오류 메시지가 출력되지 않는다. 이것은 스프링의 SpringEL이 제공하는 문법이다.


필드 오류


필드 에러 스타일 추가

<head>
    <!-- 생략 -->
    <style>
        .container {
            max-width: 560px;
        }
        .field-error {
            border-color: #dc3545;
            color: #dc3545;
        }
    </style>
</head>

<input type="text" th:classappend="${errors?.containsKey('itemName')} ? 'fielderror' : _"
 class="form-control">

classappend를 사용해서 해당 필드에 오류가 있으면 field-error라는 클래스 정보를 더해서 폼의 색깔을 빨간색으로 강조한다. 만약 값이 없으면 _ (No-Operation)을 사용해서 아무것도 하지 않는다.


물론 아래와 같이 조건문을 동해 class 속성 값을 통째로 바꿀 수도 있다.

<input type="text" id="itemName" th:field="*{itemName}"
       th:class="${errors?.containsKey('itemName')} ? 'form-control field-error' : 'form-control'"
       class="form-control" placeholder="이름을 입력하세요">


th:field 덕분에 잘못된 값을 입력하더라도 입력 폼에 값이 유지된다. (단, 모델의 Item이 담겼다는 것은, 타입 오류가 없었다는 것을 의미함. 아직 타입 오류가 발생했을 때는 값이 유지되지 않음)


그리고, 필드 오류에 대한 메시지를 출력해줘야 한다.

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

input 태그 아래에 추가해주자. 해당 필드에 오류가 존재하면 빨간 색 메시지로 컨트롤러에서 지정했던 메시지가 출력된다.


문제점

  • 뷰 템플릿에서 중복 처리가 많다. 뭔가 비슷하다.
    • itemName, price, quantity 모두 검증해주는 구조가 같다.
  • 타입 오류 처리가 안된다. Itemprice, quantity 같은 숫자 필드는 타입이 Integer이므로 문자 타입으로 설정하는 것이 불가능하다. 숫자 타입에 문자가 들어오면 오류가 발생한다. 그런데 이러한 오류는 스프링 MVC에서 컨트롤러에 진입하기도 전에 예외가 발생하기 때문에, 컨트롤러가 호출되지도 않고, 400 예외가 발생하면서 오류 페이지를 띄워준다.
  • 타입 오류가 발생한 경우에도 우리가 목표로 했던 입력 폼의 오류값 유지가 되어야 한다.

ControllerV2 - addItemV1

V1에서는 Map<String, String>에 에러 코드와 에러 메시지를 담고 모델을 통해 view에 전달했다. 모델에 매 번 이렇게 Map을 저장하는 것은 매우 불편하다. 스프링은 오류 처리를 위해 검증 오류를 보관하는 객체 BindingResult를 제공한다.


BindingResult

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

        // 검증 오류 결과를 보관
        //        Map<String, String> errors = new HashMap<>();
        // bindingResult가 V1의 errors의 역할을 해준다.      

주의

  • BindingResult bindingResult 파라미터의 위치는 @ModelAttribute Item item 다음에 와야 한다. 순서가 중요하다.

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 : 오류 기본 메시지

복합 룰 검증은?

//특정 필드가 아닌 복합 룰 검증
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 : 오류 기본 메시지
  • 사실 fieldError가 objectError의 자식임

addForm 수정

기존의 코드를 그대로 사용할 수도 있지만, 타임리프는 스프링의 BindingResult를 활용해서 편리하게 검증 오류를 표현하는 기능을 제공한다.


  • #fields : #fieldsBindingResult가 제공하는 검증 오류에 접근할 수 있다.
  • th:errors : 해당 필드에 오류가 있는 경우에 태그를 출력한다. th:if의 편의 버전이다.
  • th:errorclass : th:field에서 지정한 필드에 오류가 있으면 class 정보를 추가한다.

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

기존에 SpringEL의 Safe Navigation을 사용하는 대신, #fields.hasGlobalErrors()로 글로벌 오류 존재 유무를 체크할 수 있다. 참고로, globalError가 여러 개 있을 수 있으니까 th:each를 사용한다.


필드 오류 처리

<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="이름을 입력하세요">
    <!--    th:field에 오류가 있으면 class에다가 "field-error"를 추가해준다. -->
    <div class="field-error" th:errors="*{itemName}">
        상품명 오류
    </div>
</div>

  • 기존의 th:class="${errors?.containsKey('itemName')} ? 'form-control field-error' : 'form-control'" 대신 th:errorclass를 이용해서 코드를 간결하게 나타냈다.
    • th:field에 오류가 있으면 class에다가 "field-error"를 추가해준다.
    • 이러면 html 위에서 만들어놓은 필드 에러 css가 적용된다.
  • 기존의 <div class="field-error" th:if="${errors?.containsKey('itemName')}" th:text="${errors['itemName']}"> 대신 th:errors="*{itemName}를 사용해서, 필드에 오류가 있으면 오류 메시지가 나오도록 한다.
    • th:if 문 없이 오류 메시지를 출력할 수 있게 되었다.

BindingResult의 추가적인 기능

BindingResult가 있으면 @ModelAttribute에 데이터 바인딩 시 오류가 발생해도 컨트롤러가 호출된다!


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

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

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

  1. @ModelAttribute의 객체에 타입 오류 등으로 바인딩이 실패하는 경우 스프링이 FieldError 생성해서 BindingResult에 넣어준다.
  2. 개발자가 직접 넣어준다.
  3. Validator 사용 (이것은 뒤에서 설명)

주의

  • BindingResult는 검증할 대상인 target 바로 다음에 와야 한다. 순서가 중요하다. 예를 들어서 @ModelAttribute Item item, 바로 다음에 BindingResult가 와야 한다.
  • BindingResultModel에 자동으로 포함된다.


참고: BindingResult와 Errors

  • org.springframework.validation.Errors
  • org.springframework.validation.BindingResult

BindingResult는 인터페이스이고, Errors 인터페이스를 상속받고 있다.
실제 넘어오는 구현체는 BeanPropertyBindingResult라는 것인데, 둘 다 구현하고 있으므로 BindingResult 대신에 Errors를 사용해도 된다. Errors 인터페이스는 단순한 오류 저장과 조회 기능을 제공한다. BindingResult는 여기에 더해서 추가적인 기능들을 제공한다


그런데 오류가 발생하는 경우 고객이 입력한 내용이 모두 사라진다.
처음 구현에서는 타입 오류가 발생하지 않은 경우에는 값이 유지되었다. 하지만, BindingResult를 도입하니 오류가 발생한 어떤 경우에도 값이 유지되지 않는다.
BindingResult를 사용하지 않은 경우 Model에 담겨있는 item 객체에 접근하겠지만, BindingResult를 사용한 경우 다르게 작동해서 그렇다. 이 동작 방식에 대해 자세히 알아보자.


FieldError, ObjectError - RejectedValue

// 검증 로직
// V2 추가: Rejected Value 를 필드 에러에 넣어준다.
if (!StringUtils.hasText(item.getItemName())) {
    bindingResult.addError(new FieldError("item", "itemName", item.getItemName(), false, null, 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));
    }
}

FieldError 생성자

FieldError는 두 가지 생성자를 제공한다.


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

V1에서 쓴 건 위에 꺼고, 이번에는 두 번째 생성자를 사용한 것이다.


파라미터 목록

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

필드 에러를 사용하는 경우 rejectedValue 를 사용해야 잘못 입력한 값이 유지된다. V1에서 잘못 입력한 값이 유지되지 않은 이유도, 타임리프가 BindingResult를 사용하면 item 객체가 아닌 rejectedValue 에서 값을 꺼내다 쓰기 때문이다.


타입 오류로 바인딩에 실패하면 스프링은 FieldError를 생성하면서 사용자가 입력한 값을 넣어둔다. 그리고 해당 오류를 BindingResult에 담아서 컨트롤러를 호출한다. 따라서 타입 오류 같은 바인딩 실패 시에도 사용자의 오류 메시지를 정상 출력할 수 있다.


bindingFailure는 타입 오류 같은 바인딩이 실패했는지 여부를 적어주면 된다. 여기서는 바인딩이 실패한 것은 아니기 때문에 false를 사용한다.


나머지 파라미터는 메시지 처리를 위해 사용된다.


오류 코드와 메시지 처리

FieldError, ObjectError의 생성자는 codes, arguments를 제공한다. 이것은 오류 발생 시 오류 코드로 메시지를 찾기 위해 사용된다.


오류 메시지를 구분하기 쉽게 errors.properties라는 별도의 파일로 관리해보자. 먼저 스프링 부트가 해당 메시지 파일을 인식할 수 있게 다음 설정을 추가해야 한다. (참고로 오류 메시지도 국제화 처리를 할 수 있다.)


application.properties

spring.messages.basename=messages,errors

errors.properties

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

이제 errors에 등록한 메시지를 사용하도록 코드를 변경해보자.


addItemV3()

// V3 추가: 메시지 처리
// V3 추가(번외): code 스트링이 하나 이상이 들어갈 수 있는 이유는 앞에 메시지가 properties 에 없으면 그 다음꺼를 찾는다. 
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));
}
  • codes : required.item.itemName을 사용해서 메시지 코드를 지정한다. 메시지 코드는 하나가 아니라 배열로 여러 값을 전달할 수 있는데, 순서대로 매칭해서 처음 매칭되는 메시지가 사용된다.
  • arguments : Object[]{1000, 1000000}을 사용해서 코드의 {0}, {1}로 치환할 값을 전달한다.

//특정 필드가 아닌 복합 룰 검증
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));
    }
}

오류 코드 개선

컨트롤러에서 BindingResult는 검증해야 할 객체인 target 바로 다음에 온다. 따라서 BindingResult는 이미 본인이 검증해야 할 객체인 target을 알고 있다.


public String addItemV4(@ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model) {
    // v4: binding result 는 파라미터에서 Object 다음에 온다. 따라서 object 가 뭔지 안다.
    log.info("objectName={}", bindingResult.getObjectName());
    log.info("target={}", bindingResult.getTarget());

스프링은 이 점을 이용해서 오류 코드를 개선한 기능을 제공한다.


rejectValue() , reject()

BindingResult가 제공하는 rejectValue(), reject()를 사용하면 FieldError, ObjectError를 직접 생성하지 않고, 깔끔하게 검증 오류를 다룰 수 있다.

// v4: binding result 는 파라미터에서 Object 다음에 온다. 따라서 object 가 뭔지 안다. 그렇기에, "에러코드.오브젝트.필드명"에서 오브젝트를 알아서 찾아서 가져올 수 있다.
// 여기서 에러코드는 메시지의 errorcode와 다르다. 메시지 리졸버를 위한 에러코드임.
if (!StringUtils.hasText(item.getItemName())) {
    bindingResult.rejectValue("itemName", "required"); // 필드명, 에러코드(messages.properties 의 에러코드와는 다른 거임. 주의!)
}

오류 메시지가 정상 출력된다. 그런데 errors.properties에 있는 코드를 직접 입력하지 않았는데 어떻게 된 것일까? 뭔가 "에러코드.오브젝트.필드명" 에서 오브젝트는 아니까 나머지 정보를 가지고 조합해서 쓰는 것 같다는 느낌은 든다.


rejectValue()

void rejectValue(@Nullable String field, String errorCode, @Nullable Object[] errorArgs, @Nullable String defaultMessage);

  • field : 오류 필드명
  • errorCode : 오류 코드 (이 오류 코드는 메시지에 등록된 코드가 아니다. 뒤에서 설명할 messageResolver를 위한 오류 코드이다.)
  • errorArgs : 오류 메시지에서 {0}을 치환하기 위한 값
  • defaultMessage : 오류 메시지를 찾을 수 없을 때 사용하는 기본 메시지

bindingResult.rejectValue("price", "range", new Object[]{1000, 1000000}, null)

앞에서 BindingResult는 어떤 객체를 대상으로 검증하는지 target을 이미 알고 있다고 했다. 따라서 target (item)에 대한 정보는 없어도 된다. 오류 필드명은 동일하게 price를 사용했다.


reject()

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

rejectValue()에서 “field” 매개변수만 빠진 거 말고는 동일하다


rejectValue(), reject()의 구체적인 동작을 이해하기 위해선 MessageCodesResolver를 이해해야 한다


MessageCodesResolver

오류 코드를 만들 때 자세히 만들 수도 있고(ex. 상품 이름은 필수입니다), 단순하고 범용적으로 만들 수도 있다(ex. 필수입니다.) 가장 좋은 방법은 범용성으로 사용하다가, 세밀하게 작성해야 하는 경우에는 세밀한 내용이 적용되도록 메시지에 단계를 두는 방법이다.


MessageCodesResolver는 오류 메시지에 required.item.itemName과 같이 객체명과 필드명을 조합한 세밀한 메시지 코드가 있으면 이 메시지를 높은 우선순위로 사용하도록 한다.


마치 V3에서 bindingReuslt.addError에서 오류 코드를 찾을 때 new String[]{“required.item.itemName”, “required”};처럼 동작하고, 우선순위를 MessageCodesResolver가 정해준다.


#Level1
required.item.itemName=상품 이름은 필수입니다.
#Level2
required=필수 값 입니다.

codesResolver.resolveMessageCodes("오류코드", "객체", "필드(option)") 를 통해 직접 메시지 코드 순서를 체크해볼 수 있다.


MessageCodesResolver의 구체적인 동작 원리를 이제는 어느정도 추론할 수 있을 것이다.
사실은 bindingResult.rejectValue(“itemName”, “required”)는 내부적으로
new FieldError()를 만들고, FieldError에 넘길 Object는 item이라는 걸 알고 있으니 넘겨주고, rejectValue에서 지정한 itemName을 넣어주고, rejectValue 필요 하면 넣어주고, bindingFailure이면 넣어주고, 그 다음 String[] codes를 넣어줘야 하는데, 이 때 codesResolver.resolveMesageCodes("에러코드", "오브젝트이름")으로 가져온 메시지 코드 배열을 넣어준다.
codesResolver.resolveMesageCodes("에러코드", "오브젝트이름")는 구체적인 것이 맨 앞에 있다. 따라서, 구체적인 것부터 찾게 된다.


DefaultMessageCodesResolver의 기본 메시지 생성 규칙

MessageCodesResolver 인터페이스이고 DefaultMessageCodesResolver는 기본 구현체이다.




객체 오류

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

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

필드 오류

필드 오류의 경우 다음 순서로 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

FieldError rejectValue("itemName", "required")


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

  1. required.item.itemName
  2. required.itemName
  3. required.java.lang.String
  4. required

ObjectError reject("totalPriceMin")


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

  • totalPriceMin.item
  • totalPriceMin

타임리프 오류 메시지 출력
타임리프 화면을 렌더링할 때 th:errors가 실행된다. 만약 이때 오류가 있다면 생성된 오류 메시지 코드를 순서대로 돌아가면서 메시지를 찾는다. 그리고 없으면 디폴트 메시지를 출력한다.


참고 | ValidationUtils

BindingResult.reject 대신 다음과 같이 한 줄로 가능, 제공하는 기능은 Empty, 공백 같은 단순한 기능만 제공: ValidationUtils.rejectIfEmptyOrWhitespace(bindingResult, "itemName", "required");


Validator 분리

현재 컨트롤러에서 검증 로직이 차지하는 부분은 매우 크다. 검증 로직을 별도의 클래스에서 처리하도록 개선하자.


ItemValidator 만들기

@Component
public class ItemValidator implements Validator {
    
    @Override
    public boolean supports(Class<?> clazz) {
        return Item.class.isAssignableFrom(clazz);
        // item == clazz
        // item == subItem
    }

    @Override
    public void validate(Object target, Errors errors) {
        Item item = (Item) target;
        
        if (!StringUtils.hasText(item.getItemName())) {
            errors.rejectValue("itemName", "required"); // 필드명, 에러코드(messages.properties 의 에러코드와는 다른 거임. 주의!)
        }
        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);
            }
        }
    }
}

스프링은 검증을 체계적으로 제공하기 위해 다음 인터페이스를 제공한다.

public interface Validator {
 boolean supports(Class<?> clazz);
 void validate(Object target, Errors errors);
}

  • supports() {} : 해당 검증기를 지원하는 여부 확인 (뒤에서 설명)
  • validate(Object target, Errors errors) : 검증 대상 객체와 BindingResult

컨트롤러에 Validator의 validate를 호출하도록 코드를 추가하고, 검증 로직을 빼자.
이를 위해서는 자바 빈으로 등록하기 위해 ItemValidator에 @Component를 달고, Controller에서 의존관계 주입을 받도록 하자.


ValidationItemControllerV2

// ...
@RequiredArgsConstructor
public class ValidationItemControllerV2 {

    private final ItemRepository itemRepository;
    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);
        // 모델에 담지 않아도 bindingResult 는 알아서 담긴다.
        return "validation/v2/addForm";
    }

	// 성공 시..

그런데 ItemValidator를 보면 스프링이 인터페이스로 지원하는 Validator의 기능은 사용하지 않고 단순히 검증 로직만 사용하고 있다. 그냥 별도의 순수 자바 클래스를 쓰는게 아니라 왜 Spring의 Validator를 implement 해야할까?


Spring Validator

Validator 인터페이스를 사용해서 검증기를 만들면 스프링의 추가적인 도움을 받을 수 있다.


WebDataBinder를 통해서 사용하기

WebDataBinder는 스프링의 파라미터 바인딩의 역할을 해주고 검증 기능도 내부에 포함한다.


ValidationItemControllerV2에 다음 코드를 추가하자.

@InitBinder
public void init(WebDataBinder dataBinder) {
 log.info("init binder {}", dataBinder);
 dataBinder.addValidators(itemValidator);
}

이렇게 WebDataBinder에 검증기를 추가하면 해당 컨트롤러에서는 검증기를 자동으로 적용할 수 있다.
@InitBinder는 해당 컨트롤러에만 영향을 준다. 글로벌 설정은 별도로 해야 한다. (마지막에 설명)


@PostMapping("/add")
public String addItemV6(@Validated @ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model) {
    
    //검증에 실패하면 다시 입력 폼으로
    if (bindingResult.hasErrors()) {
        log.info("errors = {}", bindingResult);
        // 모델에 담지 않아도 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를 직접 호출하는 부분이 사라지고, 대신에 검증 대상 앞에 @Validated가 붙었다.


동작 방식
@Validated는 검증기를 실행하라는 애노테이션이다. 이 애노테이션이 붙으면 앞서 WebDataBinder에 등록한 검증기를 찾아서 실행한다. 그런데 여러 검증기를 등록하면 그 중에 어떤 검증기가 실행되어야 할지 구분이 필요하다(item, user, …). 이때 supports()가 사용된다. 여기서는 supports(Item.class) 호출되고, 결과가 true이므로 ItemValidatorvalidate()가 호출된다. (마치 핸들러 어댑터가 동작하는 것과 유사하다~)


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

스프링 MVC는 WebDataBinder가 바인딩하고 검증하는 역할을 해주는데, 요청이 올 때 마다 새로운 게 만들어진다. 새로운 게 만들어질 때 만약 글로벌 설정이 되어 있다면, 글로벌 설정해서 해놓은 validator가 들어간다.
글로벌 설정이 아니면 컨트롤러에서 설정해놓은게 들어간다. 크게 중요한 내용은 아니다.


참고

  • 검증 시 @Validated, @Valid 둘 다 사용 가능하다.
  • javax.validation.@Valid를 사용하려면 build.gradle 의존관계 추가가 필요하다.
    • implementation 'org.springframework.boot:spring-boot-starter-validation'
  • @Validated는 스프링 전용 검증 애노테이션이고, @Valid는 자바 표준 검증 애노테이션이다.

Ref) 스프링 MVC 2편 - 백엔드 웹 개발 활용 기술