BackEnd/Spring MVC

[Spring] 검증1 - Validation - 3

샤아이인 2022. 3. 6.

내가 공부한것을 올리며, 중요한 단원은 저 자신도 곱씹어 볼겸 상세히 기록하고 얕은부분들은 가겹게 포스팅 하겠습니다.

 

6. 오류 코드와 메시지 처리 1

위에서는 오류 메시지를 직접 추가해줬었는데, 이렇게 직접 추가하다보면 통일성이 없어 진다.

같은 내용의 오류메시지 이지만, 구문이 조금씩 달라질수도 있다. 이를 좀더 효율적으로 오류 메시지를 다루어 보자.

 

properties에 한번에 저장해두고, 메시지처럼 사용할수가 있다.

 

이러한 활용법을 알아보기 전에 우선 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)

 

이전 글에서는 살펴보지 않았던 codes, arguments 에 대하여 알아보자. 이것은 오류 발생시 오류 코드로 메시지를 찾기 위해 사용된다.

 

● errors 메시지 파일 생성

이전에 국제화와 메시지를 사용했듯, 오류 메시지 또한 쉽게 errors.properties 라는 별도의 파일로 만들어 관리해보자.

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

만들어진 errors.properties파일을 인식할 수 있도록 application.properties에 추가해준다.

spring.messages.basename=messages,errors
 

 

● 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("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}";
}
 

대표적으로 pirce 필드에 관한 오류처리만 살펴보자.

new FieldError("item", "price", item.getPrice(), false, new String[] {"range.item.price"}, new Object[]{1000, 1000000}
 

- codes : range.item.price 를 사용해서 메시지 코드를 지정할수 있다.

메시지 코드는 하나가 아니라 배열로 여러 값을 전달할 수 있는데, 순서대로 매칭해서 처음 매칭되는 메시지가 사용된다.

 

만약 배열에 총 2개의 메시지 코드가 있는데, 배열의 0번 메시지를 찾을수 없어서 1번 메시지를 찾아도 없으면 default 메시지를 출력한다.

 

- arguments : Object[]{1000, 1000000} 를 사용해서 코드의 {0} , {1} 로 치환할 값을 전달한다.

 

(ps, cmd + E => 최근에 열었던 파일목록)

 

7. 오류 코드와 메시지 처리 2

기존의 FieldError 와 ObjectError 를 사용하는 코드는 넘겨주어야 할 인자도 너무 많고, 길어서 복잡하다.

좀더 코드를 단순하게 작성할수는 없을까?

 

컨트롤러에서 BindingResult 는 검증해야 할 객체인 @ModelAttribute target 바로 다음에 온다.

따라서 BindingResult 는 이미 본인이 검증해야 할 객체인 target 을 알고 있는것 이다.

 

이를 확인해보기 위해 컨트롤러에 다음과 같은 로거를 검증로직 앞에 추가해주자.

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

// 이후 검증로직 진행
 

검증로직은 로거 뒤에 있는 상황이다. 따라서 검증해야할 대상에 대한 정보를 넘겨준적이 없다.

하지만 BindingResult는 이미 자신이 검증해야하는 객체에 대한 정보를 알고있는것 이다.

 

● rejectValue(), reject()

기존의 코드에서는 직접 addError() 메서드를 사용하여 FieldError와 ObjectError를 추가해 주었다.

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

 

이를 활용하여 기존의 코드를 단순화 시켜보자.

@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())){
        bindingResult.rejectValue("itenName", "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);
        }
    }

    // 검증에 실패한 경우

    // 성공 로직
}
 

- rejectValue() : FieldError 용

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

field : 오류 필드명

errorCode : 오류 코드(이 오류 코드는 메시지에 등록된 코드가 아니다. 뒤에서 설명할 messageResolver를 위한 오류 코드이다.)

errorArgs : 오류 메시지에서 {0} 을 치환하기 위한 값

defaultMessage : 오류 메시지를 찾을 수 없을 때 사용하는 기본 메시지다.

 

이를 활용하면 pirce 필드에 관한 에러코드를 다음과 같이 추가할 수 있다.

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

- reject() : ObjectError 용

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

 

● 축약된 오류 코드

FieldError() 를 직접 다룰 때는 오류 코드를 addError()을 통하여 range.item.price 과 같이 모두 입력해야 했다.

하지만 이번에 rejectValue()를 사용할때는 오류 코드를 range 로 간단하게 입력했다. 그래도 오류 메시지를 잘 찾아서 출력한다.

 

무언가 규칙이 있는 것 처럼 보이는데... 이 부분을 이해하려면 MessageCodesResolver 를 이해해야 한다.

 

8. 오류 코드와 메시지 처리 3

위에서도 봤듯 오류 코드를 "range.item.price : 상품의 가격 범위 오류 입니다. " 과 같이 자세하게 만들수도 있고,

오류 코드를 "range : 범위 오류 입니다. " 와 같이 단순하게 만들수도 있습니다.

 

단순하게 만들면 범용성이 좋아서 여러곳에서 사용할 수 있지만, 메시지를 세밀하게 작성하기 어렵다.

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

