[Spring Web MVC] 데이터 검증
숫자나 문자로 작성해야 하는 폼에 잘못된 값을 넣고 서버로 데이터를 전송하면 오류 화면으로 이동된다.
이러면 사용자는 폼을 처음부터 작성해야 하는데, 이런 서비스는 사용자 친화적이지 않다.
이처럼 컨트롤러의 중요한 역할 중 하나는 HTTP 요청이 정상인지 검증하는 것이다.
클라이언트 부분에서 자바스크립트를 통해 검증하는 방법도 있지만, 보안에 매우 취약하고 서버 단에서만 검증하면 즉각적인 고객 사용성이 떨어진다.
클라이언트와 서버에서 모두 검증하는 편이 합리적이다.
상품 등록 시 검증 로직에 부합하지 않는 정보가 입력되면 고객에게 오류 페이지를 보여주는 대신, 다시 상품 등록 폼을 보여주고 어떤 값을 수정해야 하는지 알려줘야 한다. (검증 작업은 컨트롤러에서 수행된다.)
@PostMapping("/add")
public String addItem(@ModelAttribute Item item, RedirectAttributes redirectAttributes, Model model) {
// save form value
Map<String, String> errors = new HashMap<>();
// validation logic
if(!StringUtils.hasText(item.getItemName())){
errors.put("itemName", "상품 이름 입력해");
}
if(item.getPrice() == null || item.getPrice() < 1000 || item.getPrice() > 1_000_000){
errors.put("price", "가격은 1,000~1,000,000 사이임");
}
if(item.getQuantity() == null || item.getQuantity() >= 9999){
errors.put("quantity", "수량은 9,999 까지임");
}
// mixed field validation
if(item.getPrice() != null && item.getQuantity() != null){
int total = item.getPrice() * item.getQuantity();
if(total < 10_000){
errors.put("globalError", "총 가격은 10,000 이상이여야함. 지금은 " + total +"임.");
}
}
// if validation fail go to form
if(!errors.isEmpty()){
model.addAttribute("errors", errors);
return "validation/v1/addForm"; // 전에 왔던 페이지
}
// success logic
Item savedItem = itemRepository.save(item);
redirectAttributes.addAttribute("itemId", savedItem.getId());
redirectAttributes.addAttribute("status", true);
return "redirect:/validation/v1/items/{itemId}";
}
클라이언트가 폼을 작성해 POST 메서드로 서버에게 요청했을 때, 컨트롤러에서는 클라이언트가 보낸 데이터에 대해 검증 작업을 수행한다.
Map 자료구조를 사용해 에러가 발생한 필드 이름을 키로, 에러 메세지를 값으로 넣어주고 Map 자료구조가 비어있지 않으면 클라이언트를 다시 폼 작성 화면으로 보낸다.
이 때 클라이언트가 이전에 작성하던 폼 데이터는 유지된다.
@ModelAttribute Item item 을 사용하면 스프링이 알아서 모델에 데이터를 넣어주고, 해당 데이터를 바탕으로 HTML 을 렌더링 할 때 클라이언트가 이전에 작성하던 내용이 들어간다.
<div th:if="${errors?.containsKey('globalError')}">
<p class="field-error" th:text="${errors['globalError']}">error message</p>
</div>
타임리프를 통해 에러가 발생했는지 확인하고, 발생한 에러가 있다면 해당 에러 메세지를 HTML 을 렌더링 할 때 포함시켜준다.
errors?.containsKey(...) 에서 ? 를 사용했는데, errors 가 null 일 때 NullPointerException 을 뱉는 대신 null 을 반환하는 문법이다.
꽤 괜찮게 바뀐 것 같은데.. 아직 문제점이 많이 남아있다.
뷰 템플릿에서 중복되는 요소가 많고, 타입 오류 처리에 부족한 부분이 많고, 바인딩 관련 문제도 있고...
이런 검증 로직은 정말 많이 사용되기 때문에, 스프링에서 따로 검증 방법을 제공한다.
@PostMapping("/add")
public String addItemV1(@ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model) {
// validation logic
if(!StringUtils.hasText(item.getItemName())){
// errors.put("itemName", "상품 이름 입력해");
bindingResult.addError(new FieldError("item", "itemName", "오류 메세지 : 상품이름입력하세요"));
}
if(item.getPrice() == null || item.getPrice() < 1000 || item.getPrice() > 1_000_000){
// errors.put("price", "가격은 1,000~1,000,000 사이임");
bindingResult.addError(new FieldError("item", "price", "오류 메세지 : 가격 천 ~ 백만사이로쓰세요"));
}
if(item.getQuantity() == null || item.getQuantity() >= 9999){
// errors.put("quantity", "수량은 9,999 까지임");
bindingResult.addError(new FieldError("item", "quantity", "오류 메세지 : 수량체크하세요 9999까지"));
}
// mixed field validation
if(item.getPrice() != null && item.getQuantity() != null){
int total = item.getPrice() * item.getQuantity();
if(total < 10_000){
// errors.put("globalError", "총 가격은 10,000 이상이여야함. 지금은 " + total +"임.");
bindingResult.addError(new ObjectError("item", "오류 메세지 : 토탈가격 만원이상으로하세요"));
}
}
// if validation fail go to form
if(bindingResult.hasErrors()){
// model.addAttribute("errors", bindingResult); 안 담아도 됨
return "validation/v2/addForm"; // 전에 왔던 페이지
}
// success logic
Item savedItem = itemRepository.save(item);
redirectAttributes.addAttribute("itemId", savedItem.getId());
redirectAttributes.addAttribute("status", true);
return "redirect:/validation/v2/items/{itemId}";
}
@ModelAttribute 파라미터 뒤에 BindingResult 파라미터를 배치해서 더 편하게 검증할 수 있다.
특정 필드에서 발생하는 에러는 FieldError 객체를 생성해 bindingResult에 담아주고, 특정 필드를 넘어서는 오류가 있으면 ObjectError 객체를 생성해서 bindingResult에 담아두면 된다. (파라미터는 @ModelAttribute 이름, 필드 이름, 메세지 순서)
<p class="field-error" th:each="err : ${#fields.globalErrors()}" th:text="${err}">global 오류 메시지</p>
<input type="text" id="itemName" th:field="*{itemName}"
th:errorclass="field-error" class="form-control" placeholder="이름을 입력하세요">
<div class="field-error" th:errors="*{itemName}">
타임리프는 스프링의 BindingResult를 활용해 검증 오류를 편하게 표현할 수 있는 기능을 제공한다.
#fields 로 BindingResult가 제공하는 검증 오류에 접근한다.
th:errors는 th:if 보다 편하게 사용할 수 있다. 해당 필드에 오류가 있는 경우 태그를 출력한다.
th:errorclass는 지정한 필드에 오류가 있으면 class 정보를 추가해준다.
BindingResult는 검증 오류를 보관하는 객체이고, BindingResult를 사용하면 @ModelAttribute에 데이터 바인딩 오류가 발생해도 컨트롤러가 호출된다. (모델에 Item과 BindingResult가 함께 전달된다)
즉, BindingResult 가 없을 때 바인딩 오류가 발생 시 400번대 에러가 호출돼 컨트롤러를 호출하는 대신 오류 페이지로 이동한다.
BindingResult가 있을 때 바인딩 오류가 발생 시 오류 정보를 BindingResult에 담아서 컨트롤러를 호출한다.
@ModelAttribute의 객체 타입 오류로 바인딩에 실패하면 스프링이 알아서 FieldError를 생성해 BindingResult에 넣어준다.
위에서 진행한 바와 같이 개발자가 직접 에러를 넣어준다. (비즈니스 로직)
또 Validator를 사용할 수 있는데, 이건 잠시 후에 알아보자.
BindingResult 는 인터페이스이고, Errors 인터페이스를 상속받는다.
실제 넘어오는 BindingResult의 구현체는 BeanPropertyBindingResult 객체이다.
그래도 BindingResult에 여러 기능이 포함돼있으니 Errors 대신 BindingResult를 사용하자.
그런데.. BindingResult를 사용할 때 문제가 있다.
사용하지 않고 검증을 처리했을 때는 문제가 있던 부분이 그대로 유지됐는데, BindingResult를 사용해 검증을 처리하니 문제가 있던 부분이 초기화돼서 클라이언트가 값을 다시 입력해야 된다.
이 부분을 수정하기 위해 FieldError와 ObjectError를 좀 더 자세히 살펴보자.
//bindingResult.addError(new FieldError("item", "quantity", "오류 메세지 : 수량체크하세요 9999까지"));
bindingResult.addError(new FieldError("item", "quantity",item.getItemName(), false, null, null, "오류 메세지 : 수량체크하세요 9999까지")
//bindingResult.addError(new ObjectError("item", "오류 메세지 : 토탈가격 만원이상으로하세요"));
bindingResult.addError(new ObjectError("item",null, null, "오류 메세지 : 토탈가격 만원이상으로하세요"));
FieldError는 두 가지 생성자를 제공한다.
1. String objectName, String field, String defaultMesasge
2. 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 : 기본 오류 메세지
오류가 발생한 경우 rejectedValue 파라미터에 클라이언트의 입력 데이터를 저장한다.
오류들을 가지고 있는 BindingResult는 모델에 자동으로 넘어가서 타임리프에서 해당 정보를 열람할 수 있다.
타임리프에서는 th:field = "${price}" 를 통해 값에 접근하는데, 정상적인 상황에서는 모델 객체의 값을 그대로 사용하지만 오류가 발생하면 FieldError에서 보관하는 값을 사용해서 값을 출력한다.
ObjectError도 FieldError와 유사하게 생성자를 제공한다.
이번에는 오류 메세지를 설계해보자.
오류 메세지도 하드코딩해서 넣는 것 보다 수정할 때 유리하게 프로퍼티를 사용해서 관리하는 편이 합리적이다.
FieldError 와 ObjectError 의 생성자는 codes와 arguments 파라미터를 제공하는데, 이 두 가지는 오류 발생 오류 코드로 메세지를 찾을 때 사용한다.
required.item.itemName=상품 이름은 필수입니다.
range.item.price=가격은 {0} ~ {1} 까지 허용합니다.
max.item.quantity=수량은 최대 {0} 까지 허용합니다.
totalPriceMin=가격 * 수량의 합은 {0}원 이상이어야 합니다. 현재 값 = {1}
errors.properties 파일을 만들고 프로퍼티를 설정해주자. (국제화도 가능하다)
spring.messages.basename = messages,errors
스프링이 해당 프로퍼티 파일을 인식할 수 있도록 application.properties 파일에 위 내용을 추가해주자.
if(item.getQuantity() == null || item.getQuantity() >= 9999){
bindingResult.addError(new FieldError("item", "quantity",item.getItemName(), false, new String[]{"max.item.quantity"}, new Object[]{9999}, "오류 메세지 : 수량체크하세요 9999까지"));
}
이후 bindingResult에 에러 메세지를 담을 때 프로퍼티의 키 값을 사용할 수 있다.
String 배열로 담아주는데, 해당하는 키 값을 찾을 때 까지 배열을 순회하고 못 찾으면 기본 메세지를 출력한다.
좀 더 간단하게 만들어보자.
컨트롤러에서 파라미터로 전달받는 BindingResult 는 항상 검증할 객체 다음에 오기 때문에, BindingResult는 이미 어떤 요소를 검증해야 하는지 알고 있다.
검증할 객체를 이미 알고 있다? 그러면 검증 오류를 좀 더 간단하게 다룰 수 있지 않을까?
BindingResult 가 제공하는 rejectValue() 와 reject() 메서드를 사용하면 addError() 메서드로 오류를 하나하나 넣는 경우보다 훨씬 간단하게 검증 오류를 다룰 수 있다.
각각 FieldError / ObjectError를 다룰 때 사용한다.
if(item.getPrice() == null || item.getPrice() < 1000 || item.getPrice() > 1_000_000) {
// bindingResult.addError(new FieldError("item", "price", item.getItemName(), false, new String[]{"range.item.price"}, new Object[]{1000, 100000}, "오류 메세지 : 가격 천 ~ 백만사이로쓰세요"));
bindingResult.rejectValue("price", "range", new Object[]{1000, 1000000}, null);
}
rejectValue() 메서드는 field / errorCode / errorArgs / defaultMessage 를 파라미터로 받는다.
이미 검증 대상을 알고 있기에 검증 대상에 대한 정보는 필요 없고, 오류 코드도 MessageCodeResolver를 통해 간단하게 작성할 수 있다.
required.item.itemName=상품 이름은 필수입니다.
range.item.price=가격은 {0} ~ {1} 까지 허용합니다.
max.item.quantity=수량은 최대 {0} 까지 허용합니다.
totalPriceMin=가격 * 수량의 합은 {0}원 이상이어야 합니다. 현재 값 = {1}
error= 에러남.
required = 필수임.
errors.properties 파일을 작성할 때는 자세하게 혹은 단순하게 만들 수 있다.
같은 변수를 사용하면서 세밀하게 작성해야 하는 경우에는 세밀한 내용이 적용되도록 하고 단순하게 작성해야 하는 경우에는 단순한 내용이 적용되도록 설계할 수는 없을까?
즉, 특정 형태에 맞춰서 프로퍼티를 정의해놓고 프로퍼티 내부에서 우선순위를 부여할 수는 없을까?
이 때 스프링은 MessageCodesResolver 기능을 사용한다.
@Test
void messageCodesResolverObject() {
String[] messageCodes = codesResolver.resolveMessageCodes("required","item");
assertThat(messageCodes).containsExactly("required.item", "required");
}
@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"
);
}
MessageCodesResolver 는 검증 오류 코드로 메세지 코드를 생성한다.
객체 오류
1. 오류코드 + "." + object name // required.item
2. 오류코드 // required
필드 오류
1. 오류코드 + "." + object name + "." + field // typeMismatch.user.age
2. 오류코드 + "." + field // typeMismatch.age
3. 오류코드 + "." + field type // typeMismatch.int
4. 오류코드 // type Miamatch
rejectValue() 와 reject() 메서드는 내부에서 MessageCodesResolver를 사용해 메세지 코드들을 생성한다.
핵심은 구체적인걸 먼저, 덜 구체적인건 나중에 처리하는 로직이다.
프로퍼티의 우선순위에 따라 차근차근 내려가며 일치하는 메세지을 찾아 중요도에 따라서 차등적으로 메세지를 뱉는다.
추가로, 검증 시 빈 값이 들어오거나 공백이 들어오는 등 간단한 내용은 ValidationUtils 를 사용해서 쉽게 처리할 수 있다.
ValidationUtils.rejectIfEmptyOrWhitespace(bindingResult, "itemName","required");
// validation logic
if(!StringUtils.hasText(item.getItemName())){
bindingResult.rejectValue("itemName", "required");
}
두 가지 코드는 동일한 작업을 수행한다.
개발자가 직접 오류를 설정하는 경우를 알아봤으니 스프링이 직접 검증 오류에 추가하는 경우를 살펴보자.
스프링은 타입 오류가 발생하면 typeMismatch 라는 오류 코드를 사용하고, MessageCodesResolver가 메세지 코드를 생성한다.
MessageCodesResolver를 통해 생성한 메세지 코드에 해당하는 요소가 없을 시 스프링은 기본 메세지를 생성해서 보여주는데, 이 기본 메세지가 클라이언트 입장에서 상당히 불친절하다 -_-
typeMismatch.java.lang.Integer=숫자를 입력해주세요.
typeMismatch=타입 오류입니다.
프로퍼티에 해당 오류 메세지를 추가해서 문제를 해결하자.
이제 검증 부분의 마지막이다.
컨트롤러에서 검증 로직을 분리해 따로 Validator 클래스를 만들어보자.
@ComponentScan
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 로 공백 체크는 간단하게
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);
}
}
}
}
스프링은 Validator 클래스를 통해 검증을 체계적으로 수행한다.
supports() 메서드로 해당 검증기를 지원하는지 확인하고, validate() 메서드로 검증 로직을 수행한다.
private final ItemValidator itemValidator;
@PostMapping("/add")
public String addItemV5(@ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes) {
itemValidator.validate(item, bindingResult);
if (bindingResult.hasErrors()) {
return "validation/v2/addForm";
}
//성공 로직 return.
Item savedItem = itemRepository.save(item);
redirectAttributes.addAttribute("itemId", savedItem.getId());
redirectAttributes.addAttribute("status", true);
return "redirect:/validation/v2/items/{itemId}";
}
컨트롤러에서는 주입받은 itemValidator를 가져와서 사용하면 된다.
여기서 좀 더 개선해보자.
스프링이 제공하는 Validator 인터페이스를 제대로 활용하면 좀 더 편하게 검증할 수 있다.
@InitBinder
public void init(WebDataBinder dataBinder){
dataBinder.addValidators(itemValidator);
}
WebDataBinder는 컨트롤러가 호출 될 때 마다 dataBinder가 새로 만들어지고, 내부에 검증기를 넣어둔다.
@PostMapping("/add")
public String addItemV6(@Validated @ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes) {
if (bindingResult.hasErrors()) {
return "validation/v2/addForm";
}
//성공 로직
Item savedItem = itemRepository.save(item);
redirectAttributes.addAttribute("itemId", savedItem.getId());
redirectAttributes.addAttribute("status", true);
return "redirect:/validation/v2/items/{itemId}";
}
메서드에 @Validated 애너테이션을 추가하면 item에 대해 자동으로 검증기가 실행된다.
@Validated 는 검증기를 실행하는 애너테이션으로, WebDataBinder에 등록한 검증기를 찾아서 실행한다.
검증기가 많은 경우 어떤 검증기를 사용할 지 구분이 필요한데, 이 때 support 메서드가 사용된다.
즉, 검증기를 하나씩 싹다 뒤져서 support를 실행해 true인 검증기를 실행한다.
컨트롤러에 @initBinder 애너테이션을 붙여서 사용할 경우 해당 컨트롤러에서만 검증기를 사용할 수 있지만, 다음과 같이 WebMvcConfiguer 인터페이스를 통해 모든 컨트롤러에 적용되도록 설정할 수 있다.
@SpringBootApplication
public class ItemServiceApplication implements WebMvcConfigurer {
public static void main(String[] args) {
SpringApplication.run(ItemServiceApplication.class, args);
}
@Override
public Validator getValidator() {
return new ItemValidator();
}
}
'Spring > Spring Web MVC' 카테고리의 다른 글
[Spring Web MVC] 예외 처리와 오류 페이지 (0) | 2022.08.26 |
---|---|
[Spring Web MVC] Bean Validation (0) | 2022.08.23 |
[Spring Web MVC] 메세지와 국제화 (0) | 2022.08.20 |
[Spring Web MVC] Thymeleaf (2) (0) | 2022.08.20 |
[Spring Web MVC] Thymeleaf (1) (0) | 2022.08.19 |
댓글
이 글 공유하기
다른 글
-
[Spring Web MVC] 예외 처리와 오류 페이지
[Spring Web MVC] 예외 처리와 오류 페이지
2022.08.26 -
[Spring Web MVC] Bean Validation
[Spring Web MVC] Bean Validation
2022.08.23 -
[Spring Web MVC] 메세지와 국제화
[Spring Web MVC] 메세지와 국제화
2022.08.20 -
[Spring Web MVC] Thymeleaf (2)
[Spring Web MVC] Thymeleaf (2)
2022.08.20