오류 코드와 메세지 처리1
이전에 했던 messages.properties 처럼 errors.properties로 에러 메세지를 처리할 수 있다.
FieldError의 파라미터가 objectName, field, rejectedValue, bindingFailure, codes, arguments, defaultMessage가 있는데 이 파라미터들을 다 활용해볼것이다.
errores.properties
required.item.itemName=상품 이름은 필수입니다.
range.item.price=가격은 {0} ~ {1} 까지 허용합니다.
max.item.quantity=수량은 최대 {0} 까지 허용합니다.
totalPriceMin=가격 * 수량의 합은 {0}원 이상이어야 합니다. 현재 값 = {1}
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));
}
codes에 errores.properties의 값을 넣어주면 된다. 그리고 String 배열을 사용한 이유는 첫번째 인덱스의 값이 없을 때, 두번째 세번째 등 여러 값을 넣어 사용할 수 있기 때문이다. (range.item.price가 없다면 그 다음 인덱스인 range.item.xxx가 사용됨)
price의 arguments는 1000원부터 1백만원까지 제한이었으니 그에 맞게 작성해줬다.
오류 코드와 메세지 처리2
FieldError, ObjectError는 다루기 너무 번거롭다. 조금 더 리팩토링해보자
BindingResult는 검증해야 할 객체인 target 바로 다음에 온다. 따라서 BindingResult는 이미 본인이 검증해야 할 객체인 target을 알고있다.
@PostMapping("/add")
public String addItemV4(@ModelAttribute Item item,
BindingResult bindingResult,
RedirectAttributes redirectAttributes,
Model model) {
// 검증 로직
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);
}
}
...
}
rejectValue(), reject()
BindingResult가 제공하는 rejectValue(), reject()를 사용하면 FieldError, ObjectError를 직접 생성하지 않아도 된다.
rejectValue()는 이미 objectName을 알고 있으니 굳이 적을 필요 없다. codes는 required, range, max만 작성했는데
정상적으로 작동한다. 이유가 무엇일까?
이 부분은 MessageCodesResolver를 이해해야 한다.
오류 코드와 메세지 처리3
메세지 처리를 할때를 예시로 들어보자
- 상품 이름은 필수입니다 / 필수 값입니다
- 상품의 가격 범위 오류입니다 / 범위 오류입니다
둘의 차이는 범용성에 있다. 가장 좋은방법은 범용성있게 사용하다가 세밀한 내용이 필요할 때 세밀한 내용이 적용되도록 레벨을 두는것이다.
rejectValue()에서는 객체명과 필드명을 조합한 메세지가 우선 있는지 확인하고, 없으면 더 범용적인 메세지를 사용한다.
0. required.item.itemName = 사과
1. required=음식
(물론 이 사이에 우선순위는 여러가지 더 있다.)
오류코드와 메세지 처리4
rejectValue()의 작동 방식에 대해 이해하려면 MessageCodesResolver를 이해해야 한다.
public class MessageCodeResolverTest {
MessageCodesResolver codesResolver = new DefaultMessageCodesResolver();
@Test
void messageCodesResolverObject() {
String[] messageCodes = codesResolver.resolveMessageCodes("required", "item");
assertThat(messageCodes).containsExactly("required.item", "required");
}
@Test
void messageCodesResolverField() {
String[] messageCodes = codesResolver.resolveMessageCodes("required", "item", "itemName", String.class);
for (String messageCode : messageCodes) {
System.out.println("messageCode = " + messageCode);
}
assertThat(messageCodes).containsExactly(
"required.item.itemName",
"required.itemName",
"required.java.lang.String",
"required"
);
}
}
MessageCodesResolver는 인터페이스, DefaultMessageCodesResolver는 기본 구현체이다.
주로 objectError, FieldError와 함꼐 사용한다.
객체 오류 (ObjectError) 생성 규칙
1. code + "." + object name (required.item)
2. code (required)
필드 오류 (FieldError) 생성 규칙
1. code + "." + object name + "." + field (typeMismatch.user.age)
2. code + "." + field (typeMismatch.age)
3. code + "." + field type (typeMismatch.int)
4. code (typeMismatch)
동작 방식
- rejectValue(), reject()는 내부에서 MessageCodeResolver를 사용한다. 여기에서 메세지 코드를 생성한다.
- FieldError, ObjectError를 보면 오류 코드를 하나가 아니라 여러 코드를 가질 수 있다. 이 여러 코드를 MessageCodesResolver를 통해 생성된 순서대로 오류 코드를 보관한다.
즉 rejectValue("itemName", "required")를 호출하면
- required.item.itemName
- required.itemName
- required.java.lang.String
- required
4가지가 생성되고, 위 메세지처리 2번의 코드에서 required만 작성해도 작동한 이유가 바로 이 때문이다.
오류 메세지 출력
타임리프 화면을 렌더링 할 때 th:errors가 실행된다. 에러 발생시 오류 코드를 순서대로 돌아가면서 찾고, 없다면 defaultMessage를 출력하게 된다.
오류 코드와 메세지 처리5
처리 순서는 구체적인 것에서 덜 구체적인 것으로 간다는 것이다.
비즈니스 로직을 변경하지 않고 값을 변경할 수 있다는게 핵심이다.
정리
1. rejectValue() 호출
2. MessageCodesResolver를 사용해서 검증 오류 코드로 메세지 코드들을 생성
3. new FieldError()를 생성하면서 메세지 코드를 보관
4. th:errors에서 메세지 코드들을 순서대로 메세지에서 찾고 출력\
오류 코드와 메세지 처리6
이번엔 스프링이 직접 만든 오류 메세지를 처리해보자
typeMismatch가 발생할 경우에 다음과 같이 출력된다.

