오류 코드와 메시지 처리

FieldError 생성자

  • objectName

    • 오류가 발생한 객체 이름

  • field

    • 오류가 발생한 필드

  • rejectedValue

    • 사용자가 입력한 거절된 값

  • bindingFailure

    • 타입 오류 등의 바인딩 실패인지, 검증 실패인지 구분하는 값

  • codes

    • 메시지 코드

  • arguments

    • 메시지에서 사용하는 인자

  • defaultMessage

    • 기본 오류 메시지

이 중에서 codes와 argument을 이용해 에러 메시지를 작성해보자.

# messages와 errors 두 곳 모두 찾아 쓰도록 설정한다.
# 생략하면 기본값인 messages만 읽어들인다.
spring.messages.basename=messages,errors

기존 application.properties에 설정을 추가한다. 컨트롤러에는 이제 defaultMessage 파라미터를 넘기는 대신 code와 argument 파라미터에 데이터를 넘기면 errors.properties와 매핑된다.

  • codes

    • 메시지 코드를 지정한다.

    • 배열이 들어가는 이유는 해당 키로 errors.properties에서 값을 찾지 못했을 때 다음 인덱스의 값으로 찾을 수 있기 때문이다.

  • arguments

    • properties의 {0}, {1}을 치환한다.

rejectValue(), reject()

@Slf4j
@Controller
@RequestMapping("/validation/v2/items")
@RequiredArgsConstructor
public class ValidationItemControllerV2 {
    @PostMapping("/add")
    public String addItemV3(@ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes) {
    ...
    }
}

BindingResult는 항상 @ModelAttrubute 뒤에 온다. 사실 BindingResult는 이미 자기가 어떤 객체를 검증할지 알고 있다.

log.info("objectName={}",bindingResult.getObjectName());
        log.info("target={}",bindingResult.getTarget());
objectName=item // @ModelAttribute name
target=Item(id=null, itemName=상품, price=100, quantity=1234)

실제로 찍어보면 이렇게 된다.

@Slf4j
@Controller
@RequestMapping("/validation/v2/items")
@RequiredArgsConstructor
public class ValidationItemControllerV2 {

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

        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() > 10000) {
            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);
            }
        }

      ...
    }
}

이미 알고 있기 때문에 FeldError로 일일이 오브젝트 정보를 넣어주는 대신 rejectValue()와 reject()를 넣어준다.

rejectValue()

  • field

    • 오류 필드명

  • errorCode

    • 오류 코드

    • FieldError와 다르게 앞글자만 입력해도 동작한다.

      • 메시지에 등록된 코드가 아니라 뒤에서 설명할 messageResolver를 위한 오류 코드다.

  • errorArgs

    • 오류 메시지에서 {0}을 치환한다.

  • defaultMessage

    • 오류 메시지를 찾을 수 없을 때 사용한다.

범용성 있는 오류 메시지 관리

  • required.item.itemName: 상품 이름은 필수입니다.

    • 너무 자세하게 만들면 범용성이 떨어진다.

  • required: 필수 값입니다.

    • 단순하게 만들면 여러 곳에서 사용할 수 있다.

    • 메시지를 세밀하게 작성하기 어렵다.

이런 문제는 범용적으로 사용하다가 세밀하게 작성하는 경우에는 세밀한 내용이 적용되도록 단계를 설정하는 방법으로 해결할 수 있다.

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

# Level2
required: 필수 값 입니다.

이렇게 상세한 메시지를 우선순위 높게 사용하는 것이다. 범용성 있게 잘 개발해두면 메시지를 추가해야할 때도 편리하게 관리할 수 있다.

MessageCodesResolver

  • 검증 오류 코드로 메시지 코드를 생성한다.

  • MessageCodesResolver

    • 인터페이스

  • DefaultMessageCodesResolver

    • 기본 구현체

  • ObjectError, FieldError와 함께 사용한다.

public class DefaultMessageCodesResolver implements MessageCodesResolver, Serializable {

    @Override
    public String[] resolveMessageCodes(String errorCode, String objectName) {
        return resolveMessageCodes(errorCode, objectName, "", null);
    }

    @Override
    public String[] resolveMessageCodes(String errorCode, String objectName, String field, @Nullable Class<?> fieldType) {
    ...
    }
}

MessageCodesResolver.resolveMessageCodes()는 errorCode objectName, field를 받는다.

public class MessageCodesResolverTest {

