Java Category/Spring

[Spring MVC] 검증(Validation) - 오류 코드와 메시지 처리

ReBugs 2024. 3. 12.

이 글은 인프런 김영한님의 Spring 강의를 바탕으로 개인적인 정리를 위해 작성한 글입니다.


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

 

FieldError , ObjectError 의 생성자는 codes , arguments 를 제공한다.

이것은 오류 발생시 오류 코드로 메시지를 찾기 위해 사용된다.

 

errors 메시지 파일 생성을 위해 messages.properties를 사용해도 되지만, 오류 메시지를 구분하기 쉽게 errors.properties라는 별도의 파일로 관리하는 방법이 있다.

 

먼저 스프링 부트가 해당 메시지 파일을 인식할 수 있게 다음 설정을 추가한다. 이렇게 하면 messages.properties, errors.properties 두 파일을 모두 인식한다. 생략하면 messages.properties를 기본으로 인식한다.

application.properties

spring.messages.basename=messages,errors

 

errors.properties 추가

src/main/resources/errors.properties

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

errors_en.properties 파일을 생성하면 오류 메시지도 국제화 처리를 할 수 있다. 

 

컨트롤러

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

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

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

    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() >= 10000) {
        bindingResult.addError(new FieldError("item", "quantity", item.getQuantity(), false, new String[]{"max.item.quantity"}, new Object[]{9999}, null));
    }

    //특정 필드 예외가 아닌 전체 예외
    //public ObjectError(String objectName, String defaultMessage)
    //objectName : @ModelAttribute 의 이름
    //defaultMessage : 오류 기본 메시지
    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}";
}

 

rejectValue(), reject()

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

rejectValue()

void rejectValue(@Nullable String field, String errorCode, @Nullable Object[] errorArgs, @Nullable String defaultMessage);
  • field : 오류 필드명
  • errorCode : 오류 코드(이 오류 코드는 메시지에 등록된 코드가 아님 messageResolver를 위한 오류 코드)
  • errorArgs : 오류 메시지에서 {0} 을 치환하기 위한 값
  • defaultMessage : 오류 메시지를 찾을 수 없을 때 사용하는 기본 메시지

rejectValue() 메서드는 특정 필드에 대한 검증 실패를 나타내는 오류를 등록할 때 사용된다. 이 메서드는 필드 수준의 검증에서, 필드 값이 특정 조건을 만족하지 않을 경우 오류를 등록하는 데 적합하다.

 

reject()

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

reject() 메서드는 객체 수준의 검증 실패를 나타내는 오류를 등록할 때 사용된다. 이는 특정 필드에 국한되지 않고, 전체 객체 또는 복합적인 조건에 대한 검증에서 사용된다.

 

BindingResult 는 어떤 객체를 대상으로 검증하는지 target을 이미 알고 있다. 따라서 target(item)에 대한 정보는 없어도 된다.
rejectValue() 를 사용하고 부터는 오류 코드를 간단하게 입력할 수 있다.

이 부분을 이해하려면 MessageCodesResolver 를 이해해야 한다.

컨트롤러

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

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

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

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

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

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

        ValidationUtils.rejectIfEmptyOrWhitespace(bindingResult, "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() >= 10000) {
            bindingResult.rejectValue("quantity", "max", new Object[]{9999}, null);
        }

        //특정 필드 예외가 아닌 전체 예외
        //void reject(String errorCode, @Nullable Object[] errorArgs, @Nullable String defaultMessage);

        //errorCode : 오류 코드(이 오류 코드는 메시지에 등록된 코드 아님. messageResolver를 위한 오류 코드)
        //errorArgs : 오류 메시지에서 {0} 을 치환하기 위한 값
        //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);
            }
        }

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

 

ValidationUtils

ValidationUtils 사용전

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

 

ValidationUtils 사용후

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

특정 필드 값이 비어 있거나 공백으로만 구성되어 있는지를 검증하고, 그러한 경우에 오류를 BindingResult에 추가하는 역할을 한다.

이 메서드를 사용하면 복잡한 조건 없이 간단하게 필드의 빈 값 여부를 검증할 수 있으며, 검증 로직을 보다 깔끔하게 유지할 수 있다.

 

MessageCodesResolver

MessageCodesResolver는 스프링 프레임워크에서 검증 오류 메시지의 코드를 생성하는 전략을 정의하는 인터페이스이다. 이 인터페이스는 검증 과정에서 발생한 오류에 대해 메시지 코드를 해석하고 생성하는 방법을 결정한다. 주로 BindingResult의 구현체에서 내부적으로 사용되며, 오류 메시지를 국제화하거나 세분화하는 데 필요한 메시지 코드를 생성하는 역할을 한다.

 

동작 방식

