-
[스프링MVC - 2편] 검증 (1)스프링&스프링부트 2025. 2. 8. 18:00
검증 요구사항
요구사항: 검증 로직 추가 - 컨트롤러의 중요한 역할중 하나는 HTTP 요청이 정상인지 검증하는 것이다
- 타입 검증
- 가격, 수량에 문자가 들어가면 검증 오류 처리
- 필드 검증
- 상품명 : 필수, 공백X
- 가격 : 1000원 이상, 1백만원 이하
- 수량 : 최대 9999
- 특정 필드의 범위를 넘어서는 검증
- 가격 * 수량의 합은 10,000원 이상
클라이언트 검증, 서버 검증
- 클라이언트 검증은 조작할 수 있으므로 보안에 취약하다
- 서버만으로 검증하면, 즉각적인 고객 사용성이 부족해진다
- 둘을 적절히 섞어서 사용하되, 최종적으로 서버 검증은 필수
- API 방식을 사용하면 API 스펙을 잘 정의해서 검증 오류를 API 응답 결과에 잘 남겨주어야 함
화면 개발자와 함께 업무를 진행했던 프로젝트가 있었다
그때 화면쪽에서 이미 검증을 1차적으로 해서 넘겨주는데 서버에서 왜 검증을 해야하는지 의문이었다
만약 화면에서 자바스크립트를 누군가 조작해 검증을 통과했고 서버쪽에서 검증을 하지 않았을 경우 DB에 잘못된 값이 저장될 수 있다는 경우가 있어 서버쪽에서도 검증은 필수라고 알고 있었다
결론은 강의에서와 같이 화면, 서버 모두 해야하는 것이다
검증 직접 처리
1. 상품 저장 성공
사용자가 상품 등록 폼에서 정상 범위의 데이터를 입력하면, 서버에서는 검증 로직이 통과하고, 상품을 저장하고, 상품 상세 화면으로 redirect한다
2. 상품 저장 실패
고객이 상품 등록 폼에서 상품명을 입력하지 않거나, 가격, 수량 등이 너무 작거나 커서 검증 범위를 넘어서면, 서버 검
증 로직이 실패해야 한다
이렇게 검증에 실패한 경우 고객에게 다시 상품 등록 폼을 보여주고, 어떤 값을 잘못 입력했는지 알려주어야 한다
1. 프로젝트 V1
1-1. ValidationItemControllerV1 - addItem() 수정
@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", "가격은 1000 ~ 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}"; }
검증 오류 보관
Map<String, String> errors = new HashMap<>();
만약 검증시 오류가 발생하면 어떤 검증에서 오류가 발생했는지 정보를 담아둔다
- 상품명을 등록하지 않고 저장 버튼을 눌렀을 때, error 발생 확인
1-2. addForm.html 수정
<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>
나머지 검증 확인 참고 Safe Navigation Operator
만약 여기에서 errors가 null이라면 어떻게 될까?
등록폼에 진입한 시점에는 errors가 없다 따라서 errors.containsKey()를 호출하는 순간 NullPointerException이 발생한다
errors?. 은 errors가 null일때 NullPointerException이 발생하는 대신, null을 반환하는 문법이다
th:if에서 null은 실패로 처리되므로 오류 메시지가 출력되지 않는다
이것은 스프링의 SpringEL이 제공하는 문법이다
정리
- 만약 검증 오류가 발생하면 입력 폼을 다시 보여준다
- 검증 오류들을 고객에게 친절하게 안내해서 다시 입력할 수 있게 한다
- 검증 오류가 발생해도 고객이 입력한 데이터가 유지된다
2. 프로젝트 V2 - BindingResult
2-1. ValidationItemControllerV2 - addItemV1() 수정
@PostMapping("/add") public String addItemV1(@ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model) { // 검증 로직 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", "가격은 1000 ~ 1,000,000 까지 허용합니다.")); } if (item.getQuantity() == null || item.getQuantity() >= 9999) { 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", "가격*수량의 합은 10,000원 이상이어야 합니다. 현재 값 = " + resultPrice)); } } // 검증에 실패하면 다시 입력 폼으로 이동 if (bindingResult.hasErrors()) { log.info("errors = {}", bindingResult); // model.addAttribute("errors", errors); // bindingResult는 자동으로 view로 넘어간다 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 다음에 와야 한다
FieldError 생성자
public FieldError(String objectName, String field, String defaultMessage) {}
필드에 오류가 있으면 FieldError 객체를 생성해서 bindingResult에 담아두면 된다
- objectName : @ModelAttribute 이름
- field : 오류가 발생한 필드 이름
- defaultMessage : 오류 기본 메시지
ObjectError 생성자
public ObjectError(String objectName, String defaultMessage) {}
특정 필드를 넘어서는 오류가 있으면 ObjectError 객체를 생성해서 bindingResult에 담아두면 된다
- objectName : @ModelAttribute의 이름
- defaultMessage : 오류 기본 메시지
2-2. addForm.html 수정
<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>
타임리프 스프링 검증 오류 통합 기능
타임리프는 스프링의 BindingResult를 활용해서 편리하게 검증 오류를 표현하는 기능을 제공한다
- #fields : #fields로 BindingResult가 제공하는 검증 오류에 접근할 수 있다
- th:errors : 해당 필드에 오류가 있는 경우에 태그를 출력한다
- th:errorclass : th:field에서 지정한 필드에 오류가 있으면 class 정보를 추가한다
@ModelAttribute에 바인딩 시 타입 오류가 발생 BindingResult가 있으면 @ModelAttribute에 데이터 바인딩 시 오류가 발생해도 컨트롤러가 호출된다
@ModelAttribute에 바인딩 시 타입 오류가 발생
- BindingResult가 없으면 400 오류가 발생하면서 컨트롤러가 호출되지 않고, 오류 페이지로 이동한다
- BindingResult가 있으면 오류 정보(FieldError)를 BindingResult에 담아 컨트롤러를 정상 호출한다
BindingResult와 Errors
- org.springframework.validation.Errors
- org.springframework.validation.BindingResult
BindingResult는 인터페이스이고, Errors 인터페이스를 상속받고 있다
실제 넘어오는 구현체는 BeanPropertyBindingResult라는 것인데, 둘다 구현하고 있으므로 BindingResult 대신에 Errors를 사용해도 된다
Errors 인터페이스는 단순한 오류 저장과 조회 기능을 제공한다
BindingResult는 여기에 더해서 추가적인 기능들을 제공하며 addError()도 BindingResult가 제공한다
FieldError, ObjectError
2-3. ValidationItemControllerV2 - addItemV2
@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, "가격은 1000 ~ 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 생성자
FieldError는 두 가지 생성자를 제공한다
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 : 기본 오류 메시지
타임리프의 사용자 입력 값 유지
th:field="*{price}"
타임리프의 th:field는 매우 똑똑하게 동작하는데, 정상 상황에는 모델 객체의 값을 사용하지만, 오류가 발생하면 FieldError에서 보관한 값을 사용해서 값을 출력한다
스프링의 바인딩 오류 처리
타입 오류로 바인딩에 실패하면 스프링은 FieldError를 생성하면서 사용자가 입력한 값을 넣어둔다
그리고 해당 오류를 BindingResult에 담아서 컨트롤러를 호출한다. 따라서 타입 오류 같은 바인딩 실패시에도 사용자의 오류 메시지를 정상 출력할 수 있다
검증에 실패해도 값이 유지됨 확인 728x90'스프링&스프링부트' 카테고리의 다른 글
[스프링MVC - 2편] 검증 (3) (1) 2025.03.02 [스프링MVC - 2편] 검증 (2) (0) 2025.02.09 [스프링MVC - 2편] 메시지, 국제화 (0) 2025.02.04 [스프링MVC - 2편] 타임리프 스프링 통합 (2) (1) 2025.02.02 [스프링MVC - 2편] 타임리프 스프링 통합 (2) (0) 2025.02.02 - 타입 검증