Validation - 2 (~ MessageCodesResolver 까지)

나무·2023년 11월 17일

스프링 MVC

목록 보기
3/12
post-thumbnail

이전 포스트 에서 BindingResult 를 이용해 타입오류를 처리해보았다. 하지만 오류메시지가 마음에 들지 않았었다.

그래서 이번 포스트에서는 오류메시지 설정에 대해 알아보겠다.

0. errors.properties

에러 메시지를 관리하기 위해서는 당연히 에러 메시지 소스가 필요하다.

우선 errors.properties 를 생성해주자.

errors.properties

# 에러 메시지 코드의 구조
# [요구사항조건].[객체명].[필드명]

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

그 다음, application.properties 에 가서 매시지소스의 basename에 errors 를 추가해줘야한다.

1. V3 : errors.properties 도입

[Controller]

//	@GetMapping은 V1,V2 와 동일
    @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));
            }
        }

[뷰 (타임리프)]

V1, V2와 동일


2. 에러 메시지 적용

FiledError, ObjectError 의 필드에는 codesarguments 가 있는데 이 두 필드가 메시지에 대한 필드이다.

1) codes : 메시지 코드

메시지 코드를 적는 곳이며 String[] 타입이기 때문에 여러개의 메시지 코드를 저장할 수 있다.

여러개인 이유는 우선순위를 적용할 수 있기 때문이다.

new String[]{
"code1",
"code2", 
"code3"}

위와 같이 메시지 코드를 집어 넣는다면

code1 이 없다면 code2 를,
code2 가 없다면 code3 를,
code3 도 없다면 defaultMessage 를,
defaultMessage 마저 없다면 오류가 발생한다.

2) arguments : 메시지 파라미터

메시지에 사용되는 파라미터들을 집어넣을 수 있으며 Object[] 이다.

V3 문제점

타임리프 뷰는 나름 줄이긴했지만 여전히 컨트롤러의 경우 Map 대신 BindingResult 를 쓴거 치곤 V1 에 비해 그닥 짧아지진 않았다.

좀 더 코드를 줄일 순 없을까?

3. V4 : rejectValue() 사용

Controller

//    @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("itemName", "required");
        }
        if (item.getPrice() == null || item.getPrice() < 1000 || item.getPrice() > 1000000) {
            bindingResult.rejectValue("price", "range", new Object[]{1000, 10000000}, 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("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}";
    }

BindingResult 의 경우 자신의 타겟 객체(Item)가 무엇인지 이미 알고 있다. 그렇기 때문에 굳이

bindingResult.addError(new FieldError(
	"item", 
	"itemName", 
	item.getItemName(), 
	false, 
	new String[]{"required.item.itemName"},
	null,
	null));

FieldError 에 "item" 을 명시하고, 메시지 코드도 XXX.item.itemName 이런식으로 객체명과 필드명 전체를 집어넣을 필요가 없다.

이렇게 개발자가 직접 타겟 객체와 필드를 주입해줄 필요 없이 스프링이 자동을 주입 해주는 기능을 사용해보자.

rejectValue()

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

BindingResult 에는 rejectValue() 라는 메서드가 있는데 이 메서드를 이용하면 스프링이 알아서 reject된 필드와 객체를 찾아낸다 .

 bindingResult.rejectValue("quantity", "max", new Object[]{9999}, null);

V3 와 달리 필드명만 명시를 하고 있고 심지어 메시지 코드는 축약된 메시지코드 만 저장해도 알아서 타겟 객체와 타겟 필드를 인식해준다.

V4의 문제점

에러메시지소스가 너무 구체적으로 표현되어있어서 범용성이 떨어진다.

입력폼들중에 필수적으로 입력해야하는 경우가 굉장히 많은데 이 모든 필수 입력들이 공용으로 사용하는 메시지 코드를 만들어야할 필요성이 생긴다.

하지만 반대로 특정 필드에만 적용되는경우가 있기 때문에 상세한 메시지 또한 필요로하다.

즉, 오류메시지들을 단계별로 설정해서 각 필요한 상황에 맞춰 사용할 수 있도록 설계 해야한다.


4. V4-1 : 새 에러메시지 적용

V4-1 의 경우 컨트롤러가 아닌 에러메시지소스의 개선이므로 컨트롤러코드는 V4 와 동이라하다.

errors.properties

#==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} 까지 허용합니다.

사실 V4 컨트롤러에서 rejectValue() 동작을 제대로 설명하지 않았다.

위와 같이 에러메시지 소스를 설계 해놓으면 범용성상세함 두가지를 모두 잡을 수 있다.

1) required
모든 필수 값 검증에 사용 할 수 있는 범용적인 메시지 코드

2) required.item.itemName
필수 값 항목들 중에서 "상품명" 에 대해서만 사용되는 메시지 코드.

이제 개발자는 처음에 메시지 코드를 required 만 등록을 해놨다가 좀 더 구체적인 메시지 코드가 필요하다는 요구사항이 들어오면 컨트롤러 코드를 수정 할 필요없이 required.XXX.XXX 라는 메시지 코드만 하나 더 추가를 해주면 된다.

예시

 bindingResult.rejectValue("quantity", "required", new Object[]{9999}, null);

메시지 소스에 required 만 존재하면 해당 메시지 소스가 바인딩 되지만,

required.item.itemName 과 같이 더 디테일한 메시지 코드가 있을 경우 해당 메시지 소스가 바인딩 된다.

