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

Ch04. 검증(Validation) - 오류 코드와 메시지 처리

webmaster 2022. 3. 15. 12:08
728x90

FieldError, ObjectError의 codes, arguments 사용하기

  • errors.properties 생성하기
  • application.properties 에 해당 프로퍼티 파일을 메시지로 사용한다는 것을 적어준다.
#logging.level.org.apache.coyote.http11=debug
spring.messages.basename=messages, errors
  • errors.properties 에 오류 메시지 작성
required.item.itemName=상품 이름은 필수입니다.
range.item.price=가격은 {0} ~ {1} 까지 허용합니다.
max.item.quantity=수량은 최대 {0} 까지 허용합니다.
totalPriceMin=가격 * 수량의 합은 {0}원 이상이어야 합니다. 현재 값 = {1}
  • addItemV3
@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("bindingResult = {}", bindingResult);
        //model.addAttribute("errors", bindingResult); //자동으로 뷰에 넘어가기 때문에 모델에 담을 필요 x
        return "validation/v2/addForm";
    }

    //성공 로직
    Item savedItem = itemRepository.save(item);
    redirectAttributes.addAttribute("itemId", savedItem.getId());
    redirectAttributes.addAttribute("status", true);
    return "redirect:/validation/v2/items/{itemId}";
}
  • FieldError와 ObjectError의 Codes, Arguments 을 전달하는 부분을 활용하여 메시지를 생성할 수 있다.
  • codes
    • required.item.itemName 를 사용해서 메시지 코드를 지정한다.
    • 메시지 코드는 하나가 아니라 배열로 여러 값을 전달할 수 있는데, 순서대로 매칭 해서 처음 매칭 되는 메시지가 사용된다.
  • arguments 
    • Object[]{1000, 1000000}를 사용해서 코드의 {0} , {1}로 치환할 값을 전달한다.