가장 좋은 방법은 범용성으로 사용하다가, 세밀하게 작성해야 하는 경우에는 세밀한 내용이 적용되도록 메시지에 단계를 두는 방법이다.

 

세밀한 메시지가 있으면 세밀한 메시지가 먼저 적용되는 것 이다. 다음과 같이 말이다.

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

#Level2
required: 필수 값 입니다.
 

생각해보면 addError로 메시지를 추가할때 메시지 코드를 String의 배열로 넘겼었다.

따라서 다음과 같은 코드로 내부적으로는 등록이 된다.

new String[]{"required.item.itemName", "required"}
 

자세한 내용이 먼저 앞에 추가되기 때문에 먼저 사용된다.

 

9. 오류 코드와 메시지 처리 4

이번시간에는 MessageCodesResolver에 대하여 알아보자. 테스트 코드를 작성해 봅시다!

 

● messageCodesResolverObject (Object 에러)

public class MessageCodesResolverTest {

    MessageCodesResolver codesResolver = new DefaultMessageCodesResolver();

    @Test
    void messageCodesResolverObject(){
        String[] messageCodes = codesResolver.resolveMessageCodes("required", "item");
        for (String messageCode : messageCodes) {
            System.out.println("messageCode = " + messageCode);
        }
        Assertions.assertThat(messageCodes).containsExactly("required.item", "required");
    }
}
 

MessageCodesResolver는 검증 코드를 통하여 메시지 코드를 만들어주는 역할을 한다.

MessageCodesResolver는 interface이고, 위에서 사용한 DefaultMessageCodesResolver는 이를 구현한 구현체 이다.

 

MessageCodesResolver의 interface를 한번 살펴보자.

public interface MessageCodesResolver {

    String[] resolveMessageCodes(String errorCode, String objectName);
    String[] resolveMessageCodes(String errorcode, String objectName, String field,  Class<?> fieldType);
}
 

위에서 보이듯 errorCode를 하나 넘겨주면 String 배열을 반환해 준다. 여러 값을 반환한다는 의미이다.

 

다음 코드가 핵심이다. 인자로 errorCode와 objectName을 넘겨주었다.

codesResolver.resolveMessageCodes("required", "item")
 

위 코드를 통하여 메시지코드 배열을 반환받게 된다.

 

이전에 ObjectError, FieldError에 에러코드 배열을 추가한적이 있는데, 이때 사용된다.

 

위 테스트 코드를 통해 나온 결과는 다음과 같다.

 

● messageCodesResolverField (Field 에러)

public class MessageCodesResolverTest {

    MessageCodesResolver codesResolver = new DefaultMessageCodesResolver();

    @Test
    void messageCodesResolverField(){
        String[] messageCodes = codesResolver.resolveMessageCodes("required", "item", "itemName", String.class);
        for (String messageCode : messageCodes) {
            System.out.println("messageCode = " + messageCode);
        }
        Assertions.assertThat(messageCodes).containsExactly("required.item.itemName", "required.itemName", "required.java.lang.String", "required");
    }
}
 

위에서 봤던 코드와 구조가 동일하다. 다만 resolveMessageCodes 에 넘기는 인자가 달라졌다.

odesResolver.resolveMessageCodes("required", "item", "itemName", String.class)
 

이전과 같이 errorCode, objectName 을 넘김과 동시에, fieldName, fieldType 를 추가로 넘겨준다.

이렇게 생성된 결과는 다음과 같다.

이번에는 4개의 String이 생성되여 배열에 담겨졌다.

 

● 동작 원리

우리는 위에서 reject() 와 rejectValue()를 사용한적이 있다.

이들은 내부적으로 MessageCodesResolver 를 사용하여 메시지 코드들을 생성하고 있다.

 

어짜피 reject() 와 rejectValue() 또한 내부적으로는 FieldError, ObjectError 를 사용하여 에러 메시지를 추가하는 것 이다.

 

생각해보면 FieldError, ObjectError 생성자를 보면, 오류 코드를 하나가 아니라 여러 오류코드를 가질 수 있다. 심지어 배열로 받고있다! MessageCodesResolver 를 통해서 생성된 String[] 그대로의 순서로 오류 코드를 보관한다.

 

예전에 BindingResult를 로그를 통해 확인했던 결과를 보면 다음과 같은데 이제 이게 어떻게 생성되었는지 이해할 수 있다!

codes에 배열로 순서대로 생성되어 담겨있는것을 확인할 수 있다.

MessageCodesResolver를 통해 에러 메시지를 생성하여 FieldError()에 넘겨 BindingResult에 저장하고 있는것 이다.

 

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

'BackEnd > Spring MVC' 카테고리의 다른 글

[Spring] 검증2 - Bean Validation - 1  (0) 2022.03.07
[Spring] 검증1 - Validation - 4  (0) 2022.03.06
[Spring] 검증1 - Validation - 2  (0) 2022.03.06
[Spring] 검증1 - Validation - 1  (0) 2022.03.05
[Spring] 메시지, 국제화  (0) 2022.03.05

댓글