Spring 스프링 MVC 2편 - 백엔드 웹 개발 활용 기술 - 4. 검증1 - Validation
4. 검증1 - Validation
검증 요구사항
상품 관리 시스템에 새로운 요구사항이 추가되었다.
요구사항: 검증 로직 추가
- 타입 검증
- 가격, 수량에 문자가 들어가면 검증 오류 처리
- 필드 검증
- 상품명: 필수, 공백X
- 가격: 1000원 이상, 1백만원 이하
- 수량: 최대 9999
- 특정 필드의 범위를 넘어서는 검증
- 가격 * 수량의 합은 10,000원 이상
컨트롤러의 중요한 역할중 하나는 HTTP 요청이 정상인지 검증하는 것이다.
참고: 클라이언트 검증, 서버 검증
- 클라이언트 검증은 조작할 수 있으므로 보안에 취약하다.
- 서버만으로 검증하면, 즉각적인 고객 사용성이 부족해진다.
- 둘을 적절히 섞어서 사용하되, 최종적으로 서버 검증은 필수
- API 방식을 사용하면 API 스펙을 잘 정의해서 검증 오류를 API 응답 결과에 잘 남겨주어야 함
BindingResult
- 스프링이 제공하는 검증 오류를 보관하는 객체이다. 검증 오류가 발생하면 여기에 보관하면 된다.
BindingResult
가 있으면@ModelAttribute
에 데이터 바인딩 시 오류가 발생해도 컨트롤러가 호출된다!
주의
BindingResult bindingResult
파라미터의 위치는@ModelAttribute Item item
다음에 와야 한다.
스프링의 바인딩 오류 처리
- 타입 오류로 바인딩에 실패하면 스프링은
FieldError
를 생성하면서 사용자가 입력한 값을 넣어둔다. - 그리고 해당 오류를
BindingResult
에 담아서 컨트롤러를 호출한다. - 따라서 타입 오류 같은 바인딩 실패시에도 사용자의 오류 메시지를 정상 출력할 수 있다.
1
new FieldError("item", "price", item.getPrice(), false, new String[] {"range.item.price"}, new Object[]{1000, 1000000})
FieldError 파라미터 목록
- objectName : 오류가 발생한 객체 이름
- field : 오류 필드
- rejectedValue : 사용자가 입력한 값(거절된 값)
- bindingFailure : 타입 오류 같은 바인딩 실패인지, 검증 실패인지 구분 값
- codes : 메시지 코드
- arguments : 메시지에서 사용하는 인자
- defaultMessage : 기본 오류 메시지
타임리프의 사용자 입력 값 유지
th:field="*{price}"
타임리프의 th:field
는 매우 똑똑하게 동작하는데, 정상 상황에는 모델 객체의 값을 사용하지만, 오류가 발생하면 FieldError
에서 보관한 값을 사용해서 값을 출력한다.
errors 메시지 파일 생성
messages.properties
를 사용해도 되지만, 오류 메시지를 구분하기 쉽게 errors.properties
라는 별도의 파일로 관리해보자.
1
2
3
4
required.item.itemName=상품 이름은 필수입니다.
range.item.price=가격은 {0} ~ {1} 까지 허용합니다.
max.item.quantity=수량은 최대 {0} 까지 허용합니다.
totalPriceMin=가격 * 수량의 합은 {0}원 이상이어야 합니다. 현재 값 = {1}
컨트롤러에서 BindingResult
는 검증해야 할 객체인 target
바로 다음에 온다. 따라서 BindingResult
는 이미 본인이 검증해야 할 객체인 target
을 알고 있다.
BindingResult 가 제공하는 rejectValue()
, reject()
를 사용하면 FieldError
, ObjectError
를 직접 생성하지 않고, 깔끔하게 검증 오류를 다룰 수 있다.
rejectValue()
1
2
3
if (!StringUtils.hasText(item.getItemName())) {
bindingResult.rejectValue("itemName", "required");
}
축약된 오류 코드
FieldError()
를 직접 다룰 때는 오류 코드를 range.item.price
와 같이 모두 입력했다. 그런데 rejectValue()
를 사용하고 부터는 오류 코드를 range 로 간단하게 입력했다. 그래도 오류 메시지를 잘 찾아서 출력한다.
동작 방식
rejectValue()
,reject()
는 내부에서MessageCodesResolver
를 사용한다. 여기에서 메시지 코드들을 생성한다.FieldError
,ObjectError
의 생성자를 보면, 오류 코드를 하나가 아니라 여러 오류 코드를 가질 수 있다.MessageCodesResolver
를 통해서 생성된 순서대로 오류 코드를 보관한다.이 부분을
BindingResult
의 로그를 통해서 확인해보자.required.item.itemName
required.itemName
required.java.lang.String
required
ObjectError reject("totalPriceMin")
다음 2가지 오류 코드를 자동으로 생성
- totalPriceMin.item
- totalPriceMin
오류 메시지 출력
타임리프 화면을 렌더링 할 때
th:errors
가 실행된다. 만약 이때 오류가 있다면 생성된 오류 메시지 코드를 순서대로 돌아가면서 메시지를 찾는다. 그리고 없으면 디폴트 메시지를 출력한다.
핵심은 구체적인 것에서! 덜 구체적인 것으로!
MessageCodesResolver
는 required.item.itemName
처럼 구체적인 것을 먼저 만들어주고, required
처럼 덜 구체적인 것을 가장 나중에 만든다. 이렇게 하면 앞서 말한 것 처럼 메시지와 관련된 공통 전략을 편리하게 도입할 수 있다.
ValidationUtils
1
2
3
if (!StringUtils.hasText(item.getItemName())) {
bindingResult.rejectValue("itemName", "required", "기본: 상품 이름은 필수입니다.");
}
다음과 같이 한줄로 가능, 제공하는 기능은 Empty , 공백 같은 단순한 기능만 제공
1
ValidationUtils.rejectIfEmptyOrWhitespace(bindingResult, "itemName", "required");
Validator 분리
- 컨트롤러에서 검증 로직이 차지하는 부분은 매우 크다. 이런 경우 별도의 클래스로 역할을 분리하는 것이 좋다. 그리고 이렇게 분리한 검증 로직을 재사용 할 수도 있다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
@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
WebDataBinder를 통해서 사용하기
WebDataBinder
는 스프링의 파라미터 바인딩의 역할을 해주고 검증 기능도 내부에 포함한다.
1
2
3
4
5
@InitBinder
public void init(WebDataBinder dataBinder) {
log.info("init binder {}", dataBinder);
dataBinder.addValidators(itemValidator);
}
이렇게 WebDataBinder 에 검증기를 추가하면 해당 컨트롤러에서는 검증기를 자동으로 적용할 수 있다.
@InitBinder
-> 해당 컨트롤러에만 영향을 준다.
1
2
3
4
5
6
7
8
9
10
11
12
@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()
가 호출된다.