RejectValue, Reject 활용

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

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

    //검증 로직
    if (!StringUtils.hasText(item.getItemName())) {
        //objectName 같은 경우 bindingResult 가 이미 알고있어서 쓰지 않아도 된다.
        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("bindingResult = {}", bindingResult);
        //model.addAttribute("errors", bindingResult); //자동으로 뷰에 넘어가기 때문에 모델에 담을 필요 x
        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 는 검증해야 할 객체인 target 바로 다음에 온다.
    • 따라서 BindingResult 는 이미 본인이 검증해야 할 객체인 target을 알고 있다
  • 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 : 오류 메시지를 찾을 수 없을 때 사용하는 기본 메시지
  • 축약된 오류 코드
    • 오류코드를 축약해서 range 와 같이 써도 동일하게 동작한다.
    • 뭔가 규칙이 있게 동작한다(코드.object(@ModelAttribute).필드명)
    • 해당 부분을 이해하기 위해서는 MessageCodesResolver를 이해해야 한다(뒤에 자세히 알아보자)

메시지의 우선순위

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

#Level2
required= 필수 값 입니다.
range= 범위는 {0} ~ {1} 까지 허용합니다.
max= 최대 {0}까지 허용합니다.
  • 범용적인 에러 메시지와, 상세한 오류 메시지를 나누어서 개발해야 한다.
    • 단순하게 만들면 범용성이 좋아서 여러 곳에서 사용할 수 있지만, 메시지를 세밀하게 작성하기 어렵다. 반대로 너무 자세하게 만들면 범용성이 떨어진다. 가장 좋은 방법은 범용성으로 사용하다가, 세밀하게 작성해야 하는 경우에는 세밀한 내용이 적용되도록 메시지에 단계를 두는 방법이다.
  • 이렇게 객체명과 필드명을 조합한 메시지가 있는지 우선 확인하고, 없으면 좀 더 범용적인 메시지를 선택하도록 추가 개발을 해야겠지만, 범용성 있게 잘 개발해두면, 메시지의 추가 만으로 매우 편리하게 오류 메시지를 관리할 수 있을 것이다
  • 스프링은 MessageCodesResolver라는 것으로 이러한 기능을 지원한다.

MessageCodesResolver Test

public class MessageCodesResolverTest {

    MessageCodesResolver codesResolver = new DefaultMessageCodesResolver();

    @Test
    public void messageCodesResolverObject() {
        String[] messageCodes = codesResolver.resolveMessageCodes("required", "item");
        assertThat(messageCodes).containsExactly("required.item", "required");
    }

    @Test
    public void messageCodesResolverField() {
        String[] messageCodes = codesResolver
            .resolveMessageCodes("required", "item", "itemName", String.class);
        //bindingResult.rejectValue("itemName", "required");
        assertThat(messageCodes).containsExactly("required.item.itemName", "required.itemName",
            "required.java.lang.String", "required");
    }
}
  • MessageCodesResolver
    • 검증 오류 메시지 코드들을 생성한다.
    • DefaultMessageCodesResolver가 기본 구현체이다
    • ObjectError , FieldError 가 내부적으로 해당 Resolver를 호출한다.
  • DefaultMessageCodesResolver 생성 규칙
    • 객체 오류
      1. Code + "." + object name
      2. Code
    • 필드 오류
      1. Code + "." + Object name + "." + Field
      2. Code + "." + Field
      3. Code + "." + Field Type
      4. Code
  • FieldError , ObjectError의 생성자를 보면, 오류 코드를 하나가 아니라 여러 오류 코드를 가질 수 있다. MessageCodesResolver를 통해서 생성된 순서대로 오류 코드를 보관한다
  • 오류 메시지 출력
    • 타임리프 화면을 렌더링 할 때 th:errors 가 실행된다. 만약 이때 오류가 있다면 생성된 오류 메시지 코드를 순서대로 돌아가면서 메시지를 찾는다. 그리고 없으면 디폴트 메시지를 출력한다

오류 코드 관리 전략

  • MessageCodesResolver는 required.item.itemName처럼 구체적인 것을 먼저 만들어주고, required처럼 덜 구체적인 것을 가장 나중에 만든다. 이렇게 하면 앞서 말한 것처럼 메시지와 관련된 공통 전략을 편리하게 도입할 수 있다.
  • 왜 이렇게 복잡하게 사용하는가? 모든 오류 코드에 대해서 메시지를 각각 다 정의하면 개발자 입장에서 관리하기 너무 힘들다. 크게 중요하지 않은 메시지는 범용성 있는 requried 같은 메시지로 끝내고, 정말 중요한 메시지는 꼭 필요할 때 구체적으로 적어서 사용하는 방식이 더 효과적이다
#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} 까지 허용합니다.
  • itemName의 경우 required 검증 오류 메시지가 발생하면 다음 코드 순서대로 메시지가 생성된다.
    1. required.item.itemName
    2. required.itemName
    3. required.java.lang.String 
    4. required
  • 이렇게 생성된 메시지 코드를 기반으로 순서대로 MessageSource에서 메시지에서 찾는다.
  • ValidationUtils를 사용해서 간단한 기능을 한 줄로 쓸 수 있다.
    • ValidationUtils.rejectIfEmptyOrWhitespace(bindingResult, "itemName", "required");

Spring이 직접 만든 오류 메시지 처리

#추가
#Spring이 넣어주는 전략을 내가 설정하여 바꿔줄 수 있다
typeMismatch.java.lang.Integer=숫자를 입력해주세요.
typeMismatch=타입 오류입니다.
  • 검증 오류 코드는 다음과 같이 2가지로 나눌 수 있다.
    • 개발자가 직접 설정한 오류 코드 -> rejectValue()를 직접 호출
    • 스프링이 직접 검증 오류에 추가한 경우(주로 타입 정보가 맞지 않음)
  • type이 맞지 않게 입력하게 되면 Spring이 자동으로 typeMismatch 에러를 bindingResult에 담아 준다 
    1. typeMismatch.item.price
    2. typeMismatch.price
    3. typeMismatch.java.lang.Integer
    4. typeMismatch
  • 해당 내용을 Properties에 추가만 하면 Appliction 코드를 하나도 변경하지 않고 에러 메시지를 수정할 수 있다.
728x90