사용자는 하루에도 웹사이트 수많은 데이터들을 입력하고 저장한다. 이 때, 서버로 전달되는 데이터들은 반드시 검증 단계를 거쳐야 한다.
핸드폰 번호를 입력해야하는데 이름을 입력하거나, 사이트 정책상 비밀번호를 10자리 이상 등록 해야하는데 9자리만 등록하거나 등 수많은 잘못된 형식의 데이터들이 입력될 수 있기 때문에 검증은 필수적이다.
그리고 검증뿐만 아니라 데이터가 잘못 입력되었다는게 확인됐을 경우 그 이후 처리도 중요하다. 회원이 데이터 타입을 잘못 입력했다고 해서 컴파일러 에러 메시지를 그대로 사용자에게 전달 할 순 없지 않는가?
입력된 데이터의 검증은 과연 클라이언트 단에서 처리 돼야 할까? 아니면 서버에서 처리해야 할 까?
결론 부터 말하자면 둘 다 해야한다.
서버 측 에서는 해킹과 보안 이슈가 있기 때문에 사실상 필수로 검증을 해줘야하며,
클라이언트 측 에서는 사용자가 어디가 틀렸는지, 내가 뭘 입력해서 틀렸는지 와 같은 사용성 향상을 위해서 검증을 해줘야한다.

