우선 테스트 코드로 MessageCodesResolver를 알아보자.
MessageCodesResolver는 인터페이스이고 안의 메소드를 보면
주어진 에러코드와 필드설명을 받아서 에러코드들 (String[]) 을 반환해준다.
필드에러의 코드리스트를 만드는데 사용된다고 한다.
ObjectError 테스트
Object Error는 객체의 에러인데 그 객체의 필드에러를 넘어서는 복합적인 에러를 만들때 생성해서
bindingResult에 담아둔다.
public class MessageCodesResolverTest {
MessageCodesResolver codesResolver = new DefaultMessageCodesResolver();
// 구현체는 DefaultMessageCodesResolver를 사용하면 된다.
@Test
void messageCodesResolverObject() { //오브젝트에러 관련 테스트
String[] messageCodes = codesResolver.resolveMessageCodes("required", "item");
for (String messageCode : messageCodes) {
System.out.println("messageCode = " + messageCode);
}
}
}
MessageCodesResolver의 resolveMessageCodes()를 이용해서 메시지코드들을 반환받았다
그것들을 출력해보면
코드,객체를 조합한 세밀한 코드와
그냥 전달된 코드가 들어있다.
(순서를 보면 세밀한 코드의 인덱스가 더 빠르다)
이렇게 만든 메시지코드 String배열을
ObjectError를 만들때 사용하는것이다.
new ObjectError("item", new String[]{"required.item", "required"},null,null);
오브젝트에러 최종 테스트
@Test
void messageCodesResolverObject() { //오브젝트에러 관련 테스트
String[] messageCodes = codesResolver.resolveMessageCodes("required", "item");
Assertions.assertThat(messageCodes).containsExactly("required.item", "required");
}
FieldError 테스트
@Test
void messageCodesResolverField() {
String[] messageCodes = codesResolver.resolveMessageCodes("required", "item", "itemName", String.class);
for (String messageCode : messageCodes) {
System.out.println("messageCode = " + messageCode);
}
}
필드에러 메시지코드를 받을때 매개변수는 (에러코드,객체명,필드명,필드클래스타입)이다.
메시지코드배열 출력결과 (당연하겠지만 인덱스순 == 코드 선정 우선순위순)
codesResolver.resolveMessageCodes()로 반환받은
메시지코드들이 들어있는 String[]을 FieldError를 만들때 넘기는것이다.
최종 테스트 코드
@Test
void messageCodesResolverField() {
String[] messageCodes = codesResolver.resolveMessageCodes("required", "item", "itemName", String.class);
assertThat(messageCodes).containsExactly(
"required.item.itemName",
"required.itemName",
"required.java.lang.String",
"required");
}
MessageCodesResolver
검증 오류 코드로 메시지 코드들을 생성한다. ("required" 를 전달해도 "required.item.itemName"을 만들듯이)
MessageCodesResolver 인터페이스이고 DefaultMessageCodesResolver 는 기본 구현체이다.
주로 다음과 함께 사용한다 ( ObjectError , FieldError)
BindingResult의 rejectValue()를 사용하면 그 안에서 MessageCodesResolver를 사용하기 때문에
알아서 메시지 코드들을 생성해서 ObjectError 또는 FieldError만들어서 BindingResult에 저장한다.
DefaultMessageCodesResolver의 기본 메시지 생성 규칙
객체오류(Obeject Error)인 경우
객체 오류의 경우 다음 순서로 2가지 생성
1.: code + "." + object name
2.: code
예) 오류 코드: required, object name: item
1.: required.item
2.: required
필드오류(Field Error)인 경우
필드 오류의 경우 다음 순서로 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"
동작방식
rejectValue() , reject() 는 내부에서 MessageCodesResolver 를 사용한다.
여기에서 메시지 코드들을 생성한다.
FieldError , ObjectError 의 생성자를 보면, 오류 코드를 하나가 아니라 여러 오류 코드를 가질 수 있다.
MessageCodesResolver를 통해서 생성된 순서대로 오류 코드를 보관한다
BidingResult를 로그로 찍어서 에러내용을 확인해보면
Field error in object 'item' on field 'itemName': rejected value []; codes [required.item.itemName,required.itemName,required.java.lang.String,required]; arguments []; default message [null]
Field error in object 'item' on field 'price': rejected value [null]; codes [range.item.price,range.price,range.java.lang.Integer,range]; arguments [1000,1000000]; default message [null]
Field error in object 'item' on field 'quantity': rejected value [null]; codes [max.item.quantity,max.quantity,max.java.lang.Integer,max]; arguments [9999]; default message [null]
어떤 필드에서 에러가 발생하였고, 그 필드에러의 내부내용을 보여주는데
중간쯤 codes[] 내부 내용을 보면
codes [range.item.price, range.price, range.java.lang.Integer, range]가 저장되어있는것을 확인할 수 있다.
오류 메시지 출력
타임리프 화면을 렌더링 할 때 th:errors 가 실행된다.
만약 이때 오류가 있다면
생성된 오류 메시지 코드를 순서대로 돌아가면서 메시지를 찾는다.
그리고 없으면 디폴트 메시지를 출력한다.
<div class="field-error" th:errors="*{itemName}"></div>
디폴트 메시지가 필요없다면 이렇게 한줄로 처리해도된다.
오류 코드와 메시지 처리5
오류 코드 관리 전략
핵심은 구체적인 것에서! 덜 구체적인 것으로
MessageCodesResolver 는 required.item.itemName 처럼 구체적인 것을 먼저 만들어주고,
required 처럼 덜 구체적인 것을 가장 나중에 만든다.
이렇게 하면 앞서 말한 것 처럼 메시지와 관련된 공통 전략을 편리하게 도입할 수 있다.
왜 이렇게 복잡하게 사용하는가?
모든 오류 코드에 대해서 메시지를 각각 다 정의하면 개발자 입장에서 관리하기 너무 힘들다.
크게 중요하지 않은 메시지는 범용성 있는 requried 같은 메시지로 끝내고,
정말 중요한 메시지는 꼭 필요할 때 구체적으로 적어서 사용하는 방식이 더 효과적이다.
우리 애플리케이션에 이런 오류 코드 전략을 도입해보자
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 - class 타입을 알 수 있으므로 4레벨보다는 자세히 적을 수 있다.
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 (코드.필드class)
4. required (코드)
그리고 이렇게 생성된 메시지 코드를 기반으로 "순서대로" MessageSource가 메시지에서 찾는다.
구체적인 것에서 덜 구체적인 순서대로 찾는다.
메시지에 1번이 없으면 2번을 찾고, 2번이 없으면 3번을 찾는다.
이렇게 되면 만약에 크게 중요하지 않은 오류 메시지는 기존에 정의된 것을 그냥 재활용 하면 된다!
level 1 의 오류메시지들이 선택된 모습
level 1 의 오류메시지들을 주석처리한다면?
level2의 오류메시지들이 선택되었다.
이렇게 높은 우선순위메시지가 없으면 그 다음 우선순위의 메시지를 선택한다. (우선순위는 메시지가 구체적일수록 높다)
if (item.getQuantity() == null || item.getQuantity() >= 9999) {
bindingResult.rejectValue("quantity","max",new Object[]{9999}, null);
}
메시지코드 내용이 바뀔때 reject,rejectValue()의 전달인자를 수정할 필요없이
메시지파일에 가서 메시지를 추가하거나 수정해주면 되니까 편하고
reject,rejectValue()의 전달인자로는 가장낮은레벨의 메시지코드를 넣어주기만하면 되니까 편리하다.
ValidationUtils
아이템이름에 빈값인지 확인하는 검증처리는 다음과 같이 해왔다.
if (!StringUtils.hasText(item.getItemName())) {
bindingResult.rejectValue("itemName", "required");
}
하지만 ValidationUtils가 지원하는 메소드를 이용하면 한줄로 처리가능하다.
ValidationUtils.rejectIfEmptyOrWhitespace(bindingResult, "itemName", "required");
rejectIfEmptyOrWhitespace의 설명은 다음과 같다 ( 해당 필드에 빈값,null,전체가 공백 이면 해당 필드를 거부한다고한다)
Reject the given field with the given error code
if the value is empty or just contains whitespace.
An 'empty' value in this context means either null, the empty string "",
or consisting wholly of whitespace
이런 단순한 기능만 제공하므로 이런게 있구나하고 알고 넘어가자.
정리
1. rejectValue() 호출 (ObjectError면 reject() 호출)
2. MessageCodesResolver 를 사용해서 검증 오류 코드로 메시지 코드들을 생성
3. new FieldError() 를 생성하면서 메시지 코드들을 보관
4. th:erros 에서 메시지 코드들로 메시지를 순서대로 메시지에서 찾고, 노출
addItem V4 메소드
@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, 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("erros = {}", bindingResult);
log.info("model ={}", model);
return "validation/v2/addForm";
}
//성공 로직
Item savedItem = itemRepository.save(item);
redirectAttributes.addAttribute("itemId", savedItem.getId());
redirectAttributes.addAttribute("status", true);
return "redirect:/validation/v2/items/{itemId}";
}
오류 코드와 메시지 처리6
스프링이 직접 만든 오류 메시지 처리
검증 오류 코드는 다음과 같이 2가지로 나눌 수 있다.
1. 개발자가 직접 설정한 오류 코드 rejectValue() 를 직접 호출
(비즈니스 로직상 검증처리)
2. 스프링이 직접 검증 오류에 추가한 경우(주로 타입 정보가 맞지 않음)
(@ModelAttirbute에서 매핑을 실패한다거나 그럴때와 같이)
예를들어)
price라는 필드에 숫자값이 아닌 문자를 넣으면?
BidingResult를 로그로 찍어서 내용을 살펴보면
log.info("erros = {}", bindingResult);
에러는 몇개고 , 어떤 에러인지, 에러의 내부내용을 알려준다.
Field error in object 'item' on field 'price': rejected value [ㅂ]; codes [typeMismatch.item.price,typeMismatch.price,typeMismatch.java.lang.Integer,typeMismatch]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [item.price,price]; arguments []; default message [price]]; default message [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: "ㅂ"]
Field error in object 'item' on field 'price': rejected value [null]; codes [range.item.price,range.price,range.java.lang.Integer,range]; arguments [1000,1000000]; default message [null]}
아래 내용은 범위 검증이므로 비즈니스 로직상으로 우리가 개발한것이고,
위의 내용은 스프링이 typeMismatch가 됬다고 알아서 필드에러를 만든것이다.
위의 코드내용을 보면
codes [typeMismatch.item.price,typeMismatch.price,typeMismatch.java.lang.Integer,typeMismatch];
default message [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: "ㅂ"]
스프링이 알아서 메시지코드들도 만들어주고 기본메시지까지 넣어서 필드에러를 생성하고 BindingResult에 넣어준것을 확인할 수 있다.
typeMismatch.item.price
typeMismatch.price
typeMismatch.java.lang.Integer
typeMismatch
그렇다. 스프링은 타입 오류가 발생하면 typeMismatch 라는 오류 코드를 사용한다.
이 오류 코드가 MessageCodesResolver 를 통하면서 4가지 메시지 코드가 생성된 것이다
아직 errors.properties 에 메시지 코드가 없기 때문에 스프링이 생성한 기본 메시지가 출력된다.
그러므로 위의 코드를 보면 스프링이 어떤식으로 코드를 만들지 예상이되니까
에러메시지파일에 해당 에러코드를 추가하자.
# 타입오류
typeMismatch.java.lang.Integer=숫자를 입력해주세요.
typeMismatch=타입 오류입니다.
이렇게하면 Integer로 들어와야하는 필드에서 타입오류가 발생하면 "숫자를 입력해주세요."라는 메시지가 나갈것이고
Integer가 아닌 필드에서의 타입오류가 발생하면 "타입 오류입니다."라는 메시지가 나갈것이다.
밑에 1000~ 의 메시지가 뜨는이유는
타입오류가 발생해서 price라는 필드에는 아무값도 들어가지못했다.
그러므로 price ==null 이라는 조건식에 걸려서 비즈니스 검증오류가
발생하면서 필드에러도 생성됬으므로 해당 메시지도 보인다!
해결하려면 bindingResult에 에러가있는지 체크하고 있으면 뷰로 돌려보내는 로직을
메소드 앞부분에도 놓으면
비즈니스 검증에 실패에 대한 필드에러가 bindingResult에 추가되지않으니까
비즈니스 로직 검증오류 메시지가 보이지않게된다.
@PostMapping("/add")
public String addItemV4(@ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model) {
if (bindingResult.hasErrors()) {
log.info("erros = {}", bindingResult);
// log.info("model ={}", model);
return "validation/v2/addForm";
}
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, 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("erros = {}", bindingResult);
log.info("model ={}", model);
return "validation/v2/addForm";
}
//성공 로직
Item savedItem = itemRepository.save(item);
redirectAttributes.addAttribute("itemId", savedItem.getId());
redirectAttributes.addAttribute("status", true);
return "redirect:/validation/v2/items/{itemId}";
}
보통 앞에서 많이 사용을 한다고 한다. (몇가지 생각할게 있다고는 함)
(선택사항이다.)
결과적으로 소스코드를 하나도 건들지 않고, (에러메시지파일에 에러메시지코드를 추가하기만함)
원하는 메시지(스프링이 알아서 만드는 에러에 대한)를 단계별로 설정할 수 있 수 있다.
메시지 코드 생성 전략은 그냥 만들어진 것이 아니다.
조금 뒤에서 Bean Validation을 학습하면 그 진가를 더 확인할 수 있다.
'인프런 > 스프링 MVC 2편' 카테고리의 다른 글
10) Bean Validation, @Validated(@Valid) ,프로젝트 (V3~V4) (0) | 2023.02.05 |
---|---|
9) Validator , @Validated (@Valid) (0) | 2023.02.04 |
7) Validation (검증) , 프로젝트 (V1 ~V2) ,오류 코드와 메시지처리 (1~3) (0) | 2023.02.03 |
6) 메시지 ,국제화 (0) | 2023.02.02 |
5)타임리프 - 라디오버튼,셀렉트박스 (0) | 2023.02.02 |
댓글