Link Search Menu Expand Document

검증1 - Validation

검증 직접 처리

검증 직접 처리 - 소개 - 01

  1. 사용자가 상품 등록 폼에서 정상 범위의 데이터 입력
  2. 서버에서 검증 로직 통과하고 상품 저장
  3. 상품 상세 화면으로 리다이렉트

검증 직접 처리 - 소개 - 02

  1. 사용자가 검증 범위를 넘어서는 데이터 입력
  2. 서버 검증 로직 실패
  3. 사용자에게 상품 등록 폼을 다시 보여주고 어떤 값을 잘못 입력했는지 알림

상품 등록 검증 Spring

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

상품 등록 검증 HTML

글로벌 오류 메시지

<div th:if="${errors?.containsKey('globalError')}">
  <p class="field-error" th:text="${errors['globalError']}">전체 오류 메시지</p>
</div>
  • 타임리프의 th:if 를 사용하면 조건에 만족할 때만 해당 HTML 태그를 출력

참고 Safe Navigation Operator errors?.errorsnull 일때 NullPointerException 이 발생하는 대신, null 을 반환하는 문법 th:if 에서 null 은 실패로 처리되므로 오류 메시지가 출력되지 않음 참고: https://docs.spring.io/spring-framework/docs/current/reference/html/core.html#expressions-operator-safe-navigation

필드 오류 처리

<input type="text" th:classappend="${errors?.containsKey('itemName')} ? 'fielderror' : _" class="form-control">
  • classappend: 해당 필드에 오류가 있으면 field-error라는 클래스 정보를 더함
  • 값이 없으면 _(No-Operation)을 사용하여 아무 일도 하지 않음

직접 처리 문제점

  • 뷰 템플릿에서 중복된 코드가 많음
  • 타입 오류 처리 불가능
    • Itemprice, quantity 같은 숫자 필드는 타입이 Integer 이므로 문자 타입으로 설정하는 것이 불가능
    • 숫자 타입에 문자가 들어오면 오류가 발생하나 이러한 오류는 스프링MVC에서 컨트롤러에 진입하기도 전에 예외가 발생하기 때문에, 컨트롤러가 호출되지도 않고, 400 예외가 발생
  • Itemprice에 문자를 입력하는 것처럼 타입 오류가 발생해도 고객이 입력한 문자를 화면에 남겨야 함
    • 만약 컨트롤러가 호출된다고 가정해도 ItempriceInteger이므로 문자를 보관할 수가 없음
    • 결국 문자는 바인딩이 불가능하므로 고객이 입력한 문자가 사라지게 되고, 고객은 본인이 어떤 내용을 입력해서 오류가 발생했는지 이해하기 어려움

BindingResult

필드 오류 - FieldError

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

FieldError 생성자 요약

public FieldError(String objectName, String field, String defaultMessage) {}
  • objectName: @ModelAttribute 이름
  • field: 오류가 발생한 필드 이름
  • defaultMessage: 오류 기본 메시지

글로벌 오류 - ObjectError

bindingResult.addError(new ObjectError("item", "가격 * 수량의 합은 10,000원 이상이어야 합니다. 현재 값 = " + resultPrice));

ObjectError 생성자 요약

public ObjectError(String objectName, String defaultMessage) {}
  • objectName: @ModelAttribute 이름
  • defaultMessage: 오류 기본 메시지

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

  • #fields: #fieldsBindingResult가 제공하는 검증 오류에 접근
  • th:errors: 해당 필드에 오류가 있는 경우에 태그를 출력
  • th:errorclass: th:field에서 지정한 필드에 오류가 있으면 class 정보를 추가
  • 검증과 오류 메시지 공식 메뉴얼: https://www.thymeleaf.org/doc/tutorials/3.0/thymeleafspring.html#validation-anderror-messages

글로벌 오류 처리

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

필드 오류 처리

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

BindingResult

  • 스프링이 제공하는 검증 오류를 보관하는 객체
  • BindingResult 가 있으면 @ModelAttribute 에 데이터 바인딩 시 오류가 발생해도 컨트롤러 호출
  • 예) @ModelAttribute에 바인딩 시 타입 오류가 발생하면?
    • BindingResult 가 없으면 400 오류가 발생하면서 컨트롤러가 호출되지 않고, 오류 페이지로 이동
    • BindingResult 가 있으면 오류 정보( FieldError )를 BindingResult에 담아서 컨트롤러를 정상 호출
  • BindingResult 는 검증할 대상 바로 다음에 와야한다. 순서가 중요하다. 예를 들어서 @ModelAttribute Item item , 바로 다음에 BindingResult가 와야 함
  • BindingResultModel에 자동으로 포함됨

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

  • @ModelAttribute의 객체에 타입 오류 등으로 바인딩이 실패하는 경우 스프링이 FieldError 생성해서 BindingResult 에 포함
  • 개발자가 직접 적용
  • Validator 사용

BindingResult와 Errors

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

FieldError, ObjectError

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)

파라미터 목록

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

ObjectError도 유사하게 두 가지 생성자를 제공

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