사용자가 클릭 한번 잘못했다가 이런 에러를 맞이했다고 생각해보자,,, 대략 난감이다.
타입 검증
가격, 수량 에 문자가 들어가면 검증 오류 처리
필드 검증
상품명 : 필수로 입력, 공백X
가격 : 1000원 이상, 1백만원 이하
수량 : 최대 9999
특정 필드의 범위를 넘어서는 검증
가격 * 수량 의 합은 10,000원 이상
사용자 입력 기억
사용자가 잘못 입력 했을 경우 오류 메시지와 함께 이전의 입력이 무엇이였는지 보여줘야함.
@GetMapping("/add")
public String addForm(Model model) {
model.addAttribute("item", new Item());
return "validation/v1/addForm";
}
/////////////////////////////////////////////////
@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 까지 허용합니다.");
}
//특정 필드가 아닌 복합 룰 검증
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}";
}
<form> 태그
<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>
<!-------------------------------------------------->
<div>
<label for="price" th:text="#{label.item.price}">가격</label>
<input type="text" id="price" th:field="*{price}"
th:class="${errors?.containsKey('price')} ? 'form-control field-error' : 'form-control'"
class="form-control" placeholder="가격을 입력하세요">
<div class="field-error" th:if="${errors?.containsKey('price')}" th:text="${errors['price']}">
가격 오류
</div>
</div>
여기서 중요한 부분은 GET "/add" 컨트롤러 가 Item 객체를 모델에 담아 반환하는것이다.
이 경우 2가지 장점이 생기는데
1) 타임리프 th:object 사용이 가능
2) 1) 덕분에 POST "/add" 컨트롤러와 템플릿 재사용이 가능
POST "/add" 의 검증 실패 로직 과 GET "/add" 모두 동일한 validation/v1/addForm (HTML 뷰) 를 반환하는 것을 볼 수 있다.
이것이 가능한 이유는 두 요청이 모두 양식을 통일 시키고 있기 때문이다. GET 과 POST 모두 뷰에다가 Item 을 Model 에 담아 전달 한다.
이 덕분에 굳이 두 GET요청 따로, POST 요청 따로 서로 다른 HTML 파일을 만들 필요없이 같은 HTML 파일을 사용 할 수 있는 것이다.
타입 오류 처리 불가능
타입 오류가 발생한다는것은 Integer 타입의 변수에 String 데이터를 저장하려는 상황에 발생한다.
즉, 컴파일에러가 발생하는것이기 때문에 아예 서버가 다운 돼버린다.
중복 코드
뷰 템플릿에 코드가 중복이 많고 너무 길어진다.
// 에러가 있는지 확인하고 있으면 클래스에 "field-error" 추가
th:class="${errors?.containsKey('itemName')} ?
'form-control field-error' : 'form-control'"
// "상품명" 에 대한 에러인지 확인하고 맞다면 "상품명"에 대한 에러 문구 ``<div>`` 생성
class="field-error" th:if="${errors?.containsKey('itemName')}" th:text="${errors['itemName']}">
상품명 오류
// @GetMapping 컨트롤러는 V1과 동일
@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 까지 허용합니다."));
}
//특정 필드가 아닌 복합 룰 검증
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}";
}
FieldError
이름 그대로 Item 객체에 있는 필드에 대한 에러를 저장한다
ObjectError
특정 필드에만 관련된 것이 아닌 전역적인 오류(글로벌 에러) 를 저장한다.
글로벌 에러
<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>
${#fields.hasGlobalErrors()}
글로벌 오류가 발생하면 컨트롤러에서 ObjectError 에 담았던 모든 에러를 꺼내준다.
th:errors="*{필드명}"
필드 오류가 발생하면 매핑되어있는 FiledError 에서 에러메시지를 꺼낸다.
th:errorclass
에러 클래스를 등록 하는 속성이다. 필드 오류가 발생할경우 자동으로 타임리프가 errorclass("field-error")의 값을 class에 추가해준다.
전체적으로 V1 에 비해 한결 코드가 깔끔해진걸 확인할 수 있다.
BindingResult는 스프링 MVC에서 폼 데이터 바인딩 과정 중에 발생하는 검증 오류를 수집하고 처리하는 객체이다. 폼 데이터를 모델 객체에 바인딩하고 검증을 수행하는 동안 발생한 오류 정보를 저장하고 제공해준다.
V1 에서는 에러와 에러메시지를 Map 에 담아 관리를 뷰에 전달해주었다. 하지만 V2 에서는 BindingResult 가 그 역할을 대체한다.
BindingResult 를 사용하면 스프링과 타임리프가 제공해주는 편의 기능을 사용 할 수 있다.
타입 오류는 앞에서 설명했듯이 컴파일 에러가 발생하는것이기 때문에 서버가 셧다운 돼버리는 바람에 컨트롤러가 호출이 안 돼서 기존의 방식으론 검증하기가 불가능했다.
하지만 BindingResult 가 이를 해결 해준다.
예를들어, 사용자가 숫자입력 칸에 문자열을 입력하는 경우
1. 특정 필드에 값이 바인딩 되는 과정에서 오류가 발생
2. BindingResult 가 이 부분을 캐치해서 타입 에러를 담는다.
3. 그 다음 컨트롤러를 호출해준다.
FieldError 의 생성자를 보면 다음과 같다.

objectName: 오류가 발생한 객체 이름
field: 오류 필드
rejectedValue: 사용자가 입력한 값(거절된 값)
bindingFailure: 타입 오류 같은 바인딩 실패인지, 검증 실패인지 구분 값
codes: 메시지 코드
arguments: 메시지에서 사용하는 인자
defaultMessage: 기본 오류 메시지
여기서 rejectedValue 필드가 존재하는데 이 값을 초기화 해주지 않는다면 사용자가 입력했던값이 유지가 되지 않는다.
※ ObjectError 의 경우 당연히 특정 필드에 대한 오류가 아니므로 rejectedValue 필드가 없다.
V1 에 비해 굉장히 개선이 많이 되었다. 타입 오류에 대한 요구사항도 지켰으며, 입력 값 유지도 그대로 된다.
하지만, 타입 오류 발생시 오류 메시지가 너무 개발자스럽다;;

이 때 빛을 발하는게 바로 메시지 이다. 이제 오류 메시지에 대한 문구들을 관리하는 메시지 소스를 생성해보자.
본 포스트는
김영한의 스프링 MVC 2편 - 백엔드 웹 개발 활용 기술 강의 를 보고 정리했습니다.