로그를 확인해보면 typeMismatch.item.price, typeMismatch.price, .... 라는 내용이 나온다.
스프링은 타입오류가 발생하면 typeMismatch 오류 코드를 사용하는데, 이 오류 코드가 MessageCodesResolver를 통하면서 4가지 메세지 코드가 생성된 것이다.
#추가
typeMismatch.java.lang.Integer=숫자를 입력해주세요.
typeMismatch=타입 오류입니다.
errors.properties에 추가한 내용을 적용시키면

스프링이 만든 오류코드를 우리가 설정한 메세지로 처리할 수 있다.
결과적으로 소스코드를 하나도 건들지 않고 원하는 메세지를 단계별로 설정할 수 있게 된 것이다.
Validator 분리1
현재 컨트롤러에 검증 로직이 차지하는 부분이 매우 크다. 이런 경우 별도의 클래스로 역할을 분리하는것이 좋다.
package hello.itemservice.web.validation;
import hello.itemservice.domain.item.Item;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.validation.Errors;
import org.springframework.validation.Validator;
@Component
public class ItemValidator implements Validator {
@Override
public boolean supports(Class<?> aClass) {
return Item.class.isAssignableFrom(aClass);
// item == aclass
// item == subItem (자식클래스까지 검증)
}
@Override
public void validate(Object o, Errors errors) {
Item item = (Item) o;
if (!StringUtils.hasText(item.getItemName())) {
errors.rejectValue("itemName", "required");
}
if (item.getPrice() == null
|| item.getPrice() < 1000
|| item.getPrice() > 1000000) {
errors.rejectValue("price", "range", new Object[]{1000, 1000000}, null);
}
if (item.getQuantity() == null || item.getQuantity() >= 9999) {
errors.rejectValue("quantity", "max", new Object[]{9999}, null);
}
if (item.getPrice() != null
&& item.getQuantity() != null) {
int resultPrice = item.getPrice() * item.getQuantity();
if (resultPrice < 10000) {
errors.reject("totalPriceMin", new Object[]{10000, resultPrice}, null);
}
}
}
}
ItemValidator를 스프링 빈으로 등록했다. (@Component)
supports()와 validate()메서드로 구성되어있다.
isAssignableFrom()을 사용하면 자식 클래스까지 검증해주기 때문에 사용하는것을 추천한다.
(support메서드의 설명은 나중에 함)
validate()에 검증로직을 넣어준다. (파라미터는 검증 대상 객체와 BindingResult가 들어감)
Errors는 bindingResult의 인터페이스이다. 따라서 bindingResult의 메서드를 사용할 수 있다.
@PostMapping("/add")
public String addItemV5(@ModelAttribute Item item,
BindingResult bindingResult,
RedirectAttributes redirectAttributes,
Model model) {
itemValidator.validate(item, bindingResult);
// 검증에 실패하면 다시 입력 폼으로
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}";
}
검증로직을 별도로 분리해주니 코드가 훨씬 보기 쉬워졌다.
Validator 분리2
addItemV5에선, 검증기를 직접 불러서 사용했다. (이렇게 사용하면 안된다는건 아니다)
- itemValidaitor.validate(item, bindingResult);
@InitBinder
public void init(WebDataBinder dataBinder) {
dataBinder.addValidators(itemValidator);
}
Controller에 위 코드를 추가하고, WebDataBinder에 검증기를 추가하면, 해당 컨트롤러에서 validator를 자동으로 적용할 수 있다.
(해당 컨트롤러에만 사용 가능, 글로벌 설정은 따로 해줘야함)
@PostMapping("/add")
public String addItemV6(@Validated @ModelAttribute Item item,
BindingResult bindingResult,
RedirectAttributes redirectAttributes,
Model model) {
// 검증에 실패하면 다시 입력 폼으로
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}";
}
V6에선 validator를 직접 호출하지 않고, @Validated 어노테이션을 사용해서 자동으로 호출해서 사용한다.
@Validated는 검증기를 실행하라는 어노테이션이다. 이게 붙어있으면 WebDataBinder에 등록된 검증기를 찾아서 실행한다.
그 중에 어떤 검증기가 있는지 구분이 필요하기 때문에 Validator 클래스에 있는 supports()가 이 때 사용되는 것이다.
현재는 Item 클래스에 대한 검증기밖에 없지만, 추가로 생성된다면 여기서 해당 클래스에 맞는 검증기를 찾아서 사용하는 것이다.
그 후 validate()가 실행된다.
(HandlerAdapter와 동작방식이 비슷하다고 이해하면 편함)
'Spring' 카테고리의 다른 글
| 로그인 처리1 - 쿠키, 세션 (1) | 2025.09.17 |
|---|---|
| Bean Validation (0) | 2025.09.12 |
| 메세지, 국제화 (1) | 2025.09.07 |
| 타임리프 - 스프링 통합과 폼 (1) | 2025.09.06 |
| 타임리프 - 기본 기능 (1) | 2025.08.31 |
