사용자가 특정 차트를 고르면, 전 종목의 과거(10년) 차트들을 모두 탐색하여 가장 유사한 차트 10개를 골라 사용자에게 보여줍니다.
비슷한 차트 검색기
전 종목의 최근 10년간 모든 차트를 탐색합니다. 내 종목의 차트는 과연 상승하는 차트일까요?
www.similarchart.com
김영한 선생님의 스프링 MVC 2편 강의를 듣고 정리한 내용이다.
4. 검증 1 - Validation
컨트롤러의 중요한 역할 중 하나는 HTTP 요청이 정상인지 검증하는 것이다. 그리고 정상 로직보다 이런 검증 로직을 잘 개발하는 것이 어쩌면 더 어려울 수 있다.
4.1. 검증 직접 처리
@PostMapping("/add") //실제 저장
public String addItem(@ModelAttribute Item item, RedirectAttributes redirectAttributes, Model model) {
//검증 오류 결과를 보관
Map<String, String> errors = new HashMap<>();
//검증 로직 - itemName에 글자가 없을 경우
if(!StringUtils.hasText(item.getItemName())){
errors.put("itemName", "상품 이름은 필수입니다.");
}
//검증 로직 - itemPrice가 범위를 넘어설 경우
if (item.getPrice() == null || item.getPrice() < 1000 || item.getPrice() > 1000000) {
errors.put("price", "가격은 1,000 ~ 1,000,000 까지 허용합니다.");
}
//검증 로직 - itemQuantity의 수량 검즘
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", "가격 * 수량의 값이 10000원 이상이여야 합니다. 현재 는 " + 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}";
}
보완점
- 타입 오류 처리 미비
- 타입 오류 시 입력 내용이 사라짐
- 이 때문에 스프링이 제공하는 입력방법을 사용함.
4.2. Binding Result
BindingResult bindingResult
파라미터의 위치는 @ModelAttribute Item item
다음에 와야 한다.
@PostMapping("/add") //실제 저장
public String addItemV1(@ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model) {
//검증 오류 결과를 보관
Map<String, String> errors = new HashMap<>();
//검증 로직 - itemName에 글자가 없을 경우
if(!StringUtils.hasText(item.getItemName())){
bindingResult.addError(new FieldError("item", "itemName", "상뭄 이름은 필수입니다."));
}
//검증 로직 - itemPrice가 범위를 넘어설 경우
if (item.getPrice() == null || item.getPrice() < 1000 || item.getPrice() > 1000000) {
bindingResult.addError(new FieldError("item", "price", "가격은 1,000 ~ 1,000,000 까지 허용합니다."));
}
//검증 로직 - itemQuantity의 수량 검즘
if (item.getQuantity() == null || item.getQuantity() >= 9999) {
bindingResult.addError(new FieldError("item", "quantity", "수량은 최대 9,999 까지 허용합니다."));
}
// 특정 필드가 아닌 복합 룰 검증
if (item.getPrice() != null && item.getQuantity() != null){
int resultPrice = item.getPrice() * item.getQuantity();
if(resultPrice < 10000){
// 글로벌에러는 ObjectError를 사용한다.
bindingResult.addError(new ObjectError("item", "가격 * 수량의 값이 10000원 이상이여야 합니다. 현재 는 " + resultPrice + "입니다"));
}
}
// 검증을 모두 실행한 이후에는, 다시 입력폼으로 돌아가야함
if(bindingResult.hasErrors()){ // 만약 에러가 있었다면의 표현 방식이 바뀜
// 자동으로 뷰에 넣기 때문에 Model에 담을 필요가 없음
return "validation/v2/addForm"; //입력폼 템플릿으로 보내버리기
}
// 예외사항 안타면 성공 로직
Item savedItem = itemRepository.save(item);
redirectAttributes.addAttribute("itemId", savedItem.getId());
redirectAttributes.addAttribute("status", true);
return "redirect:/validation/v2/items/{itemId}";
}
타임리프 스프링 검증 오류 통합 기능
타임리프는 스프링의 BindingResult
를 활용해서 편리하게 검증 오류를 표현하는 기능을 제공한다.
#fields
:#fields로
BindingResult
가 제공하는 검증 오류에 접근할 수 있다.<div th:if="${#fields.hasGlobalErrors()}">
th:errors
: 해당 필드에 오류가 있는 경우에 태그를 출력한다.th:if의
편의 버전이다.<div class="field-error" th:errors="*{quantity}">
th:errorclass
:th:field에서
지정한 필드에 오류가 있으면class
정보를 추가한다.<div th:errorclass="field-error" class="form-control">
문제점
오류가 발생했을 때, 데이터가 유지되지 않는 단점이 있다.
4.3. FieldError, ObjectError
입력한 값을 화면에 남겨보자FieldError
와 ObjectError
는 유사하게 크게 두 가지의 생성자를 가진다.
FieldError 생성자
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
: 기본 오류 메시지
bindingResult.addError(new FieldError("item", "itemName",item.getItemName(),false,null,null, "상품 이름은 필수입니다."));
//세번째 파라미터(rejectedValue) 가 사용자가 입력한 값(거절된 값)이다.
bindingResult.addError(new ObjectError("item",null,null, "가격 * 수량의 값이 10000원 이상이여야 합니다. 현재 는 " + resultPrice + "입니다"));
타임리프의 사용자 입력 값 유지
th:field="*{price}"
타임리프의 th:field
는 매우 똑똑하게 동작하는데, 정상 상황에는 모델 객체의 값을 사용하지만, 오류가
발생하면 FieldError에서
보관한 값을 사용해서 값을 출력한다.
스프링의 바인딩 오류 처리
타입 오류로 바인딩에 실패하면 스프링은 FieldError를
생성하면서 사용자가 입력한 값을 넣어둔다.
그리고 해당 오류를 BindingResult에
담아서 컨트롤러를 호출한다. 따라서 타입 오류 같은 바인딩
실패 시에도 사용자의 오류 메시지를 정상 출력할 수 있다.
4.4. 오류코드와 메시지 처리
위에서 봤듯이 FieldError
, ObjectError
의 생성자는 codes
, arguments를
제공한다. 이것은 오류 발생 시 오류코드로 메시지를 찾기 위해 사용된다.
1단계
errors.properties
를 만든다.- 스프링 부트가 파일을 인식할 수 있게 다음 문장을 추가한다.
spring.messages.basename=messages, errors
errors
에 등록된 메시지를 사용해 본다.
예)range.item.price=가격은 {0} ~ {1}까지 허용합니다
// 코드는 String 배열로 넘긴다. bindingResult.addError(new FieldError("item", "price",item.getPrice(),false,new String[]{"range.item.price"},new Object[]{1000,1000000}, null));
2단계
- 위와 같은 과정이 조금 번거로워서, 보다 자동화를 거친다.
rejectValue()
,reject()
를 사용하면FieldError
,ObjectError
를 사용하지 않고 깔끔하게 검증 오류를 다룰 수 있다.BindingResult는
어떤 객체를 대상으로 검증하는지 target을 이미 알고 있다. 따라서target(item)
에 대한 정보는 없어도 된다. 오류 필드명은 동일하게quantity를
사용했다.bindingResult.rejectValue("quantity", "max", new Object []{9999}, null);
3단계
오류 코드를 만들 때 다음과 같이 자세히 만들 수도 있고,
required.item.itemName
: 상품 이름은 필수입니다.range.item.price
: 상품의 가격 범위 오류입니다.
또는 다음과 같이 단순하게 만들 수도 있다.
required
: 필수 값입니다.range
: 범위 오류입니다.
그런데 오류 메시지에 required.item.itemName 와 같이 객체명과 필드명을 조합한 세밀한 메시지 코드가 있으면 이 메시지를 높은 우선순위로 사용하는 것이다.
#Level1
required.item.itemName: 상품 이름은 필수 입니다.
#Level2
required: 필수 값 입니다.
스프링은 MessageCodesResolver라는
것으로 이러한 기능을 지원한다.
4단계
FieldError rejectValue("itemName", "required")
다음 4가지 오류 코드를 자동으로 생성
- required.item.itemName
- required.itemName
- required.java.lang.String
- required
ObjectError reject("totalPriceMin")
다음 2가지 오류 코드를 자동으로 생성
- totalPriceMin.item
- totalPriceMin
@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"
);
}
5단계
** 핵심은 구체적인 것에서! 덜 구체적인 것으로!**MessageCodesResolver는 required.item.itemName처럼
구체적인 것을 먼저 만들어주고, required처럼
덜 구체적인 것을 가장 나중에 만든다.
이렇게 하면 앞서 말한 것처럼 메시지와 관련된 공통 전략을 편리하게 도입할 수 있다.
ValidationUtils
ValidationUtils
사용 전
if (!StringUtils.hasText(item.getItemName())) {
bindingResult.rejectValue("itemName", "required", "기본: 상품 이름은 필수입니다.");
}
ValidationUtils
사용 후
다음과 같이 한 줄로 가능, 제공하는 기능은 Empty , 공백 같은 단순한 기능만 제공
ValidationUtils.rejectIfEmptyOrWhitespace(bindingResult, "itemName", "required");
6단계
검증 오류 코드는 다음과 같이 2가지로 나눌 수 있다.
- 개발자가 직접 설정한 오류 코드
rejectValue()를
직접 호출 - 스프링이 직접 검증 오류에 추가한 경우(주로 타입 정보가 맞지 않음)
타입 오류 발생 시 에러 메시지 코드엔 다음과 같이 4가지 메시지 코드가 입력되어 있다.
typeMismatch.item.price
typeMismatch.price
typeMismatch.java.lang.Integer
typeMismatch
errors.properties 에 메시지 코드가 없으면 스프링이 생성한 기본 메시지가 출력된다.
Failed to convert property value of type java.lang.String to required type
java.lang.Integer for property price;
nested exception is java.lang.NumberFormatException: For input string: "A"
error.properties
에 다음 내용을 추가하면 소스코드를 건들지 않고, 원하는 메시지를 단계별로 설정할 수 있다.
typeMismatch.java.lang.Integer=숫자를 입력해주세요.
typeMismatch=타입 오류입니다.
정리
메시지 코드 생성 전략은 그냥 만들어진 것이 아니다. 조금 뒤에서 Bean Validation
을 학습하면 그 진가를 더 확인할 수 있다.
4.5. validiator 분리
컨트롤러에서 검증 로직이 차지하는 부분은 매우 크다. 이런 경우 별도의 클래스로 역할을 분리하는 것이 좋다. 그리고 이렇게 분리한 검증 로직을 재사용할 수도 있다.
위에서 작성했던 검증 로직들을 itemValidator
클래스에 떼어내고,
@InitBinder
public void init(WebDataBinder dataBinder) {
log.info("init binder {}", dataBinder);
dataBinder.addValidators(itemValidator);
}
컨트롤러에 위 내용을 추가하고, WebDataBinder
에 검증 기를 추가하면 해당 컨트롤러에서는 검증기를 자동으로 적용할 수 있다.
@InitBinder
-> 해당 컨트롤러에만 영향을 준다. 글로벌 설정은 별도로 해야 한다.
2단계
@PostMapping("/add")
public String addItemV6(@Validated @ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes) {
// @Validated라는 것을 넣어줘야 아이템에 대해서 자동으로 검증해줌
// 검증이 여러개 올 경우 서포트로 관리한다.
if (bindingResult.hasErrors()) {
return "validation/v2/addForm";
}
}
validator
를 직접 호출하는 부분이 사라지고, 대신에 검증 대상 앞에 @Validated
가 붙었다.
@Validated는
검증 기를 실행하라는 애노테이션이다.
이 애노테이션이 붙으면 앞서 WebDataBinder에
등록한 검증 기를 찾아서 실행한다. 그런데 여러 검증기를 등록한다면 그중에 어떤 검증기가 실행되어야 할지 구분이 필요하다. 이때 supports()가
사용된다.
여기서는 supports(Item.class)
호출되고, 결과가 true
이므로 ItemValidator의
validate()가
호출된다
글로벌 설정
@SpringBootApplication
public class ItemServiceApplication implements WebMvcConfigurer {
public static void main(String[] args) {
SpringApplication.run(ItemServiceApplication.class, args);
}
@Override
public Validator getValidator() {
return new ItemValidator();
}
}
이렇게 글로벌 설정을 추가할 수 있다. 기존 컨트롤러의 @InitBinder를
제거해도 글로벌 설정으로 정상 동작하는 것을 확인할 수 있다.
'스프링' 카테고리의 다른 글
스프링 MVC 2 정리 - 6. 로그인 처리 1 - 쿠키, 세션 (22.8.10) (0) | 2024.02.13 |
---|---|
스프링 MVC 2 정리 - 5. 검증2 - Bean Validation (22.8.9) (0) | 2024.02.13 |
스프링 MVC 2 정리 - 2. 타임리프 - 스프링 통합과 폼 / 3. 메세지 국제화 (22.8.7) (0) | 2024.02.13 |
스프링 MVC 2 정리 - 1. 타임리프 기본 기능 (22.8.6) (0) | 2024.02.13 |
스프링 MVC 1편 완강기념 정리 (22.7.25) (0) | 2024.02.13 |