new FieldError("item", "price", item.getPrice(), false, null, null, "가격은 1,000 ~ 1,000,000 까지 허용합니다.")

  • 사용자의 입력 데이터가 컨트롤러의 @ModelAttribute 에 바인딩되는 시점에 오류가 발생하면 모델 객체에 사용자 입력 값을 유지하기 어려움
  • 예를 들어서 가격에 숫자가 아닌 문자가 입력된다면 가격은 Integer 타입이므로 문자를 보관할 수 있는 방법이 없음
  • 오류가 발생한 경우 사용자 입력 값을 보관하는 별도의 방법이 필요
  • FieldError는 오류 발생시 사용자 입력 값을 저장하는 기능을 제공

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

th:field="*{price}" 타임리프의 th:field는 매우 똑똑하게 동작하는데, 정상 상황에는 모델 객체의 값을 사용하지만, 오류가 발생하면 FieldError 에서 보관한 값을 사용해서 값을 출력

스프링의 바인딩 오류 처리

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

오류 코드와 메시지 처리

  • FieldError, ObjectError의 생성자는 errorCode, arguments를 제공
  • 오류 발생시 오류 코드로 메시지를 찾기 위해 사용

errors 메시지 파일 생성

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

application.properties

spring.messages.basename=messages,errors

errors.properties

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

참고: errors_en.properties 파일을 생성하면 오류 메시지도 국제화 처리 가능

//range.item.price=가격은 {0} ~ {1} 까지 허용합니다.
new FieldError("item", "price", item.getPrice(), false, new String[]
{"range.item.price"}, new Object[]{1000, 1000000}
  • codes : required.item.itemName를 사용해서 메시지 코드를 지정
    • 메시지 코드는 하나가 아니라 배열로 여러 값을 전달할 수 있는데, 순서대로 매칭해서 처음 매칭되는 메시지가 사용됨
  • arguments: Object[]{1000, 1000000} 를 사용해서 코드의 {0}, {1} 로 치환할 값을 전달

rejectValue(), reject()

  • BindingResult가 제공하는 rejectValue(), reject()를 사용하면 FieldError, ObjectError를 직접 생성하지 않아도 됨

rejectValue()

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

reject()

void reject(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에 대한 정보는 없어도 됨

MessageCodesResolver

  • 검증 오류 코드로 메시지 코드들을 생성
  • MessageCodesResolver는 인터페이스이고 DefaultMessageCodesResolver가 기본 구현체
  • 주로 ObjectError, FieldError와 함께 사용

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"

동작 방식

  • rejectValue(), reject() 는 내부에서 MessageCodesResolver로 메시지 코드들을 생성
  • MessageCodesResolver를 통해서 생성된 순서대로 오류 코드를 보관

오류 메시지 출력

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

오류 코드 관리 전략

  • MessageCodesResolverrequired.item.itemName처럼 구체적인 것을 먼저 만들어주고, required처럼 덜 구체적인 것을 가장 나중에 생성
  • 크게 중요하지 않은 메시지는 범용성 있는 requried 같은 메시지로 끝내고, 정말 중요한 메시지는 꼭 필요할 때 구체적으로 적어서 사용하는 방식이 더 효과적

ValidationUtils

ValidationUtils.rejectIfEmptyOrWhitespace(bindingResult, "itemName", "required");
  • 조건문 대신 사용할 수 있음
  • Empty, 공백 같은 단순한 기능만 제공

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

  • 검증 오류 코드는 다음과 같이 2가지로 나눌 수 있음
    1. 개발자가 직접 설정한 오류 코드 → rejectValue()를 직접 호출
    2. 스프링이 직접 검증 오류에 추가한 경우(주로 타입 정보가 맞지 않음)
  • 스프링은 타입 오류가 발생하면 typeMismatch라는 오류 코드를 사용
  • error.properties에 아래와 같은 내용을 추가하면 소스 코드를 수정하지 않고 메시지 처리가 가능
typeMismatch.java.lang.Integer=숫자를 입력해주세요.
typeMismatch=타입 오류입니다.

Validator 분리

Validator 인터페이스

public interface Validator {
    boolean supports(Class<?> clazz);
    void validate(Object target, Errors errors);
}
  • supports(): 해당 검증기를 지원하는 여부 확인
  • validate(Object target, Errors errors): 검증 대상 객체와 BindingResult

WebDataBinder

@InitBinder
public void init(WebDataBinder dataBinder) {
    log.info("init binder {}", dataBinder);
    dataBinder.addValidators(itemValidator);
}
  • WebDataBinder는 스프링의 파라미터 바인딩의 역할을 해주고 검증 기능도 내부에 포함
  • 이렇게 WebDataBinder에 검증기를 추가하면 해당 컨트롤러에서는 검증기를 자동으로 적용
  • @InitBinder: 해당 컨트롤러에만 영향. 글로벌 설정은 별도

@Validated

  • 이 애노테이션이 붙으면 앞서 WebDataBinder에 등록한 검증기를 찾아서 실행
  • 그런데 여러 검증기를 등록한다면 각 검증기의 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) {...}
}

글로벌 설정

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

주의

  • 글로벌 설정을 하면 다음에 설명할 BeanValidator가 자동 등록되지 않음
  • 참고로 글로벌 설정을 직접 사용하는 경우는 드묾