우선순위는 메시지코드가 상세 할 수록 높아진다.

5. MessageCodesResolver

그럼 스프링은 도대체 어떤 원리로 메시지 코드의 우선순위를 파악하고 자동으로 바인딩 시켜주는 것일까?

이 원리의 비밀은 BindingResultrejectValue() 가 가지고 있다.

BindingResult 의 구현체인 AbstractBinidingResult 의 내부 코드이다.

코드를 통해 알 수 있다시피 사실 rejectValue()new FieldError() 인스턴스를 만들어서 직접 에러를 등록해주고 있다. 즉, rejectValue() 가 만들어주기 때문에 개발자가 직접 FieldError 를 만들필요가 없던것이다.

우리가 rejectValue() 파라미터로 넘겨준 값중 errorCodefield가 있었을 것이다.

rejectedValue() 는 이 `errorCodefield 를 또 다시 resolveMessageCodes() 에 전달해준다. 그렇다면 과연 resolveMessageCodes() 는 또 무엇일까?

resolveMessageCodes()

AbsctractBindingResult 는 필드멤버로 MessageCodesResolver 를 가지고 있다.

이 객체가 바로 메시지 코드를 자동으로 선택해주는 역할을 한다.

AbstractBindingResult 는 기본적으로 DefaultMessageCodesResolver 를 사용하고 있지만

DefaultMessageCodesResolve 에는 앞서 보았던 resolveMessageCodes() 메서드가 구현되어있다.

resolveMessageCodes() 는 두 종류로 오버로딩이 되어있음을 확인할 수 있다.

파라미터 4개짜리를 한번 살펴보자.

@Override
	public String[] resolveMessageCodes(String errorCode, String objectName, String field, @Nullable Class<?> fieldType) {
		Set<String> codeList = new LinkedHashSet<>();
		List<String> fieldList = new ArrayList<>();
		buildFieldList(field, fieldList);
		addCodes(codeList, errorCode, objectName, fieldList);
		int dotIndex = field.lastIndexOf('.');
		if (dotIndex != -1) {
			buildFieldList(field.substring(dotIndex + 1), fieldList);
		}
		addCodes(codeList, errorCode, null, fieldList);
		if (fieldType != null) {
			addCode(codeList, errorCode, null, fieldType.getName());
		}
		addCode(codeList, errorCode, null, null);
		return StringUtils.toStringArray(codeList);
	}

이 코드를 전체 뜯어보고 싶은 마음이 있지만 그건 주제넘는 일이므로 그냥 메서드 이름으로 추측해보자면,

codeList : 모든 메시지 코드들을 저장하는 리스트(실제 자료구조는 Set이다).

fieldList : 필드 이름을 저장하는 리스트.

buildFieldList() : field에서 필드 이름을 추출하여 fieldList에 저장하는 메서드.

addCodes() : 메시지 코드를 생성하여 codeList에 추가하는 메서드. fieldList에 사용하여 다양한 메시지 코드를 생성.

위의 변수와 메서드들을 이용하여 하나의 에러코드만으로 여러개의 메시지 코드를 저장한 배열을 반환해준다.

또한 codeList 는 순서가 존재하므로 저장되는 순서가 곧 메시지 코드의 우선순위가 된다.

객체 오류

필드 오류

전체 흐름 정리

1) 컨트롤러에서 개발자가 BindingResult 인터페이스에 속한 rejectValue()"에러코드""필드명" 을 넘겨 준다.

2) 호출된 rejectValue()new FieldError() 인스턴스를 생성하는데 이때 생성자 파라미터에 resolveMessageCodes() 에서 반환된 값을 주입한다.

3) resolveMessageCodes() 는 전달받은 파라미터들로 여러 메시지 코드들을 만들고 이들을 codeList[] (메시지 코드 리스트) 에 저장해 반환해준다.

4) 반환된 배열은 new FieldError() 에 다시 생성자 주입이 된다.

테스트 코드

@Test
    void myMessageCodesResolverField() {
        String[] messageCodes = codesResolver.resolveMessageCodes("required", "item", "itemName", String.class);
        for (String messageCode : messageCodes) {
            System.out.println(messageCode);
        }
    }

추가 정보

메시지 코드 리졸버는 위와 같이 다른 구현체로 교체도 할 수도 있다. DefaultMessageCodesResolver 는 총 4가지의 메시지 코드가 생성되지만 개발자가 직접 커스터마이징하여 8개 9개 까지 반환하는 리졸버를 만들어서 바꿔끼워도 된다. (근데 그럴일 없음)

+ TypeMismatch

오류 메시지 소스에 typeMismatch 에 대한 메시지도 따로 정의해줄 수 있다.

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

5. V4, V4-1 문제점

현재 컨트롤러 코드를 보면 알 수 있다시피 검증 로직이 너무 크게 차지를 한다. 즉, SRP 를 위배하게 되며 그걸 떠나서라도 그냥 너무 보기에 지저분하다.

다음 장에서는 Vaildator 라는 객체를 따로 정의하여 이 객체에게 검증이란 책임을 모두 전가시킬 것이다.

본 포스트는
"김영한의 스프링 MVC 2편 - 백엔드 웹 개발 활용 기술 강의" 를 보고 정리했습니다.

profile
🍀 개발을 통해 지속 가능한 미래를 만드는데 기여하고 싶습니다 🍀

0개의 댓글