MessageCodesResolver는 주로 rejectValue() 또는 reject() 메서드를 통해 검증 오류가 등록될 때 사용된다.

  • rejectValue(), reject() 는 내부에서 MessageCodesResolver 를 사용한다. 여기에서 메시지 코드들을 생성한다.
  • FieldError, ObjectError 의 생성자를 보면, 오류 코드를 하나가 아니라 여러 오류 코드를 가질 수 있다. 
  • MessageCodesResolver 를 통해서 생성된 순서대로 오류 코드를 보관한다.

이러한 메서드는 오류를 등록하는 동안 MessageCodesResolver를 사용하여 오류 코드에 해당하는 메시지 코드를 생성한다. 생성된 메시지 코드는 다음과 같은 용도로 사용될 수 있다

  • 오류 메시지의 국제화: 다양한 언어로 오류 메시지를 제공할 수 있게 함으로써, 애플리케이션의 다국어 지원을 강화한다.
  • 세분화된 오류 메시지 제공: 상황에 따라 다른 오류 메시지를 표시함으로써 사용자에게 더 구체적인 피드백을 제공한다.

 

구현체

스프링 프레임워크는 DefaultMessageCodesResolver라는 기본 구현체를 제공한다.

이 구현체는 오류 코드, 객체 이름, 필드 이름 등을 기반으로 메시지 코드를 생성하는 전략을 정의한다. 예를 들어, 필드 검증 오류에 대한 메시지 코드는 다음과 같은 순서로 생성될 수 있다

 

DefaultMessageCodesResolver의 기본 메시지 생성 규칙

객체 오류

객체 오류의 경우 다음 순서로 2가지 생성

  1. code + "." + object name
  2. code

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

  1. required.item
  2. required

ObjectError reject("totalPriceMin") → 다음 2가지 오류 코드를 자동으로 생성

  1. totalPriceMin.item
  2. totalPriceMin

 

필드 오류

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

 

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

 

예제코드

package hello.itemservice.validation;

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

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

public class MessageCodesResolverTest {
    MessageCodesResolver codesResolver = new DefaultMessageCodesResolver();

    //String[] resolveMessageCodes(String errorCode, String objectName);

    //errorCode: 검증 과정에서 발견된 오류의 코드
    //objectName: 검증 대상 객체의 이름
    @Test
    void messageCodesResolverObject() {
        String[] messageCodes = codesResolver.resolveMessageCodes("required", "item");
        assertThat(messageCodes).containsExactly("required.item", "required");
    }

    //String[] resolveMessageCodes(String errorCode, String objectName, String field, Class<?> fieldType);
    
    //errorCode: 검증 과정에서 발견된 오류의 코드.
    //objectName: 검증 대상 객체의 이름.
    //field: 검증 대상 필드의 이름, 필드 수준의 오류 코드 생성에 사용된다.
    //fieldType: 검증 대상 필드의 타입, 필드 타입을 기반으로 메시지 코드를 생성하는 데 사용될 수 있다.
    @Test
    void messageCodesResolverField() {
        String[] messageCodes = codesResolver.resolveMessageCodes("required", "item", "itemName", String.class);
        assertThat(messageCodes).containsExactly(
                "required.item.itemName",
                "required.itemName",
                "required.java.lang.String",
                "required"
        );
    }
}

 

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

검증 오류 코드는 다음과 같이 2가지로 나눌 수 있다.

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

 

예를 들어, itme 객체에 price 필드에 int 가 입력되야하는데 String 타입 자료형이 입력되었을 때, 아래와 같은 메시지 코드들이 생성된 것을 확인 할 수 있다.

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

스프링은 타입 오류가 발생하면 typeMismatch 라는 오류 코드를 사용한다. 이 오류 코드가 MessageCodesResolver 를 통하면서 4가지 메시지 코드가 생성된 것이다.

 

errors.properties 에 메시지 코드가 없기 때문에 스프링이 생성한 기본 메시지가 출력된다.

error.properties 에 다음 내용을 추가하자

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

 

핵심은 구체적인 것에서! 덜 구체적인 것으로!
MessageCodesResolver 는 required.item.itemName 처럼 구체적인 것을 먼저 만들어주고, required 처 럼 덜 구체적인 것을 가장 나중에 만든다.
이렇게 하면 메시지와 관련된 공통 전략을 편리하게 도입할 수 있다.

왜 이렇게 복잡하게 사용하는가?
모든 오류 코드에 대해서 메시지를 각각 다 정의하면 개발자 입장에서 관리하기 너무 힘들다.
크게 중요하지 않은 메시지는 범용성 있는 requried 같은 메시지로 끝내고, 정말 중요한 메시지는 꼭 필요할 때 구체적으로 적어서 사용하는 방식이 더 효과적이다.

댓글