Spring MVC - Validation(검증)
웹사이트에서 회원가입할 때 어떤 문자를 잘못 입력한적이 있을 것이다. 이 때, 잘 설계된 웹사이트는 오류를 친절하게 설명하면서 우리가 작성한 폼을 유지시켜주면서 다시 작성하라고 했을 것이다. 그러나 잘못 설계되거나 친절하지 않은 웹사이트는 오류를 친절하게 설명해주지 않을 뿐더러 우리가 작성했던 폼도 유지시켜주지 않을 것이다.
잘 설계된 웹 서비스는 폼 입력시 오류가 발생하면, 고객이 입력한 데이터를 유지한 상태로 어떤 오류가 발생했는지 친절하게 알려주어야 할 것이다. 비밀번호 입력 같은 기능의 경우는 보안적인 문제가 있기 때문에 제외하면 말이다. 이런 것을 처리해주는 Spring MVC와 Spring Boot가 제공하는 기능들에 대해서 알아보자.
우선 Spring이 제공하는 기능들을 쓰지 않고 순수한 자바코드로 검증기능을 만들어보자.
Java 코드로 구현한 Validation
@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 까지 허용합니다.");
}
//가격 * 수량의 합이 10000 이상이 안돼는 경우
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()){
log.info("errors = {}", errors);
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}";
}
위 코드는 상품등록 폼에서 상품등록 버튼을 누르면 동작하는 컨트롤러 메서드이다. Spring 이 제공하는 검증 기능을 쓰지않는다면 Map에 에러가 발생한 필드의 이름과 에러 메시지를 담아서 뷰에 렌더링하여 고객에게 보여줘야 할 것이다.
이렇게 Spring 이 제공하는 기능을 쓰지않고 어느정도까지는 검증을 할 수 있지만, 타입 오류 처리가 안된다는 문제점이 있다. 타입 오류는 스프링 MVC 컨트롤러에 진입하기도 전에 예외가 발생하기 때문에 컨트롤러 자체가 호출되지 않기 때문에 따로 처리도 불가능하기 때문이다. 따라서 다음과 같이 타입 오류가 나면 컨트롤러가 호출되지도 않고 400 Bad Request 예외가 발생하면서 고객에게 오류 페이지를 띄우게 된다.
BindingResult
이제는 순수 Java 코드가 아닌 Spring이 제공하는 검증 방법을 알아보자. 보통 메서드에 BindingResult를 파라미터로 사용하여 검증하는데, 스프링이 제공하는 검증 오류를 보관하는 객체이다. 이를 점진적으로 검증 소스코드가 간결해지고 중복을 제거하는 식으로 알아보자.
Version 1
@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.getPrice() < 1000 || item.getPrice() > 1000000){
bindingResult.addError(new FieldError("item", "price", "가격은 1,000 ~ 1,000,000 까지 허용합니다."));
}
//상품 수량을 잘못 입력한 경우
if(item.getQuantity() == null || item.getQuantity() >= 9999){
bindingResult.addError(new FieldError("item", "quantity", "수량은 최대 9,999 까지 허용합니다."));
}
//가격 * 수량의 합이 10000 이상이 안돼는 경우
if(item.getPrice() != null && item.getQuantity() != null){
int resultPrice = item.getPrice() * item.getQuantity();
if(resultPrice < 10000){
bindingResult.addError(new ObjectError("item", "가격 * 수량의 합은 10,000원 이상이어야 합니다. 현재 값 = " + resultPrice));
}
}
//검증에 실패하면 다시 입력 폼으로
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}";
}
전의 코드와 다른점은 오류가 발생하면 Map 대신에 BindingResult에 오류 정보를 담는다는 것이다. 여기서 주의할점은 바인딩하는 객체, 즉 @ModelAttribute Item item 파라미터 뒤에 BindingResult가 와야한다는 것이다. 왜냐하면 사용자가 입력한 Item 정보가 @ModelAttribute에 의해서 Item 객체로 바인딩이 되는데, BindingResult는 이 바인딩된 정보를 가지고 있어야 하기 때문에 바인딩이 된 후에 뒤에 위치해야 한다.
BindingResult에 에러는 대표적으로 FieldError와 ObjectError를 생성하여 넣는다. FieldError는 말 그대로 객체에 존재하는 한 멤버변수에 오류가 발생하는 것이라고 할 수 있고, ObjectError는 두개 이상의 멤버변수에 오류가 발생하는 것이라고 할 수 있다.
FieldError와 ObjectError를 생성할 때 필요한 인자는 다음과 같다. 생성자가 한개씩 더 존재하는데 나머지는 뒤쪽에서 살펴보도록 하겠다.
public FieldError(String objectName, String field, String defaultMessage) {}
public ObjectError(String objectName, String defaultMessage) {}
- objectName: @ModelAttribute의 이름, 바인딩되는 객체
- field: 필드명
- defaultMessage: 오류가 발생하면 들어가는 기본 메시지
위의 코드를 데이터 바인딩 오류가 발생하게끔 다시 실행해보면 400 Bad Request가 발생하지 않고 컨트롤러가 호출되는 것을 알 수 있다.
데이터 바인딩 오류가 발생하면 Spring이 자체에서 데이터를 폼에 유지시켜 주지만, 우리가 작성한 FieldError, ObjectError에서 오류가 발생한다면 폼에 데이터가 남아있지 않고 사라지게 된다. 만약 고객이 작성해야하는 폼이 매우 많고, 작성하다가 오류가 발생하면 처음부터 다시 작성해야하는 문제점이 발생할 것이다. 이 때문에 FieldError와 ObjectError는 또 다른 생성자를 제공하는데, 한번 살펴보자.
public FieldError(String objectName, String field, @Nullable Object rejectedValue, boolean bindingFailure,
@Nullable String[] codes, @Nullable Object[] arguments, @Nullable String defaultMessage) {}
public ObjectError(String objectName, @Nullable String[] codes, @Nullable Object[] arguments, @Nullable String defaultMessage) {}
- rejectedValue: 오류가 발생했을 때 사용자가 쓴 값(거절된 값)을 의미하며 폼에 입력한 값이 유지될 수 있게한다.
- bindingFailure: 바인딩에 실패했는지, 검증에 실패했는지 구분하는 값
- codes: 메시지 코드를 의미하며, errors.properties에 존재하는 값을 넣는다.
- arguments: 메시지 코드에서 사용하는 인자를 의미하며, 메시지 코드를 동적으로 변경하기 위해서 사용한다.
Version 2
@PostMapping("/add")
public String addItemV2(@ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model) {
//검증 로직
//상품 이름에 값이 들어가있지 않는다면
if(!StringUtils.hasText(item.getItemName())){
bindingResult.addError(new FieldError("item", "itemName", item.getItemName(), false, null, null, "상품 이름은 필수입니다."));
}
//상품 가격을 잘못 입력한 경우
if(item.getPrice() == null || item.getPrice() < 1000 || item.getPrice() > 1000000){
bindingResult.addError(new FieldError("item", "price", item.getPrice(), false, null, null, "가격은 1,000 ~ 1,000,000 까지 허용합니다."));
}
//상품 수량을 잘못 입력한 경우
if(item.getQuantity() == null || item.getQuantity() >= 9999){
bindingResult.addError(new FieldError("item", "quantity", item.getQuantity(), false, null, null, "수량은 최대 9,999 까지 허용합니다."));
}
//가격 * 수량의 합이 10000 이상이 안돼는 경우
if(item.getPrice() != null && item.getQuantity() != null){
int resultPrice = item.getPrice() * item.getQuantity();
if(resultPrice < 10000){
bindingResult.addError(new ObjectError("item", null, null, "가격 * 수량의 합은 10,000원 이상이어야 합니다. 현재 값 = " + resultPrice));
}
}
//검증에 실패하면 다시 입력 폼으로
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}";
}
위 생성자를 사용하여 rejectedValue에 값을 넣어서 FieldError나 ObjectError를 생성하면 사용자가 잘못 입력했을 때 오류가 발생하더라도 다음과 같이 폼에 원래 입력한 값이 남아있다.
그런데 여기서 데이터 바인딩 오류 부분에서의 의문점이 들 수 있다. Version 1 에서 rejectedValue에 값을 넣지 않아도 폼에 원래 입력한 값이 유지된다는 것이다. 왜 그럴까? 이는 스프링이 자동적으로 처리해주기 때문이다. 스프링은 타입 오류가 발생하면 FieldError를 생성하고 사용자가 입력한 값을 넣어둔다. 그런다음 해당 오류를 BindingResult에 담아서 컨트롤러를 호출한다.
다음과 같이 타입 오류가 발생하면 코드를 따로 작성하지 않아도 TypeMismatch 로그가 찍히는 것을 볼 수 있다.
FieldError와 ObjectError 생성자 인자로 메시지코드와 메시지코드에 대한 인자를 받을 수 있다. 여기서 메시지코드를 인자로 넣으면 스프링부트가 우리가 생성한 메시지 파일을 뒤져서 해당 메시지코드를 찾게된다. 메시지 국제화에서 사용했던 messages.properties에 에러 메시지코드도 같이 관리해도 상관없지만 구분해서 다른 파일에서 관리하는 것을 추천한다.
스프링 부트는 메시지 파일을 읽을 때 따로 설정하지 않으면 messages.properties를 기본으로 인식하기 때문에 우리는 errors.preperties를 생성하고 설정정보를 추가할 것이다.
applcation.properties
spring.messages.basename=messages,errors
이와같이 설정정보에 추가하면 messages.properties, errors.properties 두 파일 모두 스프링부트가 인식하게 된다. 여기서 에러 메시지도 국제화 처리를 하려면 errors_en.properties와 같은 파일을 추가해서 관리할 수도 있다.
이제 errors.properties에 에러 메시지코드를 추가하고 코드를 변경해보자.
errors.properties
required.item.itemName=상품 이름은 필수입니다.
range.item.price=가격은 {0} ~ {1} 까지 허용합니다.
max.item.quantity=수량은 최대 {0} 까지 허용합니다.
totalPriceMin=가격 * 수량의 합은 {0}원 이상이어야 합니다. 현재 값 = {1}
Version 3
@PostMapping("/add")
public String addItemV3(@ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model) {
//검증 로직
//상품 이름에 값이 들어가있지 않는다면
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() >= 9999){
bindingResult.addError(new FieldError("item", "quantity", item.getQuantity(), false, new String[]{"max.item.quantity"}, new Object[]{9999}, null));
}
//가격 * 수량의 합이 10000 이상이 안돼는 경우
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";
}
//성공 로직
Item savedItem = itemRepository.save(item);
redirectAttributes.addAttribute("itemId", savedItem.getId());
redirectAttributes.addAttribute("status", true);
return "redirect:/validation/v2/items/{itemId}";
}
코드를 보면 메시지 코드를 배열에다가 넣어서 지정하는 것을 볼 수 있다. 메시지 코드는 배열로 여러개의 값을 전달할 수 있는데, 순서대로 매칭하여 처음 매칭되는 메시지가 사용된다. 또한 메시지 코드에 {0}, {1} 과 같은 인자로 치환할 메시지가 존재한다면 인자를 Object 배열에 담아서 전달할 수 있다. 0번째부터 순서대로 치환이 되어서 들어가게된다.
이제는 매번 FieldError와 ObjectError 객체를 생성하여 BindingResult에 전달하는 것도 별로일 것이다. BindingResult가 제공하는 rejectValue(), reject()를 사용하면 Error 객체를 직접 생성하지 않고 검증을 할 수 있다.
Version 4
@PostMapping("/add")
public String addItemV4(@ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model) {
if(bindingResult.hasErrors()){
log.info("errors = {}", bindingResult);
return "validation/v2/addForm";
}
log.info("objectName={}", bindingResult.getObjectName());
log.info("target={}", bindingResult.getTarget());
/**
* rejectValue: field error
* reject: object error
*/
//밑에 조건문이랑 같은 동작을 한다. -> 단순한 조건만 지원한다.
//ValidationUtils.rejectIfEmptyOrWhitespace(bindingResult, "itemName", "required");
//검증 로직
//상품 이름에 값이 들어가있지 않는다면
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() >= 9999){
bindingResult.rejectValue("quantity", "max", new Object[]{9999}, null);
}
//가격 * 수량의 합이 10000 이상이 안돼는 경우
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}";
}
이렇게 코드를 작성하고 실행을 하면 오류 메시지가 정상적으로 출력되는 것을 볼 수 있다. 하지만 여기서 의문점이 있는데, 왜 errors.properties에 메시지 코드를 입력하지 않았는데 출력이 되는 것일까? 이것의 비밀은 MessageResolver 에 있다. MessageResolver를 설명하기 전에 rejectValue()와 reject 코드를 보자.
void rejectValue(@Nullable String field, String errorCode,
@Nullable Object[] errorArgs, @Nullable String defaultMessage);
void reject(String errorCode, @Nullable Object[] errorArgs, @Nullable String defaultMessage);
- field: 오류 필드명
- errorCode: 오류 메시지 코드
- errorArgs: 오류 메시지 코드에 들어가는 인자들
- defaultMessage: 기본 오류 메시지
여기서 errorCode(오류 메시지 코드)에 비밀이 숨어있다. 위에 Version 4 코드를 보면 각각의 errorCode가 itemName, range, max 등을 볼 수 있다. 그런데 우리가 작성한 errors.properties에는 해당 errorCode가 없지 않은가? itemName을 기준으로 설명해보겠다.
우리는 errors.properties에 required.item.itemName을 작성해두었다. 그리고 itemName만으로 되어있는 오류 메시지코드는 존재하지 않은 상태이다. 이 때 itemName이라는 오류 메시지코드를 작성하여 넣으면 어떻게 될까? 예상과 달리 required.item.itemName이 메시지 코드로 출력되는 것을 확인할 수 있을 것이다. 왜냐하면 이는 MessageResolver가 정해둔 기준에 따라서 동작하기 때문인데, MessageResolver는 구체적인 것과 포괄적인 것이 있으면 구체적인 것에 먼저 우선권을 둔다. 따라서 required.item.itemName이 동작할 수 있는 것이다. 만약 errors.properties에 itemName이 들어가는 메시지 코드가 없다면 데이터 타입에 따라서 처리하도록 만들어놓았다. 찾는 오류 메시지코드가 없다면 우리가 rejectValue()를 호출할 때 작성한 디폴트 메시지가 출력될 것이다.
정리하면 동작원리는 다음과 같다.
- rejectValue() 또는 reject() 가 호출된다.
- MessageCodesResolver를 사용하여 검증 오류 코드로 메시지 코드를 생성한다.
- new FieldError(), new ObjectError()를 생성하면서 메시지 코드들을 보관한다.
- 메시지 코드들로 메시지를 순서대로 찾고 뷰 렌더링을 한다.
'Spring Framework > Spring Web MVC' 카테고리의 다른 글
Spring MVC - FrontController(Dispatcher Servlet) (0) | 2022.04.09 |
---|---|
Spring MVC - API 예외처리(Exception) (0) | 2022.04.05 |
Spring MVC - 로그인 처리(쿠키, 세션) (0) | 2022.04.02 |
Spring MVC - Bean Validation (0) | 2022.03.29 |
HTTP 메시지 컨버터(HTTP Message Converter) (0) | 2022.03.27 |