공부 내용을 정리하고 앞으로의 학습에 이해를 돕기 위해 작성합니다.
검증 직접 처리
이번에는 스프링이 제공하는 기능을 사용하지 않고, 컨트롤러에서 직접 검증을 구현하는 방식으로 상품 등록 검증 로직을 작성해 본다.
검증 요구사항 정리
- 상품명: 필수, 공백 불가
- 가격: 1,000원 이상, 1,000,000원 이하
- 수량: 최대 9,999
- 복합 조건: 가격 * 수량의 합은 10,000원 이상
1. 컨트롤러 수정
컨트롤러에서 직접 검증 로직을 구현한다.
@PostMapping("/add")
public String addItem(@ModelAttribute Item item, RedirectAttributes redirectAttributes, Model model) {
Map<String, String> errors = new HashMap<>();
if (!StringUtils.hasText(item.getItemName())) {
errors.put("itemName", "상품 이름은 필수입니다.");
}
if (item.getPrice() == null || item.getPrice() < 1000 || item.getPrice() > 1000000) {
errors.put("price", "가격은 1,000 ~ 1,000,000 까지 허용합니다.");
}
if (item.getQuantity() == null || item.getQuantity() > 9999) {
errors.put("quantity", "수량은 최대 9,999 까지 허용합니다.");
}
if (item.getPrice() != null && item.getQuantity() != null) {
int resultPrice = item.getPrice() * item.getQuantity();
if (resultPrice < 10000) {
errors.put("globalError", "가격 * 수량의 합은 10,000원 이상이어야 합니다. 현재 값 = " + resultPrice);
}
}
if (!errors.isEmpty()) {
model.addAttribute("errors", errors);
return "validation/v1/addForm";
}
Item savedItem = itemRepository.save(item);
redirectAttributes.addAttribute("itemId", savedItem.getId());
redirectAttributes.addAttribute("status", true);
return "redirect:/validation/v1/items/{itemId}";
}
2. 검증 결과 전달
검증 결과는 Map<String, String> errors 에 담고, Model 에 errors 를 추가하여 다시 입력 폼으로 되돌린다. 필드 오류는 itemName, price, quantity, 전역 오류는 globalError 라는 키로 구분한다.
3. 뷰 템플릿 수정
글로벌 오류 출력
<div th:if="${errors?.containsKey('globalError')}">
<p class="field-error" th:text="${errors['globalError']}">전체 오류 메시지</p>
</div>
필드 오류 출력
<input type="text" th:field="*{itemName}"
th:class="${errors?.containsKey('itemName')} ? 'form-control field-error' : 'form-control'"
class="form-control" placeholder="이름을 입력하세요">
<div class="field-error" th:if="${errors?.containsKey('itemName')}" th:text="${errors['itemName']}"></div>
오류 스타일 적용
.field-error {
border-color: #dc3545;
color: #dc3545;
}
4. Safe Navigation 연산자
뷰에서 ${errors['itemName']} 같은 표현을 사용할 때 errors 가 null이면 NullPointerException 이 발생한다. 이를 방지하기 위해 ${errors?.containsKey('itemName')} 과 같이 Safe Navigation 연산자를 사용한다.
5. 남은 문제점
- 중복 처리: 각 필드마다 오류 처리를 반복해야 한다.
- 타입 오류 처리 불가: Integer 타입 필드에 문자열을 넣으면 컨트롤러 진입 전에 예외 발생 → 클라이언트는 어떤 오류인지 모르게 됨.
- 입력값 보존 문제: 타입 바인딩이 실패한 필드의 입력값은 사라진다.
BindingResult1
스프링 MVC는 폼 입력값에 대한 서버 측 검증을 매우 유연하게 처리할 수 있는 기능을 제공한다. 그 핵심은 BindingResult이다. 이를 통해 필드 오류와 글로벌 오류를 깔끔하게 처리할 수 있으며, 타임리프와 결합하면 검증 결과를 사용자에게 친절하게 보여줄 수 있다.
BindingResult 사용 방식
스프링 컨트롤러에서 검증 로직을 직접 작성할 경우, 다음과 같이 BindingResult를 파라미터로 받아 사용한다.
@PostMapping("/add")
public String addItemV1(@ModelAttribute Item item, BindingResult bindingResult,
RedirectAttributes redirectAttributes) {
주의할 점은 BindingResult는 반드시 @ModelAttribute 뒤에 와야 한다는 점이다.
필드 오류 처리: FieldError
다음은 상품명을 입력하지 않았을 경우 필드 오류를 처리하는 예시이다.
if (!StringUtils.hasText(item.getItemName())) {
bindingResult.addError(new FieldError("item", "itemName", "상품 이름은 필수입니다."));
}
- objectName: 모델 이름 (item)
- field: 오류가 발생한 필드 이름
- defaultMessage: 사용자에게 보여줄 기본 오류 메시지
가격과 수량에 대해서도 같은 방식으로 처리한다.
글로벌 오류 처리: ObjectError
가격 * 수량의 합계가 특정 조건을 만족해야 하는 경우는 다음과 같이 글로벌 오류로 처리한다.
if (item.getPrice() != null && item.getQuantity() != null) {
int resultPrice = item.getPrice() * item.getQuantity();
if (resultPrice < 10000) {
bindingResult.addError(new ObjectError("item",
"가격 * 수량의 합은 10,000원 이상이어야 합니다. 현재 값 = " + resultPrice));
}
}
오류 발생 시 입력 폼으로 복귀
오류가 하나라도 존재하면 bindingResult.hasErrors()를 통해 다시 입력 폼을 보여준다.
if (bindingResult.hasErrors()) {
return "validation/v2/addForm";
}
뷰 템플릿에서 검증 오류 처리
Thymeleaf는 BindingResult의 오류 정보를 쉽게 처리할 수 있는 기능을 제공한다.
<input type="text" id="itemName" th:field="*{itemName}"
th:errorclass="field-error" class="form-control" placeholder="이름을 입력하세요">
<div class="field-error" th:errors="*{itemName}">상품명 오류</div>
- th:errors: 해당 필드에 오류가 있으면 메시지를 출력
- th:errorclass: 필드에 오류가 있으면 클래스 정보 추가
- #fields.hasGlobalErrors(): 글로벌 오류가 있는지 체크
- #fields.globalErrors(): 글로벌 오류 목록을 반복문으로 출력 가능
<div th:if="${#fields.hasGlobalErrors()}">
<p class="field-error" th:each="err : ${#fields.globalErrors()}" th:text="${err}">글로벌 오류</p>
</div>
결론
- BindingResult를 사용하면 컨트롤러에서 손쉽게 필드/글로벌 오류를 처리할 수 있다.
- 타임리프는 BindingResult를 활용한 뷰 렌더링을 강력하게 지원한다.
- 검증 실패 시에도 입력값을 유지하며 오류 메시지를 친절하게 사용자에게 안내할 수 있다.
- 타입 변환 오류 등은 BindingResult가 컨트롤러 진입 전 처리하므로, 입력값을 유지하려면 추가 처리가 필요하다.
BindingResult2
BindingResult란?
스프링이 제공하는 검증 오류 저장 객체로, 검증 오류가 발생하면 해당 오류를 BindingResult에 보관한다.
핵심 기능은 다음과 같다:
- 검증 오류 발생 시 오류를 FieldError, ObjectError로 저장 가능
- @ModelAttribute 바인딩 시 발생하는 타입 오류도 컨트롤러 내부에서 처리 가능
- 오류 정보를 기반으로 입력값을 다시 사용자에게 전달하고, 메시지 출력 가능
@ModelAttribute 바인딩 시 타입 오류 발생 예
예를 들어 Item 객체의 price가 Integer인데, 사용자가 "문자"를 입력하면 바인딩 오류가 발생한다.
- BindingResult가 없을 경우
스프링 MVC는 400 Bad Request를 반환하고, 컨트롤러가 호출되지 않음. - BindingResult가 있을 경우
스프링은 FieldError 객체를 만들어 BindingResult에 넣고, 컨트롤러를 정상 호출한다.
BindingResult에 오류 적용하는 3가지 방법
1. 스프링이 자동으로 넣어줌
- @ModelAttribute 바인딩 과정에서 타입 오류 발생 시 자동으로 FieldError 생성 후 BindingResult에 저장
2. 개발자가 수동으로 추가
bindingResult.addError(new FieldError("item", "price", "가격은 숫자여야 합니다."));
3. Validator 사용 (다음 장에서 설명)
- 커스텀 Validator를 만들어 검증 로직을 외부로 분리할 수 있음
주의사항
1. BindingResult는 반드시 @ModelAttribute 다음에 위치해야 한다. 순서가 중요하다.
public String addItem(@ModelAttribute Item item, BindingResult bindingResult)
2. BindingResult는 Model에 자동 포함되므로 뷰에서 접근 가능하다.
BindingResult vs Errors
- BindingResult는 org.springframework.validation.Errors의 하위 인터페이스이다.
- Errors는 검증 오류 저장 및 조회만 가능
- BindingResult는 addError() 등 추가적인 기능 제공
- 일반적으로 BindingResult를 사용하는 것이 관례
문제점 및 다음 목표
지금까지는 오류 정보를 BindingResult에 저장하여 처리했다. 하지만 고객이 입력한 값이 오류 발생 시 사라지는 문제가 있다.
이제 이 문제를 해결하여, 입력값을 다시 화면에 유지하는 방법을 알아볼 것이다.
FieldError, ObjectError
목표
검증 오류가 발생하더라도 사용자가 입력한 값이 입력 폼에 그대로 남도록 한다.
예: 가격을 1000 미만으로 입력해도 폼에 입력했던 가격이 유지되어야 한다.
Controller 코드 예시
@PostMapping("/add")
public String addItemV2(@ModelAttribute Item item, BindingResult bindingResult,
RedirectAttributes redirectAttributes) {
if (!StringUtils.hasText(item.getItemName())) {
bindingResult.addError(new FieldError("item", "itemName",
item.getItemName(), false, null, null,
"상품 이름은 필수입니다."));
}
if (item.getPrice() == null || item.getPrice() < 1000 || item.getPrice() > 1000000) {
bindingResult.addError(new FieldError("item", "price",
item.getPrice(), false, null, null,
"가격은 1,000 ~ 1,000,000 까지 허용합니다."));
}
if (item.getQuantity() == null || item.getQuantity() >= 10000) {
bindingResult.addError(new FieldError("item", "quantity",
item.getQuantity(), false, null, null,
"수량은 최대 9,999 까지 허용합니다."));
}
if (item.getPrice() != null && item.getQuantity() != null) {
int resultPrice = item.getPrice() * item.getQuantity();
if (resultPrice < 10000) {
bindingResult.addError(new ObjectError("item", null, null,
"가격 * 수량의 합은 10,000원 이상이어야 합니다. 현재 값 = " + resultPrice));
}
}
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}";
}
FieldError 생성자 상세
public FieldError(
String objectName,
String field,
@Nullable Object rejectedValue,
boolean bindingFailure,
@Nullable String[] codes,
@Nullable Object[] arguments,
@Nullable String defaultMessage
)
파라미터 설명
- objectName: 오류가 발생한 객체 이름 (@ModelAttribute 이름)
- field: 오류가 발생한 필드명
- rejectedValue: 사용자가 입력한 값 (거절된 값)
- bindingFailure: 타입 바인딩 실패 여부 (false면 단순 검증 실패)
- codes: 메시지 코드 (MessageSource에서 사용됨)
- arguments: 메시지 코드에 넘길 인자
- defaultMessage: 기본 오류 메시지
ObjectError 생성자
public ObjectError(String objectName, @Nullable String[] codes, @Nullable Object[] arguments, String defaultMessage)
- 필드 오류가 아닌 객체 수준에서 발생하는 글로벌 오류에 사용
- 예: 가격 * 수량 < 10000원인 경우
사용자 입력 값 유지 방법
- FieldError의 rejectedValue에 사용자 입력값을 저장해 두면, 타임리프에서 자동으로 이 값을 사용함
- 따라서 검증 실패 후에도 폼에 입력했던 값이 유지됨
타임리프와 입력값 유지
<input type="text" th:field="*{price}">
- 타임리프 th:field는 검증 오류 시 FieldError.rejectedValue 값을 출력함
- 정상 상황이면 모델 객체의 값을 사용
스프링 바인딩 오류 처리 흐름
- 스프링은 바인딩 실패 시 내부적으로 FieldError를 생성하면서 사용자 입력 값을 rejectedValue로 보관
- 오류 정보는 BindingResult에 담긴 채 컨트롤러가 호출되므로, 오류 메시지 출력과 사용자 값 유지가 모두 가능
'Spring MVC' 카테고리의 다른 글
[MVC] 타임리프 - 메시지, 국제화 (0) | 2025.06.29 |
---|---|
[MVC] 타임리프 - 스프링 통합과 폼(2) (2) | 2025.06.24 |
[MVC] 타임리프 - 스프링 통합과 폼(1) (1) | 2025.06.21 |
[MVC] 타임리프 - 기본 기능(4) (0) | 2025.06.17 |
[MVC] 타임리프 - 기본 기능(3) (1) | 2025.06.14 |