웹 애플리케이션을 만들고 유효성 검증을 추가 하지 않는다면, 사용자가 폼에 잘못된 값을 입력할 시 아래와 같은 오류화면을 만나게 될 것 입니다.
이렇게 되면 클라이언트 입장에서는 상당히 당황스러울 것이고 입력한 값이 모두 날라가 처음부터 다시 작성해야 하는 일이 생깁니다.
또한 우리가 웹 사이트에서 값을 잘못입력했다고 이런 오류화면을 본적도 없죠.
우리는 웹 서비스를 할 때 이렇게 클라이언트가 해결하지 못하는 오류 화면을 보여줄 것이 아니라
클라이언트가 잘 못 입력했을 시 무엇을 잘못 입력했는지, 어떻게 입력해야하는 지를 알려줘야합니다.
그래서 Validation 검증 기능을 추가해 어떤 값을 잘못 입력했는지 알 수 있도록 해봅시다.
결과물과 주의점
이 포스팅은 검증 설명에 중점을 뒀기 때문에
예제에 사용된 코드는 이 결과물을 만들기 위한 과정만을 위한 설명으로 설명에 필요없는 몇몇 코드가 빠져있기 때문에 똑같이 코드를 사용해도 위 결과물을 얻을 수 없습니다.
오류 메시지 관리
errors.properties 오류 메시지
errors.properties 프로퍼티 파일을 만들고 다음과 같이 내용을 추가합니다.
검증 로직 클래스에서 MessageCodesResolver에 대해 설명하겠지만 간단하게 설명하고 넘어가자면
"메시지이름.객체이름.객체필드" (required.item.itemName) 로 작성한 메시지는 메시지이름으로만 작성한 (required) 보다 구체화된 메시지로 같은 메시지 이름일 경우 우선순위로 사용됩니다.
required.item.itemName=상품 이름은 필수입니다.
range.item.price=가격은 {0} ~ {1} 까지 허용합니다.
max.item.quantity=수량은 최대 {0} 까지 허용합니다.
totalPriceMin=가격 * 수량의 합은 {0}원 이상이어야 합니다. 현재 값 = {1}
required = 필수 값 입니다.
min= {0} 이상이어야 합니다.
range= {0} ~ {1} 범위를 허용합니다.
max= {0} 까지 허용합니다.
타임리프에서 사용할 일반 메시지
hello=안녕
hello.name=안녕 {0}
label.item=상품
label.item.id=상품 ID
label.item.itemName=상품명
label.item.price=가격
label.item.quantity=수량
page.items=상품 목록
page.item=상품 상세
page.addItem=상품 등록
page.updateItem=상품 수정
button.save=저장
button.cancel=취소
그리고 application.properties 파일에 두 메시지를 읽어 올 수 있도록 다음과 같이 옵션을 추가해줍니다.
spring.messages.basename=messages, errors
Item 객체와 Repository
Item
import lombok.Data;
@Data
public class Item {
private Long id;
private String itemName;
private Integer price;
private Integer quantity;
public Item() {
}
public Item(String itemName, Integer price, Integer quantity) {
this.itemName = itemName;
this.price = price;
this.quantity = quantity;
}
}
Repository
import java.util.HashMap;
import java.util.Map;
@Repository
public class ItemRepository {
private static final Map<Long, Item> store = new HashMap<>();
private static long sequence = 0L;
public Item save(Item item) {
item.setId(++sequence);
store.put(item.getId(), item);
return item;
}
}
검증 로직을 담당하는 클래스 생성
스프링은 검증을 체계적으로 제공하기 위해 다음 인터페이스를 제공합니다.
public interface Validator {
boolean supports(Class<?> clazz);
void validate(Object target, Errors errors);
}
- supports() {}
해당 검증기를 지원하는 여부 확인 ( @Validated 애노테이션과 상호작용, 아래 컨트롤러에 설명 )
결과가 True이면 validate 메서드 실행
- validate(Object target, Errors errors)
Object target은 검증할 객체를 넘깁니다. (캐스팅 해서 사용)
Errors는 BindingResult의 부모로 errors에 Controller의 파라미터인 BindingResult를 넘겨줍니다.
(BindingResult는 아래 COntroller에 설명)
검증 로직 클래스
import hello.itemservice.domain.item.Item;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.validation.Errors;
import org.springframework.validation.Validator;
@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;
// 검증 로직
if (!StringUtils.hasText(item.getItemName())) {
errors.rejectValue("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() >= 9999) {
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);
}
}
}
}
Item.class.isAssignableFrom(clazz);
파라미터로 넘어오는 "clazz"가 Item.class에 지원이 되느냐 확인합니다.
isAssignableFrom()는 파라미터로 받은 "clazz"의 자식 클래스도 같이 확인해줍니다.
rejectValue() , reject()
컨트롤러에서 BindingResult 는 검증해야 할 객체 바로 다음에 온다고 했습니다.
따라서 BindingResult 는 이미 본인이 검증해야 할 객체인 target 을 알고 있습니다.
BindingResult 가 제공하는 rejectValue() , reject() 를 사용하면 FieldError , ObjectError 를 직접 생성하지 않고, 깔끔하게 검증 오류를 다룰 수 있습니다.
rejectValue()
특정 필드 오류를 검증할 때 사용합니다.
void rejectValue(@Nullable String field, String errorCode, @Nullable Object[] errorArgs, @Nullable String defaultMessage);
- field : 오류 필드명
- errorCode : 작성한 오류 메시지에 등록된 코드를 사용합니다. MessageResolver를 위한 오류 코드 (아래 설명)
- errorArgs : 오류 메시지에서 {0}, {1} ... 등 파라미터를 치환하기 위한 값 ( new Object[] {...} 배열로 작성 )
- defaultMessage : 오류 메시지를 찾을 수 없을 때 사용하는 기본 메시지
위 예제 코드를 보면 오류 코드를 range 로 간단하게 입력한 것을 볼 수 있습니다.
그래도 오류 메시지를 잘 찾아서 출력하는데 그 이유는 아래에 MessageCodesResolver 설명에 있습니다.
reject()
특정 필드 오류가 아닌 복합 오류를 검증할 때 사용합니다.
void reject(String errorCode, @Nullable Object[] errorArgs, @Nullable String defaultMessage);
각 파라미터는 rejectValue() 설명과 같습니다.
MessageCodesResolver
오류 메시지에 객체명과 필드명을 조합한 디테일한 코드가 있으면 이 메시지를 높은 우선순위로 사용합니다.
itemName 의 경우 위에서 만든 에러메시지 중 required 검증 오류 메시지가 발생하면 다음 코드 순서대로 메시지가 생성됩니다.
- required.item.itemName
- required.itemName
- required.java.lang.String
- require
만약 아래 처럼 두 종류의 에러메시지를 만들어 놨을 때 검증 오류가 발생하면, 아래 에서 더 구체적인 Level1의 required.item.itemName를 사용합니다.
#Level1
required.item.itemName: 상품 이름은 필수 입니다.
#Level2
required: 필수 값 입니다.
Controller 생성
package hello.itemservice.web.validation;
import hello.itemservice.domain.item.Item;
import hello.itemservice.domain.item.ItemRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.util.StringUtils;
import org.springframework.validation.BindingResult;
import org.springframework.validation.FieldError;
import org.springframework.validation.ObjectError;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.WebDataBinder;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@Controller
@RequestMapping("/validation/items")
@RequiredArgsConstructor
public class ValidationItemController {
private final ItemRepository itemRepository;
private final ItemValidator itemValidator;
@InitBinder // 이 컨트롤러가 요청될 때 마다 모든 메서드에 검증이 적용
public void init(WebDataBinder dataBinder) {
dataBinder.addValidators(itemValidator);
}
@GetMapping("/add")
public String addForm(Model model) {
model.addAttribute("item", new Item());
return "validation/addForm";
}
@PostMapping("/add")
public String addItemV6(@Validated @ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes) {
// 검증에 실패하면 다시 입력 폼으로
if (bindingResult.hasErrors()) {
// bindingResult는 model에 안담아도 스프링이 자동으로 View에 넘긴다.
return "validation/addForm";
}
Item savedItem = itemRepository.save(item);
redirectAttributes.addAttribute("itemId", savedItem.getId());
redirectAttributes.addAttribute("status", true);
// PRG 방식을 위해 사용했지만 분량이 많아서 다른 코드는 생략
return "redirect:/validation/items/{itemId}";
}
}
BindingResult
스프링이 제공하는 검증 오류를 보관하는 객체로, 검증 오류가 발생하면 여기에 보관합니다.
BindingResult 가 있으면 @ModelAttribute 에 데이터 바인딩 시 오류가 발생해도 오류 정보( FieldError )를 BindingResult 에 담아서 컨트롤러를 정상 호출합니다. (입력값 유지 됨)
BindingResult는 파라미터 순서가 중요한데, BindingResult 는 검증할 대상 바로 다음에 와야합니다.
(Ex: 위 예제에서는 검증할 대상인 Item 객체 바로 다음에 BindingResult가 왔다.)
BindingResult 는 Model에 자동으로 포함되기 때문에 model에 담아 View에 보내지 않아도 스프링이 자동으로 View에 넘겨줍니다.
if (bindingResult.hasErrors()) {
return "validation/addForm";
}
bindingResult.hasErrors() : 이름 그대로 bindingResult에 검증 오류가 있다면 실행합니다.
return "validation/addForm"; : 검증에 실패하면 다시 입력폼 View 템플릿 addForm을 보여줍니다.
WebDataBinder
해당 컨트롤러가 요청될 때 항상 먼저 @InitBinder가 호출되고 WebDataBinder가 내부적으로 생성 후, 인자로 넘긴 검증기를 자동으로 적용해줍니다.
@InitBinder
public void init(WebDataBinder dataBinder) {
dataBinder.addValidators(itemValidator);
}
모든 컨트롤러에 다 적용되도록 글로벌 적용도 할 수 있습니다. (잘 사용하지 않으니 생략)
@Validated
검증기를 실행하라는 애노테이션
이 애노테이션이 붙으면 앞서 WebDataBinder 에 등록한 검증기를 찾아서 실행합니다.
그런데 여러 검증기를 등록한다면 그 중에 어떤 검증기가 실행되어야 할지 구분이 필요한데, 이때 Validator 인터페이스의 supports() 가 사용됩니다. (Validator는 아래 검증 로직 클래스에서 설명)
여기서는 supports(Item.class) 호출되고, 결과가 true 이므로 ItemValidator 의 validate() 가 호출됩니다.
View 템플릿 (타임리프 사용)
타임리프는 스프링의 BindingResult 를 활용해서 편리하게 검증 오류를 표현하는 기능을 제공합니다.
- #fields : #fields 로 BindingResult 가 제공하는 검증 오류에 접근
- th:errors : 해당 필드에 오류가 있는 경우에 태그를 출력 ( th:if 의 편의 버전 )
- th:errorclass : th:field 에서 지정한 필드에 오류가 있으면 class 정보를 추가
<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="utf-8">
<link th:href="@{/css/bootstrap.min.css}"
href="../css/bootstrap.min.css" rel="stylesheet">
<style>
.container {
max-width: 560px;
}
.field-error {
border-color: #dc3545;
color: #dc3545;
}
</style>
</head>
<body>
<div class="container">
<div class="py-5 text-center">
<h2 th:text="#{page.addItem}">상품 등록</h2>
</div>
<form action="item.html" th:action th:object="${item}" method="post">
<!-- 글로벌 오류는 여러 오류가 있을 수 있기 때문에 each 반복문 사용-->
<div th:if="${#fields.hasGlobalErrors()}">
<p class="field-error" th:each="err : ${#fields.globalErrors()}" th:text="${err}">전체 오류 메시지</p>
</div>
<div>
<label for="itemName" th:text="#{label.item.itemName}">상품명</label>
<input type="text" th:field="*{itemName}"
th:errorclass="field-error" class="form-control" placeholder="이름을 입력하세요">
<div class="field-error" th:errors="*{itemName}">상품명 오류</div>
</div>
<div>
<label for="price" th:text="#{label.item.price}">가격</label>
<input type="text" th:field="*{price}"
th:errorclass="field-error" class="form-control" placeholder="가격을 입력하세요">
<div class="field-error" th:errors="*{price}">가격 오류</div>
</div>
<div>
<label for="quantity" th:text="#{label.item.quantity}">수량</label>
<input type="text" th:field="*{quantity}"
th:errorclass="field-error" class="form-control" placeholder="수량을 입력하세요">
<div class="field-error" th:errors="*{quantity}">
수량 오류
</div>
</div>
<hr class="my-4">
<div class="row">
<div class="col">
<button class="w-100 btn btn-primary btn-lg" type="submit" th:text="#{button.save}">상품 등록</button>
</div>
<div class="col">
<button class="w-100 btn btn-secondary btn-lg"
onclick="location.href='items.html'"
th:onclick="|location.href='@{/validation/items}'|"
type="button" th:text="#{button.cancel}">취소</button>
</div>
</div>
</form>
</div> <!-- /container -->
</body>
</html>
스프링의 타입 오류 메시지 해결
앞의 내용들을 다 적용 한 후에는 검증 로직 대로 잘못 입력하면 어느 필드가 오류가 발생했는지 빨간색 칸과 설명으로 알려주게 되었습니다.
하지만 아직 한 가지 문제가 있습니다.
바로 잘못된 타입을 입력하면 스프링에서 자체적으로 만든 타입오류 메시지를 출력하게 되어버립니다.
로그를 확인해보면 BindingResult에 스프링에서 만든 다음과 같은 4가지 메시지 코드가 담겨있는 것을 볼 수 있습니다.
typeMismatch.item.price
typeMismatch.price
typeMismatch.java.lang.Integer
typeMismatch
위에서 만든 errors.properties에 위 메시지 코드의 내용이 없기 때문에 스프링이 생성한 기본 메시지가 출력되는 것입니다.
errors.properties에 아래처럼 내용을 추가하면 스프링 기본 메시지 대신 직접 작성한 메시지가 출력되게 됩니다.
typeMismatch.java.lang.Integer=숫자를 입력해주세요.
typeMismatch=타입 오류입니다.
'◼ Spring' 카테고리의 다른 글
[Spring] 트랜잭션 AOP 주의 사항 (0) | 2023.04.25 |
---|---|
[Spring] Bean Validation - 유효성 검증 (0) | 2023.04.06 |
[Spring] PRG(Post-Redirect-Get) 패턴이란? (0) | 2023.03.29 |
[Spring] HTTP 응답 방법과 관련 어노테이션(Annotation) (0) | 2023.03.28 |
[Spring] HTTP 요청 방법과 관련 어노테이션(Annotation) (0) | 2023.03.28 |