검증1 - Validation
검증 직접 처리
- 사용자가 상품 등록 폼에서 정상 범위의 데이터 입력
- 서버에서 검증 로직 통과하고 상품 저장
- 상품 상세 화면으로 리다이렉트
- 사용자가 검증 범위를 넘어서는 데이터 입력
- 서버 검증 로직 실패
- 사용자에게 상품 등록 폼을 다시 보여주고 어떤 값을 잘못 입력했는지 알림
상품 등록 검증 Spring
@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}";
}
상품 등록 검증 HTML
글로벌 오류 메시지
<div th:if="${errors?.containsKey('globalError')}">
<p class="field-error" th:text="${errors['globalError']}">전체 오류 메시지</p>
</div>
- 타임리프의
th:if
를 사용하면 조건에 만족할 때만 해당 HTML 태그를 출력
참고 Safe Navigation Operator
errors?.
은errors
가null
일때NullPointerException
이 발생하는 대신,null
을 반환하는 문법th:if
에서null
은 실패로 처리되므로 오류 메시지가 출력되지 않음 참고: https://docs.spring.io/spring-framework/docs/current/reference/html/core.html#expressions-operator-safe-navigation
필드 오류 처리
<input type="text" th:classappend="${errors?.containsKey('itemName')} ? 'fielderror' : _" class="form-control">
classappend
: 해당 필드에 오류가 있으면field-error
라는 클래스 정보를 더함- 값이 없으면
_
(No-Operation)을 사용하여 아무 일도 하지 않음
직접 처리 문제점
- 뷰 템플릿에서 중복된 코드가 많음
- 타입 오류 처리 불가능
Item
의price
,quantity
같은 숫자 필드는 타입이Integer
이므로 문자 타입으로 설정하는 것이 불가능- 숫자 타입에 문자가 들어오면 오류가 발생하나 이러한 오류는 스프링MVC에서 컨트롤러에 진입하기도 전에 예외가 발생하기 때문에, 컨트롤러가 호출되지도 않고, 400 예외가 발생
Item
의price
에 문자를 입력하는 것처럼 타입 오류가 발생해도 고객이 입력한 문자를 화면에 남겨야 함- 만약 컨트롤러가 호출된다고 가정해도
Item
의price
는Integer
이므로 문자를 보관할 수가 없음 - 결국 문자는 바인딩이 불가능하므로 고객이 입력한 문자가 사라지게 되고, 고객은 본인이 어떤 내용을 입력해서 오류가 발생했는지 이해하기 어려움
- 만약 컨트롤러가 호출된다고 가정해도
BindingResult
필드 오류 - FieldError
if (!StringUtils.hasText(item.getItemName())) {
bindingResult.addError(new FieldError("item", "itemName", "상품 이름은 필수입니다."));
}
FieldError 생성자 요약
public FieldError(String objectName, String field, String defaultMessage) {}
objectName
:@ModelAttribute
이름field
: 오류가 발생한 필드 이름defaultMessage
: 오류 기본 메시지
글로벌 오류 - ObjectError
bindingResult.addError(new ObjectError("item", "가격 * 수량의 합은 10,000원 이상이어야 합니다. 현재 값 = " + resultPrice));
ObjectError 생성자 요약
public ObjectError(String objectName, String defaultMessage) {}
objectName
:@ModelAttribute
이름defaultMessage
: 오류 기본 메시지
타임리프 스프링 검증 오류 통합 기능
#fields
:#fields
로BindingResult
가 제공하는 검증 오류에 접근th:errors
: 해당 필드에 오류가 있는 경우에 태그를 출력th:errorclass
:th:field
에서 지정한 필드에 오류가 있으면class
정보를 추가- 검증과 오류 메시지 공식 메뉴얼: https://www.thymeleaf.org/doc/tutorials/3.0/thymeleafspring.html#validation-anderror-messages
글로벌 오류 처리
<div th:if="${#fields.hasGlobalErrors()}">
<p class="field-error" th:each="err : ${#fields.globalErrors()}" th:text="${err}">전체 오류 메시지</p>
</div>
필드 오류 처리
<input type="text" id="itemName" th:field="*{itemName}"
th:errorclass="field-error" class="form-control" placeholder="이름을
입력하세요">
<div class="field-error" th:errors="*{itemName}">
상품명 오류
</div>
BindingResult
- 스프링이 제공하는 검증 오류를 보관하는 객체
- BindingResult 가 있으면
@ModelAttribute
에 데이터 바인딩 시 오류가 발생해도 컨트롤러 호출 - 예)
@ModelAttribute
에 바인딩 시 타입 오류가 발생하면?BindingResult
가 없으면 400 오류가 발생하면서 컨트롤러가 호출되지 않고, 오류 페이지로 이동BindingResult
가 있으면 오류 정보(FieldError
)를BindingResult
에 담아서 컨트롤러를 정상 호출
- BindingResult 는 검증할 대상 바로 다음에 와야한다. 순서가 중요하다. 예를 들어서
@ModelAttribute Item item
, 바로 다음에BindingResult
가 와야 함 BindingResult
는Model
에 자동으로 포함됨
BindingResult에 검증 오류를 적용하는 3가지 방법
@ModelAttribute
의 객체에 타입 오류 등으로 바인딩이 실패하는 경우 스프링이FieldError
생성해서BindingResult
에 포함- 개발자가 직접 적용
- Validator 사용
BindingResult와 Errors
org.springframework.validation.Errors
org.springframework.validation.BindingResult
BindingResult
는 인터페이스이고,Errors
인터페이스를 상속- 실제 넘어오는 구현체는
BeanPropertyBindingResult
라는 것인데, 둘다 구현하고 있으므로BindingResult
대신에Errors
를 사용 가능 Errors
인터페이스는 단순한 오류 저장과 조회 기능을 제공BindingResult
는 여기에 더해서 추가적인 기능들을 제공addError()
도BindingResult
가 제공- 주로 관례상
BindingResult
를 많이 사용한다.
FieldError, ObjectError
FieldError 생성자
public FieldError(String objectName, String field, String defaultMessage);
public FieldError(String objectName, String field, @Nullable Object
rejectedValue, boolean bindingFailure, @Nullable String[] codes, @Nullable Object[] arguments, @Nullable String defaultMessage)
파라미터 목록
objectName
: 오류가 발생한 객체 이름field
: 오류 필드rejectedValue
: 사용자가 입력한 값(거절된 값)bindingFailure
: 타입 오류 같은 바인딩 실패인지, 검증 실패인지 구분 값codes
: 메시지 코드arguments
: 메시지에서 사용하는 인자defaultMessage
: 기본 오류 메시지
ObjectError
도 유사하게 두 가지 생성자를 제공
오류 발생시 사용자 입력 값 유지
new FieldError("item", "price", item.getPrice(), false, null, null, "가격은 1,000 ~ 1,000,000 까지 허용합니다.")
- 사용자의 입력 데이터가 컨트롤러의
@ModelAttribute
에 바인딩되는 시점에 오류가 발생하면 모델 객체에 사용자 입력 값을 유지하기 어려움 - 예를 들어서 가격에 숫자가 아닌 문자가 입력된다면 가격은
Integer
타입이므로 문자를 보관할 수 있는 방법이 없음 - 오류가 발생한 경우 사용자 입력 값을 보관하는 별도의 방법이 필요
FieldError
는 오류 발생시 사용자 입력 값을 저장하는 기능을 제공
타임리프의 사용자 입력 값 유지
th:field="*{price}"
타임리프의 th:field
는 매우 똑똑하게 동작하는데, 정상 상황에는 모델 객체의 값을 사용하지만, 오류가 발생하면 FieldError
에서 보관한 값을 사용해서 값을 출력
스프링의 바인딩 오류 처리
- 타입 오류로 바인딩에 실패하면 스프링은
FieldError
를 생성하면서 사용자가 입력한 값을 보관 - 해당 오류를
BindingResult
에 담아서 컨트롤러를 호출한다. 따라서 타입 오류 같은 바인딩 실패 시에도 사용자의 오류 메시지를 정상 출력
오류 코드와 메시지 처리
FieldError
,ObjectError
의 생성자는errorCode
,arguments
를 제공- 오류 발생시 오류 코드로 메시지를 찾기 위해 사용
errors 메시지 파일 생성
스프링 부트 메시지 설정 추가
application.properties
spring.messages.basename=messages,errors
errors.properties
required.item.itemName=상품 이름은 필수입니다.
range.item.price=가격은 {0} ~ {1} 까지 허용합니다.
max.item.quantity=수량은 최대 {0} 까지 허용합니다.
totalPriceMin=가격 * 수량의 합은 {0}원 이상이어야 합니다. 현재 값 = {1}
참고:
errors_en.properties
파일을 생성하면 오류 메시지도 국제화 처리 가능
//range.item.price=가격은 {0} ~ {1} 까지 허용합니다.
new FieldError("item", "price", item.getPrice(), false, new String[]
{"range.item.price"}, new Object[]{1000, 1000000}
codes
:required.item.itemName
를 사용해서 메시지 코드를 지정- 메시지 코드는 하나가 아니라 배열로 여러 값을 전달할 수 있는데, 순서대로 매칭해서 처음 매칭되는 메시지가 사용됨
arguments
:Object[]{1000, 1000000}
를 사용해서 코드의{0}
,{1}
로 치환할 값을 전달
rejectValue(), reject()
BindingResult
가 제공하는rejectValue()
,reject()
를 사용하면FieldError
,ObjectError
를 직접 생성하지 않아도 됨
rejectValue()
void rejectValue(@Nullable String field, String errorCode,
@Nullable Object[] errorArgs, @Nullable String defaultMessage);
reject()
void reject(String errorCode, @Nullable Object[] errorArgs, @Nullable String defaultMessage);
field
: 오류 필드명errorCode
: 오류 코드(messageResolver
를 위한 오류 코드)errorArgs
: 오류 메시지에서{0}
을 치환하기 위한 값defaultMessage
: 오류 메시지를 찾을 수 없을 때 사용하는 기본 메시지
bindingResult.rejectValue("price", "range", new Object[]{1000, 1000000}, null)
BindingResult
는 어떤 객체를 대상으로 검증하는지target
을 이미 알고 있으므로target
에 대한 정보는 없어도 됨
MessageCodesResolver
- 검증 오류 코드로 메시지 코드들을 생성
MessageCodesResolver
는 인터페이스이고DefaultMessageCodesResolver
가 기본 구현체- 주로
ObjectError
,FieldError
와 함께 사용
DefaultMessageCodesResolver의 기본 메시지 생성 규칙
객체 오류
객체 오류의 경우 다음 순서로 2가지 생성
1. code + "." + object name
2. code
예) 오류 코드: required, object name: item
1. required.item
2. required
필드 오류
필드 오류의 경우 다음 순서로 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
로 메시지 코드들을 생성MessageCodesResolver
를 통해서 생성된 순서대로 오류 코드를 보관
오류 메시지 출력
- 타임리프 화면을 렌더링 할 때
th:errors
가 실행 - 만약 오류가 있다면 생성된 오류 메시지 코드를 순서대로 돌아가면서 메시지를 검색, 없으면 디폴트 메시지를 출력
오류 코드 관리 전략
MessageCodesResolver
는required.item.itemName
처럼 구체적인 것을 먼저 만들어주고,required
처럼 덜 구체적인 것을 가장 나중에 생성- 크게 중요하지 않은 메시지는 범용성 있는
requried
같은 메시지로 끝내고, 정말 중요한 메시지는 꼭 필요할 때 구체적으로 적어서 사용하는 방식이 더 효과적
ValidationUtils
ValidationUtils.rejectIfEmptyOrWhitespace(bindingResult, "itemName", "required");
- 조건문 대신 사용할 수 있음
Empty
, 공백 같은 단순한 기능만 제공
스프링이 직접 만든 오류 메시지 처리
- 검증 오류 코드는 다음과 같이 2가지로 나눌 수 있음
- 개발자가 직접 설정한 오류 코드 →
rejectValue()
를 직접 호출 - 스프링이 직접 검증 오류에 추가한 경우(주로 타입 정보가 맞지 않음)
- 개발자가 직접 설정한 오류 코드 →
- 스프링은 타입 오류가 발생하면
typeMismatch
라는 오류 코드를 사용 error.properties
에 아래와 같은 내용을 추가하면 소스 코드를 수정하지 않고 메시지 처리가 가능
typeMismatch.java.lang.Integer=숫자를 입력해주세요.
typeMismatch=타입 오류입니다.
Validator 분리
Validator 인터페이스
public interface Validator {
boolean supports(Class<?> clazz);
void validate(Object target, Errors errors);
}
supports()
: 해당 검증기를 지원하는 여부 확인validate(Object target, Errors errors)
: 검증 대상 객체와BindingResult
WebDataBinder
@InitBinder
public void init(WebDataBinder dataBinder) {
log.info("init binder {}", dataBinder);
dataBinder.addValidators(itemValidator);
}
WebDataBinder
는 스프링의 파라미터 바인딩의 역할을 해주고 검증 기능도 내부에 포함- 이렇게
WebDataBinder
에 검증기를 추가하면 해당 컨트롤러에서는 검증기를 자동으로 적용 @InitBinder
: 해당 컨트롤러에만 영향. 글로벌 설정은 별도
@Validated
- 이 애노테이션이 붙으면 앞서
WebDataBinder
에 등록한 검증기를 찾아서 실행 - 그런데 여러 검증기를 등록한다면 각 검증기의
supports()
사용하여 구분 - 아래와 같은 검증기에서는
supports(Item.class)
가 호출되고, 결과가true
이므로ItemValidator
의validate()
가 호출
@Component
public class ItemValidator implements Validator {
@Override
public boolean supports(Class<?> clazz) {
return Item.class.isAssignableFrom(clazz);
}
@Override
public void validate(Object target, Errors errors) {...}
}
글로벌 설정
@SpringBootApplication
public class ItemServiceApplication implements WebMvcConfigurer {
public static void main(String[] args) {
SpringApplication.run(ItemServiceApplication.class, args);
}
@Override
public Validator getValidator() {
return new ItemValidator();
}
}
주의
- 글로벌 설정을 하면 다음에 설명할 BeanValidator가 자동 등록되지 않음
- 참고로 글로벌 설정을 직접 사용하는 경우는 드묾