
📌 클라이언트 검증, 서버 검증
클라이언트 검증 : 조작할 수 있으므로 보안에 취약
서버 검증 : 서버만으로 검정하면, 즉각적인 고객 사용성이 부족해진다.
둘을 적절히 섞어서 사용하되, 최종적으로 서버 검증은 필수
API방식을 사용하면 API 스펙을 잘 정의해서 검증 오류를 API 응답 결과에 잘 남겨주어야 한다.
1. 사용자가 상품 등록 페이지에 접근(GET /add)
1. 사용자가 상품 등록 페이지에 접근(GET /add)Map에 담아 뷰에 보내는 방법@PostMapping("/add")
public String addItem(@ModelAttribute Item item, RedirectAttributes redirectAttributes, Model model) {
//검증 오류 결과를 보관
Map<String,String> errors = new HashMap<>();
//검증 로직
if(!StringUtils.hasText(item.getItemName())){ //itemName에 글자가 없으면
errors.put("itemName","상품 이름은 필수입니다."); //key, values
}
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개까지 허용합니다.");
}
//특정 필드가 아닌 복합 룰 검증. 가격 * 수량의 합은 10,000원이상
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}";
}
errors 객체를 만들고 errors 객체 안에 오류 정보를 담아둔다.key값으로는 필드명을, values값으로는 오류 발생 시 출력할 오류 메시지를 저장한다.<form action="item.html" th:action th:object="${item}" method="post">
<div th:if="${errors?.containsKey('globalError')}">
<p class="field-error" th:text="${errors['globalError']}">전체 오류 메시지</p>
</div>
<div>
<label for="itemName" th:text="#{label.item.itemName}">상품명</label>
<input type="text" id="itemName" th:field="*{itemName}"
th:class="${errors?.containsKey('itemName')} ? 'form-control field-error' : 'form-control'"
class="form-control" placeholder="이름을 입력하세요">
<div class="field-error" th:if="${errors?.containsKey('itemName')}" th:text="${errors['itemName']}">
상품명 오류
</div>
</div>
</form>
errors에 내용이 있으면 th:if를 이용하여 오류메시지를 출력한다.
📌Safe Navigation Operator
등록폼에 진입한 시점에서는errors가null이다.
errors가 없으면errors.contaionsKey()를 호출 하는 순간NullPointException이 발생한다.
errors?.는errors가null이여도NullPointException이 발생하지 않고,null을 반환해주는 문법이다.
직접 검증을 하는 방식은 타입 오류 처리가 되지 않는다.
숫자 필드는 타입이 Intreger이므로 문자 타입으로 설정하는 것이 불가능하므로 문자타입이 들어오면 오류가 발생한다.
즉, Controller가 호출되기 전에 예외가 발생하므로 Controller가 호출되지도 않고, 400 예외가 발생하면서 오류 페이지를 띄운다.
타입 오류가 발생하더라도 오류 페이지로 넘어가지 않고 고객이 입력한 문자를 화면에 남겨야한다.
BindingResult는 스프링이 제공하는 검증 오류를 보관하는 객체이다.BindingResult를 이용하면 직접 검증의 문제점이였던 타입 오류 처리도 가능하다.📌
@ModelAttribute에 바인딩 시 타입오류가 발생하면?
BindingResult가 없으면 -> 400오류 발생하면서 컨트롤러가 호출되지 않고 오류페이지로 이동BindingResult가 있으면 -> 오류 정보FieldError를BindingResult에 담아 컨트롤러 정상 호출
@PostMapping("/add")
public String addItemV1(@ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model) {
//검증 로직
if(!StringUtils.hasText(item.getItemName())){ //itemName에 글자가 없으면
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개까지 허용합니다."));
}
//특정 필드가 아닌 복합 룰 검증. 가격 * 수량의 합은 10,000원이상
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}";
}
BindingResult bindingResult 파라미터의 위치는 @ModelAttribute Item item다음에 와야 한다.BindingResult는 자동으로 view에 넘어가므로 model에 담는 로직 생략 가능.FieldError 객체를 생성해서 bindingResult에 담아둔다.📌 FieldError 생성자 요약
objectName:@ModelAttribute이름field: 오류가 발생한 필드 이름- `defualtMessage : 오류 기본 메시지
ObjectError객체를 사용한다.ObjectError객체를 생성해서 bindingResult에 담아둔다.📌 ObjectError 생성자 요약
objectName:@ModelAttribute이름- `defualtMessage : 오류 기본 메시지
<form action="item.html" th:action th:object="${item}" method="post">
<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" id="itemName" th:field="*{itemName}" th:errorclass="field-error" class="form-control" placeholder="이름을 입력하세요">
<div class="field-error" th:errors="*{itemName}">
상품명 오류
</div>
</div>
</form>
BindingResult를 활용해서 편리하게 검증 오류를 표현하는 기능을 제공한다#fields : BindingResult가 제공하는 검증 오류에 접근할 수 있다.th:errors : 해당 필드에 오류가 있는 경우에 태그 출력. th:if의 편의 버전th:errorclass : th:filed에서 지정한 필드에 오류가 있다면 class정보를 추가한다.하지만
BindingResult를 사용하여 오류 처리를 하면, 오류가 발생하는 경우 사용자가 입력한 내용이 모두 사라진다. 이 문제점을 해결하려면?
FieldError와 ObjectError를 사용하면 BindingResult를 사용하여 오류처리를 했을때 사용자가 입력한 내용을 화면에 남길 수 있다.FieldError는 두가지 생성자를 제공한다.(ObjectError도 유사)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 : 기본오류메시지다음과 같은 방식으로 사용하면 오류 발생 시 사용자 입력 값이 유지된다.
new FieldError("item", "quantity",item.getQuantity(),false,null,null, "수량은 최대 9,999개까지 허용합니다.")
new ObjectError("item",null,null,"가격 * 수량의 합은 10,000원 이상이어야 합니다. 현재 값 = " + resultPrice)
오류가 발생한 경우 사용자가 입력한 값을 보관하는 별도의 방법이 필요하고, 보관한 사용자 입력 값을 검증 오류 발생 시 화면에 다시 출력해야 한다.
fieldError는 오류 발생 시 사용자가 입력한 값을 저장하는 기능을 제공하고,
여기서 resultedValue가 바로 오류 발생 시 입력값을 저장하는 필드다.
th:field="*{price}"th:field는 정상 상황에서는 모델 객체의 값을 사용하지만, 오류가 발생하면 FieldError에서 보관한 값을 사용해서 값을 출력한다.FieldError, ObjectError의 생성자인 codes, arguments는 오류 발생시 오류 코드로 메시지를 찾기 위해 사용된다.application.properties
spring.messages.basename=messages,errors
스프링 부트가 해당 메시지 파일을 인식할 수 있게 위와 같이 설정해준다.
이렇게 설정해주면 messages.properties, errors.properties 두 파일 모두 인식한다
required.item.itemName=상품 이름은 필수입니다.
range.item.price=가격은 {0} ~ {1} 까지 허용합니다.
max.item.quantity=수량은 최대 {0} 까지 허용합니다.
totalPriceMin=가격 * 수량의 합은 {0}원 이상이어야 합니다. 현재 값 = {1}
bindingResult.addError(new FieldError("item", "itemName", item.getItemName(), false,
new String[]{"required.item.itemName"}, null, null));
위와 같이 controller에서 검증 로직을 변경하면 에러메시지를 사용할 수 있다.
codes : required.item.itemName를 사용해서 메시지 코드를 지정한다. 배열로 여러 값을 전달 할 수 있는데, 순서대로 매칭해서 처음 매칭되는 메시지가 사용된다.arguments : object[]{1000,10000}를 사용해서 코드의 {0},{1}로 치환할 값을 전달 한다.하지만 이런 방식은 매우 번거롭고, 담아야할 속성도 너무 많다.
BindingResult의rejectValue(),reject()를 사용하면 이러한 문제점을 해결할 수 있다.
bindingResult.rejectValue("itemName", "required");
bindingResult.rejectValue("price", "range", new Object[]{1000, 1000000}, null);
bindingResult.reject("totalPriceMin", new Object[]{10000, resultPrice}, null);
BindingResult가 제공하는 rejectValue()와 reject()를 사용하면 FieldError, ObjectError를 직접 생성하지 않고 깔끔하게 검증오류를 다룰 수 있다.
void rejectValue(@Nullable String field, String errorCode,
@Nullable Object[] errorArgs, @Nullable String defaultMessage);
field : 오류 필드명errorCode : 오류 코드(이 오류코드는 메시지에 등록된 코드가 아님! 뒤에서 설명할 messageResolver를 위한 오류 코드이다.errorArgs : 오류 메시지에 {0}을 치환하기 위한 값defalutMessage : 오류 메시지를 찾을 수 없을 때 사용하는 기본 메시지
errors.properties에 있는 코드를 직접 입력하지 않았는데 어떤 방식으로 오류메시지를 가져 오는 걸까?
스프링에서 제공하는 마커 인터페이스인 MessageCodesResolver는 기본 구현체로 DefaultMessageCodesResolver를 제공하기 때문에 이를 이용하여 각종 메시지에 대한 대처가 쉽게 가능하다.
MessageCodesResolver는 메시지의 단계에 따라 차례대로 메시지를 찾으면서 제일 처음 매칭되는 결과를 가져온다.
#Level1
required.item.itemName=상품 이름은 필수입니다.
#Level2
required.itemName=상품 이름을 입력해주세요.
#Level3
required.java.lang.String = 필수 문자입니다.
#Level4
required = 필수 값 입니다.
errors.properties가 위처럼 작성되어 있다면, MessageCodesResolver는 Leve1부터 순서대로 찾는다.
객체 오류의 경우 다음 순서로 2가지 생성
1.: code + "." + object name
2.: code
예) 오류 코드: required, object name: item
1.: required.item
2.: required
필드 오류의 경우 다음 순서로 4가지 메시지 코드 생성
필드오류1.: code + "." + object name + "." + field
2.: code + "." + field
3.: code + "." + field type
4.: code
예) 오류 코드: typeMismatch, object name "user", field "age", field type: int
1. "typeMismatch.user.age"
2. "typeMismatch.age"
3. "typeMismatch.int"
4. "typeMismatch"
📌동작 방식 정리
1.rejectValue()호출
2.MessageCodesResolver를 사용해서 검증 오류 코드로 메시지 코드들을 생성
3.new FieldError()를 생성하면서 메시지 코드들을 보관
4.th:errors에서 메시지 코드들로 메시지를 순서대로 메시지에서 찾고, 노출
스프링은 검증을 체계적으로 제공하기 위해 Validator라는 인터페이스를 제공한다.
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())){ //itemName에 글자가 없으면
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);
}
//특정 필드가 아닌 복합 룰 검증. 가격 * 수량의 합은 10,000원이상
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) : 해당 Validator 구현체가 Item클래스에 대한 검증을 수행할 수 있음을 의미Errors errors : BindingResult 클래스의 부모 타입@Slf4j
@Controller
@RequestMapping("/validation/v2/items")
@RequiredArgsConstructor
public class ValidationItemControllerV2 {
private final ItemRepository itemRepository;
private final ItemValidator itemValidator;
@PostMapping("/add")
public String addItemV5(@ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes) {
itemValidator.validate(item, bindingResult);
//검증에 실패하면 다시 입력 폼으로
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}";
}
}
컨트롤러에 있던 많은 검증 로직을 분리함으로써 컨트롤러에서는 validate메서드 호출만으로도 검증이 가능해졌다.
스프링에서는 검증기를 실행하라는 @Validated애너테이션을 지원한다.
이 애너테이션이 붙으면 앞서 WebDataBinder에 등록한 검증기를 찾아서 실행한다.
@Slf4j
@Controller
@RequestMapping("/validation/v2/items")
@RequiredArgsConstructor
public class ValidationItemControllerV2 {
private final ItemRepository itemRepository;
private final ItemValidator itemValidator;
@InitBinder
public void init(WebDataBinder dataBinder) {
dataBinder.addValidators(itemValidator);
}
위와 같이 @WebDataBinder를 사용하면 컨트롤러가 호출 될때마다 항상 WebDataBind가 내부적으로 만들어지고, validator를 찾는다. 즉, 어떤 메서드가 호출되든 항상 그 검증기를 적용할 수 있다.
@InitBinder를 사용하면 해당 검증기는 이 컨트롤러에서만 적용하게 된다. 글로벌 설정은 별도로 해야한다.
@PostMapping("/add")
public String addItemV6(@Validated @ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes) {
//검증에 실패하면 다시 입력 폼으로
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}";
}
@Validated : 검증기를 실행하라는 애노테이션. WebDataBinder에 등록한 검증기를 찾아 실행해준다.supports()가 사용된다.@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) {...}
📌@Validated와 @Valid
검증 시@Validated와@Valid둘 다 사용 가능하다.
@Validated는 스프링 전용 검증 애노테이션이고,@Valid는 자바 표준 검증 애노테이션이다.