Validation
개요
웹 어플리케이션 폼에서 숫자를 문자로 작성하는 등의 오류가 발생할 수 있다. 이러한 처리를 별도로 해주지 않는다면 사용자는 오류 페이지로 이동하고, 처음부터 다시 입력해야 하는 문제가 생긴다. (실제 서비스에 적합하지 않다). 실제로 사용되는 웹 서비스들은 오류가 발생하면, 고객이 입력해놓은 데이터를 유지한 상태로 오류 현황을 알려주곤 한다.
타입 검증: 가격, 수량에 문자가 들어가는 등의 오류
필드 검증: 공백이 허용되지 않거나, 수 범위가 설정되는 등의 제약
필드 조합 검증: 여러 필드의 값을 조합하여 검증 (ex.가격*수량의 합이 10000 이하)
일반적으로 검증은 클라이언트 검증과, 서버 검증이 존재한다. 클라이언트 검증(using js)은 조작을 이용한 보안에 취약하다는 단점이 있고, 서버 검증은 즉각적인 고객 사용성이 부족해진다는 단점이 있다. 따라서 둘을 적절하게 섞어서 사용하는 것이 좋다.
시작
상품을 등록하는 과정에서 적절하지 않은 값이 들어왔다고 하자. 그러면 form의 결과가 POST등의 방식으로 서버로 들어왔을 때, 메세지와 함께 redirect시켜줘야 할 것이다.
물론 컨트롤러에 에러를 담는 HashMap을 만들고, 이 Map의 정보와 타임리프의 기능들을 활용해서 사용자에게 에러를 표시할 수도 있을 것이다. 하지만 이러한 방법은 에러처리 코드의 반복으로 코드가 복잡해지고, Controller에서 처리하기 전에 발생하는 에러(타입 에러)들은 문제가 발생할 수 있다.
BindingResult 이용
- 컨트롤러 호출 전 에러 해결
따라서 스프링에서 지원하는 검증 기능을 사용해보자. 기존 @ModelAttribute 어노테이션이 붙은 객체 뒤에 BindingResult라는 객체를 선언해두면 바인딩된 결과를 자동으로 담아서 뷰에 넘겨준다. (기존에 error를 담기 위해 HashMap을 사용했다면, 그 역할을 대신해준다)
@PostMapping("/add")
public String addItem(@ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model) {
BindingResult 파라미터는 반드시 @ModelAttribute 다음에 와야 한다. BindingResult 객체에 아래와 같이 FieldError(단일 필드 에러), ObjectError(복합 필드 에러)에 객체명(여기에선 item)을 넘기면 에러정보를 저장해둘 수 있다.
//검증 로직 (Controller)
if(!StringUtils.hasText(item.getItemName())) {
// errors.put("itemName", "상품 이름은 필수입니다"); // 기존에는 Map을 이용하여 에러 저장
...
//특정 필드의 에러
bindingResult.addError(new FieldError("item", "itemName", "상품 이름은 필수입니다."));
//다중 필드 에러
bindingResult.addError(new ObjectError("item", "가격*수량의 합은 10,000원 이상이어야 합니다. 현재 값 = " + rstPrice));
}
원시적인 방법 대신 BindingResult를 사용하면, 에러를 별도의 코드 없이 자동으로 뷰에 넘겨준다는 장점도 있고, 무엇보다 @ModelAttribute에 데이터 바인딩 중에 오류가 발생해도 컨트롤러를 호출해준다는 장점이 있다. 즉, 컨트롤러 호줄전에 발생하는 오류(타입 에러)들을 처리할 수 있다.
- 입력한 값 유지
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값을 넣을 수 있는데, 오류 발생으로 바인딩에 실패하면 스프링은 FieldError를 생성하면서 사용자가 입력한 값을 넘기고, BindingResult에 담아서 컨트롤러에 호출하면 컨트롤러는 뷰에 넘겨서 값을 표시해준다. 이것을 이용하면 오류 발생시 기존 값을 유지할 수 있다.
- 에러 메세지 관리
위에서 공부한 FieldError 생성자의 defaultMessage 대신 codes와 arguments를 활용하여 에러 메세지를 관리해보자. 메세지/국제화때처럼 properties파일을 이용하자. errors.properties 파일을 만든 다음에 그 값을 codes에 String[]으로 넘기면 에러 메세지를 체계적으로 관리할 수 있게 된다.
bindingResult.addError(new FieldError("item", "price", item.getPrice(), false, new String[]{"range.item.price"}, new Object[]{"1,000", "100,000"}, null));
//errors.properties
range.item.price=가격은 {0} ~ {1} 까지 허용합니다.
- rejectValue(), reject()
BindingResult 객체가 검증해야 할 객체 다음에 와야 하므로, BindingResult는 자신이 검증해야 할 객체에 대한 정보를 알고 있다. 이것을 이용해서 FieldError나 ObjectError를 사용하지 않고도 검증 코드를 작성할 수 있다. 따라서 이것들 대신 rejectValue()나 reject()를 사용하여 검증해보자.
bindingResult.rejectValue("itemName", "required", new Object[]{"required", null}); //required.item.itemName
bindingResult.reject("totalPriceMin", new Object[]{10000, rstPrice}, null);
FieldError나 ObjectError를 사용하지 않고도 위와 같이 간단하게 사용할 수 있다. 앞서 말했듯이 이미 검증 목표에 대한 객체 정보(objectName)은 알고 있으므로 오류 필드명만 명시해주면 된다.
- 스프링이 추가한 검증 오류
일반적으로 사용자가 조건을 달아서 설정한 오류(rejectValue() 사용) 외에도, 타입 오류와 같이 스프링이 검증하는 오류들이 존재한다. 예를 들어, int형으로 된 price 필드에 문자가 들어오면 스프링이 타입 오류를 검증하여 자체적으로 에러 메세지를 띄우는데, 이는 별도의 오류 코드를 사용해서 관리가 가능하다.
typeMismatch.java.lang.Integer=숫자를 입력해주세요.
typeMismatch=타입 오류입니다.
이렇게 errors.properties에 추가해두면 해당 오류가 발생했을 경우 해당 메세지를 뷰로 전달하게끔 할 수 있다.
- 자동화된 기능 사용
@Component
public class ItemValidator implements Validator {
@Override
public boolean supports(Class<?> clazz) {...}
@Override
public void validate(Object target, Errors errors) {
Item item = (Item)target;
//검증 로직
if(!StringUtils.hasText(item.getItemName())) {
errors.rejectValue("itemName", "required"); //required.item.itemName
}
if(item.getPrice()==null || item.getPrice()<1000 || item.getPrice()>1000000) {
errors.rejectValue("price", "range", new Object[]{1000, 1000000}, null); //range.item.price
}
if(item.getQuantity()==null || item.getQuantity() >= 9999) {
errors.rejectValue("quantity", "max", new Object[]{9999}, null);
}
//특정 필드가 아닌 복합 룰 검증
if(item.getPrice()!=null && item.getQuantity()!=null) {
int rstPrice = item.getPrice() * item.getQuantity();
if(rstPrice<10000) {
errors.reject("totalPriceMin", new Object[]{10000, rstPrice}, null);
}
}
}
}
@Controller
@RequestMapping("/validation/v2/items")
@RequiredArgsConstructor
public class ValidationItemController {
private final ItemValidator itemValidator;
@InitBinder
public void init(WebDataBinder dataBinder) { //addItemV6의 @Validated 어노테이션과 함께 동작
dataBinder.addValidators(itemValidator);
}
@PostMapping("/add")
public String addItem(@Validated @ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model) {
//검증에 실패하면 다시 입력 폼으로
if(bindingResult.hasErrors()) {
log.info("errors = {}", bindingResult);
// model.addAttribute("errors", errors);
return "validation/v2/addForm";
}
//성공 로직
Item savedItem = itemRepository.save(item);
redirectAttributes.addAttribute("itemId", savedItem.getId());
redirectAttributes.addAttribute("status", true);
return "redirect:/validation/v2/items/{itemId}";
}
> WebDataBinder를 인자로 받는 @InitBinder 어노테이션 메소드에 Validator를 상속받은 검증 클래스(ItemValidator)를 오버라이딩해서 인스턴스를 만들어 넘겨주고, 컨트롤러의 매개변수에 추가로 @Validated 어노테이션을 추가해주면 컨트롤러에 요청이 올 때마다 자동으로 검증 기능을 포함하여 실행되게 할 수 있다.
이 때 supports()함수는 인자로 넘어간 클래스 검증기를 지원하는지 여부를 확인하는 용도이고, validate() 안에 검증 대상 객체와 BindingResult를 상속하는 Errors 클래스를 이용하여 로직을 집어넣어두면 된다.
정리
순수한 자바 코드를 이용하는 방법부터 BindingResult를 거쳐 WebDataBinder와 @InitBinder를 사용하는 방법까지 알아보았다. 하지만 이러한 방법들도 기존 코드 외적으로 별도의 컴포넌트와 코드를 필요로 하는 등 복잡하다고 생각할 수 있다. 다음에는 Bean Validation을 사용하여 어노테이션을 이용한 검증 방법에 대해서 알아보자.
'[ Backend ] > Spring Web' 카테고리의 다른 글
[Spring MVC] 쿠키를 사용한 로그인 (0) | 2022.08.08 |
---|---|
[Spring MVC] 검증 2 - Bean Validation (0) | 2022.07.14 |
[Spring MVC] 메세지, 국제화 (0) | 2022.07.08 |
[Spring MVC] URL 매핑, 데이터 전송/조회 (0) | 2022.02.19 |
[Spring MVC] 서블릿과 스프링, 프론트 컨트롤러 (0) | 2022.02.09 |