요구사항: 검증 로직 추가
컨트롤러의 중요한 역할 중 하나는 HTTP 요청이 정상인지 검증하는 것이다.
참고: 클라이언트 검증, 서버 검증
클라이언트 쪽은 보안에 취약, 서버만으로 하면 즉각적인 고객 사용성이 부족해짐 --> 둘을 적절히 사용해야됨
이렇게 만들어 줄거다
그냥 Map<String,String> errors 에 각 필드마다 검증한 다음 넣어줌
만약 errors가 비지 않았다면 model 에 errors 를 담아서 넘겨준다.
이때 보통 다시 입력폼으로 되돌아 간다.
<div th:if="${errors?.containsKey('globalError')}">
<p class="field-error" th:text="${errors['globalError']}">전체 오류 메시지</p>
</div>
<input type="text" th:classappend="${errors?.containsKey('itemName')} ? 'fielderror' : _"
class="form-control">
대충 이런식으로 사용한다.
문제점들
1. 뷰 템플릿에서 중복 처리가 많음
2. 타입 오류 처리하기
3. 고객이 입력한 문자를 화면에 남겨둬야함
@PostMapping("/add")
public String addItemV1(@ModelAttribute Item item, BindingResult bindingResult,
RedirectAttributes redirectAttributes) {
if (!StringUtils.hasText(item.getItemName())) {
bindingResult.addError(new FieldError("item", "itemName", "상품 이름은
필수입니다."));
}
...
//특정 필드 예외가 아닌 전체 예외
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 bindingResult 파라미터의 위치는 @ModelAttribute Item item 다음에 와야 한다.
FieldError 생성자
public FieldError(String objectName, String field, String defaultMessage) {}
필드에 오류가 있으면 FieldError 객체를 생성해서 bindingResult에 담기
ObjectError 생성자
public ObjectError(String objectName, String defaultMessage) {}
특정 필드를 넘어서는 오류가 있으면 ObjectError 객체를 생성해서 bindingResult에 담아 준다.
당연한거겟지만 field error가 아니므로... field명 필요 없다.
bindingResult에 값이 담겨있으면 저절로 model 에 추가되는 듯..?
#field로 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)
파라미터 목록:
ObjectError 도 유사하게 두가지 생성자를 제공함
new FieldError("item", "price", item.getPrice(), false, null, null, "가격은 1,000 ~
1,000,000 까지 허용합니다.")
FieldError 는 오류 발생 시 사용자 입력 값을 저장하는 기능을 제공한다.
여기서 rejectedValue가 바로 오류 발생 시 사용자 입력 값을 저장하는 필드이다.
타입은 Object 타입임
타임리프에서 쓸 때는 th:field="*{price}" 같이 써주면 댐
정상 상황에서는 모델 객체의 값을 사용하는데, 오류가 발생하면 FieldError 에서 꺼내와준다.
목표!: 오류 메시지를 체계적으로 다루어보자 하하
FieldError와 ObjectError의 생성자를 보면
errorCode,arguments를 제공하는데 이것은 발생 시 오류코드로 메시지를 찾기 위해 사용된다.
메시지를 사용하기위해서 errors 메시지 파일을 생성해야댐
messages.properties를 사용해도 되는데 구분하기 쉽게 errors.properties라는 별도의 파일로 관리해보장
applicaition.properties 에 해당 메시지 파일을 인식할 수 있게 추가설정해주야 된다!!
spring.messages.basename=messages,errors
일단 errors.properties에 이렇게 추가해보자
required.item.itemName=상품 이름은 필수입니다.
range.item.price=가격은 {0} ~ {1} 까지 허용합니다.
max.item.quantity=수량은 최대 {0} 까지 허용합니다.
totalPriceMin=가격 * 수량의 합은 {0}원 이상이어야 합니다. 현재 값 = {1}
@PostMapping("/add")
public String addItemV3(@ModelAttribute Item item, BindingResult bindingResult,
RedirectAttributes redirectAttributes) {
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() > 10000) {
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));
}
}
if (bindingResult.hasErrors()) {
log.info("errors={}", bindingResult);
return "validation/v2/addForm";
}
대충 이렇게 수정해보면
FieldError 생성자 부분에 codes와 arguments들이 추가 되었다.
codes: 메시지 코드를 지정해준다. 메시지 코드는 하나가 안리ㅏ 배열로 여러 값을 전달할 수 있는데, 순서대로 매칭해서 처음 매칭되는 메시지가 사용된다.
arguments: 코드의 {0},{1},로 치환할 값을 전달해 주는 역할이다.
목표!:
FieldError,ObjectError는 다루기 너무 번거롭다..
오류 코드도 좀 더 자동화 할 수 있지 않을까 라는 생각을 해보자
컨트롤러에서 BindingResult는 검증해야 할 객체인 target 바로 다음에 온다. 따라서 BindingResult는 이미 본인이 검증해야 할 객체인 target을 알고 있음!!!
BindingResult가 제공하는 이것들을 사용하면 FieldError,ObjectError를 직접 생성하지 않고, 깔끔하게 검증 오류를 다룰 수 있다.
@PostMapping("/add")
public String addItemV4(@ModelAttribute Item item, BindingResult bindingResult,
RedirectAttributes redirectAttributes) {
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() > 10000) {
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}";
}
rejectValue()
void rejectValue(@Nullable String field, String errorCode,
@Nullable Object[] errorArgs, @Nullable String defaultMessage);
field: 오류 필드명
errorCode: 오류 코드 (messageResolver를 위한 오류 코드임!!!)
errorArgs:오류 메시지에서 {0}을 치환하기 위한 값
defalutMessage: 오류 메시지 못찾으면 기본 메시지 출력함
bindingResult.rejectValue("price", "range", new Object[]{1000, 1000000}, null)
이걸 보면 bidingResult는 어떤 객체를 대상으로 검증하는 지 target을 이미 알고 있으니깐.. 따라서 fielderror나 objecterror에서 사용하는 obejctName에 대한 정보가 넘어갈 필요가 없다.
축약된 오류 코드
FiledError()를 직접 다룰 때는 오류 코들르 range.item.price와 같이 모두 입력했다. 그런데 rejectValue()를 사용하고 부터는 오류 코드를 range로 간단하게 입력햇다.
이해하려면 MessageCodesResolver를 이해해야한다.
먼저 reject() 부터 살펴 보자
이건 걍 obejctError 랑 매칭되는 애 같음
오류 코드를 만들 때 다음과 같이 자세히 만들 수도 있고,
required.item.itemName : 상품 이름은 필수 입니다.
range.item.price : 상품의 가격 범위 오류 입니다.
또는 다음과 같이 단순하게 만들 수도 있다!
required : 필수 값 입니다.
range : 범위 오류 입니다.
단순하게 만들면 범용성이 좋아서 여러곳에서 사용할 수 있지만, 메시지를 세밀하게 작성하기 어렵다. 반대로 너무 자세학 ㅔ만들면 범용성이 떨어진다. 가장 좋은 방법은 범용성으로 사용하다가, 세밀하게 작성해야 하는 경우에는 세밀한 내용이 적용되도록 메시지에 단계를 두는 방법이다.
#Level1
required.item.itemName: 상품 이름은 필수 입니다.
#Level2
required: 필수 값 입니다.
구체적인 것이 우선순위가 높다.
MessageCodesResolver를 알아보자
public class MessageCodesResolverTest {
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);
assertThat(messageCodes).containsExactly(
"required.item.itemName",
"required.itemName",
"required.java.lang.String",
"required"
);
}
}
!!!!!!!!!!!DefaultMessageCodesResolver의 기본 메시지 생성 규칙!!!!!!!!!!!!
1.객체 오류일 때(ObjectError)
객체 오류의 경우 다음 순서로 2가지 생성
1.: code + "." + object name
2.: code
예) 오류 코드: required, object name: item
1.: required.item
2.: required
참고로 reject()는 이거임
void reject(String errorCode, @Nullable Object[] errorArgs, @Nullable String
defaultMessage);
2.필드 오류일 때(FieldError)
필드 오류의 경우 다음 순서로 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()는 이거임
void rejectValue(@Nullable String field, String errorCode,
@Nullable Object[] errorArgs, @Nullable String defaultMessage);
오류 메시지 출력
타임리프 화면을 렌더링 할 때 th:errors 가 실행된다. 만약 이때 오류가 있다면 생성된 오류 메시지
코드를 순서대로 돌아가면서 메시지를 찾는다. 그리고 없으면 디폴트 메시지를 출력한다.
오류 코드 관리 전략
핵심은 구체적인 것에서 덜 구체적인 것으로!
우선 errors.properties를 다음과 같이 만들어 준다.
#required.item.itemName=상품 이름은 필수입니다.
#range.item.price=가격은 {0} ~ {1} 까지 허용합니다.
#max.item.quantity=수량은 최대 {0} 까지 허용합니다.
#totalPriceMin=가격 * 수량의 합은 {0}원 이상이어야 합니다. 현재 값 = {1}
#==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} 까지 허용합니다.
이거는 직접 눈으로 하나하나 파악해보려고한다
ValidationUtils
다음 4가지 오류 코드를 자동으로 생성
1.required.item.itemName
2.required.itemName
3.required.java.lang.String
4.required
1.range.item.price
2.range.price
3.range.java.lang.String
4.range
여기서 1번이 걸리게 된다..
1.max.item.quantity
2.max.quantity
3.max.java.lang.String
4.max
여기서도 1번이 걸림..
정리:
1. rejectValue() 호출
2. MessageCodesResolver를 사용해서 검증 오류 코드로 메시지 코드들을 생성
3. new FieldError()를 생성하면서 메시지 코드들을 보관
4. th:errors에서 메시지 코드들로 메시지를 순서대로 메시지에서 찾고, 노출시켜준다.!
스프링이 직접 만든 오류 메시지 처리
price 필드에 문자 'A'를 입력해보면
BindingResult에 FieldError가 담겨있고, 다음과 같은 메시지 코드들이 생성된다.
스프링은 타입 오류가 발생하면 typeMismatch라는 오류 코드를 사용한다. 이 오류 코드가 MessageCodesResolver를 통하면서 4가지 메시지 코드가 생성된 것이다.
error.properties에 다음 내용을 추가하면 된다.
validate 디렉토리 만들어서 ItemValidator을 만들어준다.
@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) {
Item item = (Item) target;
ValidationUtils.rejectIfEmptyOrWhitespace(errors, "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() > 10000) {
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);
}
}
}
}
supports() {} : 해당 검증기를 지원하는 지에 대한 여부 확인
validate(Object target,Errors errors): 검증 대상 객체와 BindingResult
private final ItemValidator itemValidator ; 선언 후
itemValidator.validate(item,bindingResult); 넣어서 사용해주면 된다.
WebDataBinder를 통해서 사용하기
@InitBinder
public void init(WebDataBinder dataBinder) {
log.info("init binder {}", dataBinder);
dataBinder.addValidators(itemValidator);
}
@PostMapping("/add")
public String addItemV6(@Validated @ModelAttribute Item item, BindingResult
bindingResult, RedirectAttributes redirectAttributes) {
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를 직접 호출하는 부분 사라지고, 검증 대상객체 앞에 @Validated 가 붙었다.
@Validated 는 검증기를 실행하라는 애노테이션이다.
이 애노테이션이 붙으면 앞서 WebDataBinder 에 등록한 검증기를 찾아서 실행한다. 그런데 여러 검증기를
등록한다면 그 중에 어떤 검증기가 실행되어야 할지 구분이 필요하다. 이때 supports() 가 사용된다.
여기서는 supports(Item.class) 호출되고, 결과가 true 이므로 ItemValidator 의 validate() 가
호출된다.