아무 검증작업도 안해 놓으면 폼 입력시 빈 문자열이 오면 안되는데 빈 문자열이 온다거나, 숫자를 문자로 작성해서 검증 오류가 발생하면 오류화면으로 이동해버린다. 이렇게 되면 사용자는 다시 처음부터 작업을 해야된다. 이렇다면 이용자는 금세 떠나버릴 것이다. 고객이 입력한 데이터를 유지한 상태로 어떤 오류가 발생했는지 친절하게 알려줘야 된다.
컨트롤러의 중요한 역할중 하나는 HTTP 요청이 정상인지 검증하는 것이다.
참고 : 클라이언트 검증(자바스크립트로 보통 하는 검증), 서버 검증
- 클라이언트 검증은 조작할 수 있으므로 보안에 취약하다.
- 서버만으로 검증하면, 즉각적인 고객 사용성이 부족해진다.
- 둘을 적절히 섞어서 사용하되 최종적으로 서버 검증은 필수
- API 방식을 사용하면 API 스펙을 잘 정의해서 검증 오류를 API응답 결과에 잘 남겨주어야 한다.
Map<String, String> errors = new HashMap<>();
특정 필드 검증
if(!StringUtils.hasText(item.getItemName())) {
errors.put("itemName","상품 이름은 필수입니다.");
}
if(item.getPrice == null || item.getPrice() <1000 || item.getPrice() >1000000) {
error.put("price", "가격은 1,000 ~ 1,000,000 까지 허용합니다.");
}
if(item.getQuantity() == null || item.getQuantity() >= 9999) {
error.put("quantity", "수량은 최대 9,999까지 허용합니다.");
}
복합 필드 검증
if(item.getPrcie() != null && item.getQuantity != null) {
int resultPrice = item.getprice()*item.getQuantity();
if(resultPrice<10000) {
errors.put("globalError", "가격 * 수량 합은 10,000원 이상이어야 합니다. 현재 값="+resultPrice);
}
}
//검증에 실패하면 다시 입력 폼으로 (부정에 부정을 사용하지 말라)
if(!errors.isEmpty()) {
model.addAttribute("errors", errors);
return "validation/addForm";
}
@ModelAttribute가 자동으로 넘겨주는 model 덕분에 우리는 폼에 따로 정보를 남겨둘 필요가 없이 새로 로딩되어도 정보가 남아 있다.
화면단에 에러 표현시
<input type="text" id="price" th:field="*{price}"
th:class="${errors?.containsKey('price')} ? 'form-control field-error' : 'form-control'"
class="form-control"> // 오류시, 클래스를 다르게 표시함.
클래스에 css를 통해서 박스 색깔을 빨갛게해 경고
<div th:if="${errors?.containsKey('itemName')}" th:text="${errors['itemName']}상품 오류</div>
참고 : Safe Navigation Operator
만약 Map의errors
가 null이라면 어떻게 될까?
등록 폼에 진입한 시점에는 errors가 없다. 따라서errors.containsKey()
를 호출하는 순간 NullPointerException이 발생한다.
errors?.
는errors
가 null일때NullPointException
이 발생하는 대신, null을 반환한다. th:if에서 null은 실패로 처리되므로 오류 메시지가 출력 되지않는다.
문제점
@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() > 10000) {
bindingResult.addError(new FieldError("item", "quantity", "수량은 9,999 까지 허용합니다."));
}
//특정 필드 예외가 아닌 전체 예외
if (item.getPrice() != null && item.getQuantity() != null) {
int resultPrice = item.getPrice() * item.getQuantity();
if (resultPrice < 10000) {
bindingResult.addError(new ObjectError("item", "만원 이상이어야 됨 현재 값="+resultPrice));
}
}
//오류시 리턴
if (bindingResult.hasErrors()) {
log.info("errors={}", bindingResult);
return "validation/v2/addForm";
}
BindingResult 파라미터를 받아주면 된다. 이때 순서에 주의해야된다! 반드시 @ModelAttribute 파라미터 뒤에 와야 된다.
필드 오류는 FieldError
객체 생성해 bindingResult
에 담아두면 됨.
FieldError(objectName(사용 객체 이름), field(오류 발생 필드이름), defaultMessage(기본 메시지));
화면에서 오류 출력
<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">
<div class="field-error" th:errors="*{itemName}">상품명 오류</div>
</div>
th:errors
나 th:errorclass
를 사용하면 훨씬 편리 해진다.th:errors
, th:errorclass
. 해당 필드에 오류가 있는 경우 출력.#fields
로 BindingResult
가 제공하는 검증 오류에 접근 가능.중요
BindingResult는 스프링이 제공하는 검증 오류 보관 객체이다.
BindingResult는 데이터 바인딩시 오류가 발생해도 컨트롤러가 호출된다
BindingResult에 검증 오류를 적용하는 방법은 3가지가 있다.
FieldError
를 생성해 BindingResult
에 넣어준다.Validator
를 사용하는 방법실제 타입 오류를 내서 확인해보니 이상한 오류 메시지가 밑에 나왔다.
BindingResult
로 바꾸고 나서는 오류가 발생하는 경우 고객이 입력한 내용이 모두 사라진다. 이 문제를 해결하기 위해 FieldError
와 ObjectError
를 자세히 알아보자.
기존 FieldError에 받는 파라미터말고 더 많은 파라미터를 받을 수 있다.
FieldError(objectName(사용 객체 이름), field(오류 발생 필드이름), defaultMessage(기본 메시지));
FieldError(objectName, field, rejectedValue. bindingFailure, codes, arguments, defaultMessage);
ObjectError의 경우는 넘어오는 값이 있거나, 그런게 없기 때문에 bindingFailure가 발생하거나 필드 정보가 필요하지 않음. 여러 필드가 넘어오는 것을 조합해서 제공하기 때문에...
타임리프의 사용자 입력 값 유지
th:field가 매우 많은 역할을 한다. 정상 상황에는 모델 객체의 값을 사용하지만, 오류가 발생하면 FieldError
에서 보관한 값을 사용해서 값을 출력한다.
FieldError에 defaultMessage를 적어준 것을 기억할 것이다. 이런 오류 메시지들도 일관성 있게 한곳에서 관리가 가능하다.
메시지 파트에서 사용했던 message.properties같은 파일을 만들어서 한곳에서 제공 가능.
오류 메시지도 message.properties에 추가해도 작동은 하지만, 관리를 위해 errors.properties를 만들어 관리하자.
이때, spring.messages.basename=messages,errors 로 추가해 줘야된다.
errors.properties에 추가
required.item.itemName=상품 이름은 필수입니다.
range.item.price=가격은 {0} ~ {1} 까지 허용합니다.
max.item.quantity=수량은 최대 {0} 까지 허용합니다.
totalPriceMin=가격 * 수량의 합은 {0}원 이상이어야 합니다. 현재 값 = {1}
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.getQuantity() != null) {
int resultPrice = item.getPrice() * item.getQuantity();
if (resultPrice < 10000) {
bindingResult.addError(new ObjectError("item", new String[] {"totalPriceMin"},
new Object[]{10000, resultPrice}, null));
}
}
문제는 FieldError와 ObjectError는 다루기 너무 번거롭다.
BindingResult의 rejectValue()
나 reject()
를 사용하면 좀 더 간편하게 FieldError
나 ObjectError
를 사용하지 않고 사용 할 수 있다.
(BindingResult는 이미 Object나 target을 알고있다. @ModelAttribute객체 옆에 쓰는 이유)
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.getPrice() != null && item.getQuantity() != null) {
int resultPrice = item.getPrice() * item.getQuantity();
if (resultPrice < 10000) {
bindingResult.reject("totalPriceMin", new Object[]{10000, resultPrice}, null);
}
}
축약된 오류 코드
rejctValue의 작동 코드롤 보면 결국 우리가 이전에 했던 FieldError 생성을 대신 해준다. 무언가 다른 코드로 하는것이 아니다. 그냥 개발자가 쓰기 쉽게 줄여준것 뿐이다.
BindingResult는 어떤 객체를 대상으로 검증하는 target을 이미 알고있다. 따라서 target에 대한 정보를 입력해줄 필요가 없다.
rejectValue
오류 코드를 그 전에는 required.item.itemName
과 같이 모두 입력했다. 그런데 rejectValue()를 사용하고 부터는 required
라고 간단히 입력했다. 이는 바로 MessageResolver
가 동작을 한 것이다.
//단순함. 범용적
required=필수 값 입니다.
//객체명과 필드명을 조합한 세밀한 메시지 코드가 있으면 그 메시지를 높은 우선순위로 사용한다.
//Level1 -> 객체명과 필드명을 조합한 디테일한 이름.
required.item.itemName: 상품 이름은 필수입니다.
//Level2
required.tiem : 아이템은 필수 값입니다.
객체명과 필드명을 조합한 메시지를 사용하게 된다면, 메시지의 추가 만으로 매우 편리하게 오류 메시지를 관리하게 된다.
실제 코드에는 required만 적어두지만, 오류 메시지 관리 파일에서는 객체명과, 필드명을 조합해 더 세밀한 부분으로 조정이 가능한 것이다.
이런 것이 어떻게 가능할 것인가? 바로 MessageCodesResolver 덕분이다
MessageCodeResolver라는 인터페이스를 받아 테스트를 해본면 실제 나오는 값이 errorcode와 objectName, field, type를 조합한 몇가지 조합이 나오는 것을 확인할 수 있다.
이렇게 우선순위에 따라 우리가 만들둔 값을 꺼내서 온다.
객체 오류의 경우 다음 순서로 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"
ValidationUtils은 Empty , 공백 같은 단순한 기능만 제공. 만약 공백, Empty같은 것만 검증할 것 같으면 ValidtaionUtils를 쓰는게 더 편하긴 하다.
ValidationUtils.rejectIfEmptyOrWhitespace(bindingResult, "itemName","required");
기존에 어떤 메시지를 선택하지 않았을때 스프링에서 기본적으로 뽑아낸 이상한 메시지가 바로 이런식으로 typeMismatch라는 오류코드를 불러왔던 것이다. 이는 사실 스프링에서 만들어서 codes로 날려준 것이다.
우리는 이런 typeMismatch를 실제 error.properties에 정의해서 사용 할 수 도 있다.
메시지 코드 생성 전략은 그냥 만들어진 것이 아니다. 조금 뒤에서 Bean Validation을 학습하면 그 진가를 더 확인할 수 있다.
검증로직이 너무 많다. 컨트롤러에 있는 실제 로직은 몇줄 되지 않지만 검증 로직은 수십줄이 넘는다. 이런 기능을 분리해서 만들면 검증은 검증클래스에, 컨트롤 역할은 컨트롤러에 확실하게 분리가능 하다.
Validator 인터페이스를 상속받아 사용하면 된다. 실제 검증 코드들은 여기에 들어가게 될 것이다.
@Override
public boolean supports(Class<?> clazz) {
return Item.class.isAssignableFrom(clazz);
}
@Override
public void validate(Object target, Errors errors) {
}
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";
}
//성공 로직
Item savedItem = itemRepository.save(item);
redirectAttributes.addAttribute("itemId", savedItem.getId());
redirectAttributes.addAttribute("status", true);
return "redirect:/validation/v2/items/{itemId}";
}
그 많던 코드가 단 몇줄로 정의 되었다. 검증과 관련된 부분이 깔끔하게 분리되었는 것을 알 수 있다.
다만 왜 Validator
인터페이스를 별도로 제공받아서 구현을 할까? 이 Validator
없이 우리가 그냥 정의해서 사용해도 아무 문제 없이 동작할텐데 말이다. 그런데 Validator
인터페이스를 사용해 검증기를 만들면 스프링의 추가적인 도움을 받을 수 있다.
@InitBinder
public void init(WebDataBinder dataBinder) {
dataBinder.addValidators(itemValidator);
}
@PostMapping("/add")
public String addItemV5(@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}";
}
WebDataBinder라는 것을 검증기에 추가하면 해당 컨트롤러에서는 검증기를 자동으로 적요할 수 있다.
validator를 직접 호출하는 부분이 메서드에서 사라지고, 대신에 검증 대상 앞에 @validated
를 객체 앞에 붙여준다.
그런데 여러 검증기가 있다면 무슨 검증기가 호출되야 되는지 어떻게 아는가? 이는 support() 메서드가 자동으로 체크해준다.
참고
@Validated , @Valid 둘다 사용가능하다
@Valid를 사용하려면 의존관계 추가가 필요하다. @Validated는 스프링 전용 검증 애노테이션. @Valid는 자바 표즌 검증 애노테이션이다.
참고 : 본 글은 김영한님의 스프링 강의를 정리한 것이다.