이전 포스트 에서 BindingResult 를 이용해 타입오류를 처리해보았다. 하지만 오류메시지가 마음에 들지 않았었다.
그래서 이번 포스트에서는 오류메시지 설정에 대해 알아보겠다.
에러 메시지를 관리하기 위해서는 당연히 에러 메시지 소스가 필요하다.
우선 errors.properties 를 생성해주자.
# 에러 메시지 코드의 구조
# [요구사항조건].[객체명].[필드명]
#required.item.itemName=상품 이름은 필수입니다.
#range.item.price=가격은 {0} ~ {1} 까지 허용합니다.
#max.item.quantity=수량은 최대 {0} 까지 허용합니다.
#totalPriceMin=가격 * 수량의 합은 {0}원 이상이어야 합니다. 현재 값 = {1}
그 다음, application.properties 에 가서 매시지소스의 basename에 errors 를 추가해줘야한다.

// @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와 동일
FiledError, ObjectError 의 필드에는 codes와 arguments 가 있는데 이 두 필드가 메시지에 대한 필드이다.

메시지 코드를 적는 곳이며 String[] 타입이기 때문에 여러개의 메시지 코드를 저장할 수 있다.
여러개인 이유는 우선순위를 적용할 수 있기 때문이다.
new String[]{
"code1",
"code2",
"code3"}
위와 같이 메시지 코드를 집어 넣는다면
code1 이 없다면 code2 를,
code2 가 없다면 code3 를,
code3 도 없다면 defaultMessage 를,
defaultMessage 마저 없다면 오류가 발생한다.
메시지에 사용되는 파라미터들을 집어넣을 수 있으며 Object[] 이다.
타임리프 뷰는 나름 줄이긴했지만 여전히 컨트롤러의 경우 Map 대신 BindingResult 를 쓴거 치곤 V1 에 비해 그닥 짧아지진 않았다.
좀 더 코드를 줄일 순 없을까?
// @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 이런식으로 객체명과 필드명 전체를 집어넣을 필요가 없다.
이렇게 개발자가 직접 타겟 객체와 필드를 주입해줄 필요 없이 스프링이 자동을 주입 해주는 기능을 사용해보자.
field: 오류 필드명
errorCode: 오류 코드(이 오류 코드는 메시지에 등록된 코드가 아니다. 뒤에서 설명할
messageResolver를 위한 오류 코드이다.)
errorArgs: 오류 메시지에서 {0} 을 치환하기 위한 값
defaultMessage: 오류 메시지를 찾을 수 없을 때 사용하는 기본 메시지
BindingResult 에는 rejectValue() 라는 메서드가 있는데 이 메서드를 이용하면 스프링이 알아서 reject된 필드와 객체를 찾아낸다 .
bindingResult.rejectValue("quantity", "max", new Object[]{9999}, null);
V3 와 달리 필드명만 명시를 하고 있고 심지어 메시지 코드는 축약된 메시지코드 만 저장해도 알아서 타겟 객체와 타겟 필드를 인식해준다.
에러메시지소스가 너무 구체적으로 표현되어있어서 범용성이 떨어진다.
입력폼들중에 필수적으로 입력해야하는 경우가 굉장히 많은데 이 모든 필수 입력들이 공용으로 사용하는 메시지 코드를 만들어야할 필요성이 생긴다.
하지만 반대로 특정 필드에만 적용되는경우가 있기 때문에 상세한 메시지 또한 필요로하다.
즉, 오류메시지들을 단계별로 설정해서 각 필요한 상황에 맞춰 사용할 수 있도록 설계 해야한다.
V4-1 의 경우 컨트롤러가 아닌 에러메시지소스의 개선이므로 컨트롤러코드는 V4 와 동이라하다.
#==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 과 같이 더 디테일한 메시지 코드가 있을 경우 해당 메시지 소스가 바인딩 된다.
우선순위는 메시지코드가 상세 할 수록 높아진다.
그럼 스프링은 도대체 어떤 원리로 메시지 코드의 우선순위를 파악하고 자동으로 바인딩 시켜주는 것일까?
이 원리의 비밀은 BindingResult 의 rejectValue() 가 가지고 있다.
BindingResult 의 구현체인 AbstractBinidingResult 의 내부 코드이다.
코드를 통해 알 수 있다시피 사실 rejectValue() 는 new FieldError() 인스턴스를 만들어서 직접 에러를 등록해주고 있다. 즉, rejectValue() 가 만들어주기 때문에 개발자가 직접 FieldError 를 만들필요가 없던것이다.
우리가 rejectValue() 파라미터로 넘겨준 값중 errorCode 와 field가 있었을 것이다.
rejectedValue() 는 이 `errorCode 와 field 를 또 다시 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.java.lang.Integer=숫자를 입력해주세요.
typeMismatch=타입 오류입니다.
현재 컨트롤러 코드를 보면 알 수 있다시피 검증 로직이 너무 크게 차지를 한다. 즉, SRP 를 위배하게 되며 그걸 떠나서라도 그냥 너무 보기에 지저분하다.
다음 장에서는 Vaildator 라는 객체를 따로 정의하여 이 객체에게 검증이란 책임을 모두 전가시킬 것이다.
본 포스트는
"김영한의 스프링 MVC 2편 - 백엔드 웹 개발 활용 기술 강의" 를 보고 정리했습니다.