    MessageCodesResolver messageCodesResolver = new DefaultMessageCodesResolver();

    @Test
    void messageCodesResolverObject() {
        // 에러 코드를 넣으면 메시지 코드가 여러개 나온다.
        String[] messageCodes = messageCodesResolver.resolveMessageCodes("required", "item");

        for (String messageCode : messageCodes) {
            System.out.println("messageCode = " + messageCode);
        }
    }
}
messageCode = required.item
messageCode = required

실행해보면 errorCode와 objectName을 조합해 세세한 메시지 코드를 먼저 만들고 그 다음에 범용적인 코드를 만든다.

public class MessageCodesResolverTest {

    MessageCodesResolver messageCodesResolver = new DefaultMessageCodesResolver();

    @Test
    void messageCodesResolverField() {
        String[] messageCodes = messageCodesResolver.resolveMessageCodes("required", "item", "itemName", String.class);

        assertThat(messageCodes).containsExactly(
                "required.item.itemName",
                "required.itemName",
                "required.java.lang.String",
                "required"
        );
    }
}

field를 넣으면 그것까지 포함해서 만들어준다. required.java.lang.String는 타입도 거를 수 있다.

DefaultMessageCodesResolver의 기본 메시지 생성 규칙

객체 오류

  1. code + "." + object name

  2. code

errorCode: required, object name: item이라면

  1. required.item

  2. required

필드 오류

  1. code + "." + object name + "." + field

  2. code + "." + field

  3. code + "." + field type

  4. code

errorCode: 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를 통해 생성된 순서대로 오류 코드를 보관한다.

    • FieldError, ObjectError의 생성자를 보면 오류 코드는 여러 개를 가질 수 있다.

codes [range.item.price, range.price, range.java.lang.Integer, range]

BindingResult 로그를 통해서도 확인할 수 있다.

FieldError

rejectValue("itemName", "required")
  • required.item.itemName

  • required.itemName

  • required.java.lang.String

  • required

ObjectError

reject("totalPriceMin")
  • totalPriceMin.item

  • totalPriceMin

오류 코드 관리 전략

  • 구체적인 것에서 덜 구체적인 것으로

  • 모든 오류 코드에 대해 메시지를 각각 정의하면 관리가 힘들다.

  • 중요하지 않은 건 범용성 있는 메시지로, 중요한 메시지는 필요할 때 구체적으로 적는다.

## ObjectError
# Level 1
totalPriceMin.item=상품의 가격 * 수량의 합은 {0}원 이상이어야 합니다. 현재 값 = {1}

# Level 2 - 생략
totalPriceMin=전체 가격은 {0}원 이상이어야 합니다. 현재 값 = {1}

## FieldError
# Level 1
required.item.itemName=상품 이름은 필수입니다.
range.item.price=가격은 {0} ~ {1} 까지 허용합니다.
max.item.quantity=수량은 최대 {0} 까지 허용합니다.

# Level 2 - 생략

# Level 3
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} 까지의 숫자를 허용합니다.

# Level 4
required=필수 값 입니다.
min={0} 이상이어야 합니다.
range={0} ~ {1} 범위를 허용합니다.
max={0} 까지 허용합니다.

객체 오류와 필드 오류로 나누고 그 안에서 범용성에 따라 레벨을 나눈다.

  1. required.item.itemName

  2. required.itemName

  3. required.java.lang.String

  4. required

메시지 코드는 이렇게 생성된다. 메시지에 1번이 없으면 2번으로, 2번이 없으면 3번을 찾는다.

ValidationUtils

before

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

after

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

empty, white space 같은 간단한 기능은 한 줄로 줄일 수 있다.

정리

  1. rejectValue()를 호출한다.

  2. MessageCodesResolver로 검증 오류 코드를 이용해 메시지 코드를 생성한다.

  3. new FieldError()를 생성하면서 메시지 코드를 보관한다.

  4. th:errors에서 메시지 코드로 메시지를 순서대로 찾는다.

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

codes[typeMismatch.item.price,typeMismatch.price,typeMismatch.java.lang.Integer,typeMismatch]

price 필드에 문자를 넣으면 typeMismatch를 설정해준 적이 없는데 이렇게 찍힌다. 스프링은 타입 오류가 발생하면 직접 생성해준다.

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: "A"

하지만 메시지가 복잡하기 때문에 따로 설정해주는 것이 좋다.

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

소스 코드를 건들지 않고도 메시지를 단계별로 설정할 수 있다.

Last updated