일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | 5 | ||
6 | 7 | 8 | 9 | 10 | 11 | 12 |
13 | 14 | 15 | 16 | 17 | 18 | 19 |
20 | 21 | 22 | 23 | 24 | 25 | 26 |
27 | 28 | 29 | 30 |
- API 개발 고급
- 임베디드 타입
- 트위터
- 타임리프 문법
- 스프링MVC
- JPA
- 검증 애노테이션
- 컬렉션 조회 최적화
- 스프링 데이터 JPA
- 프로젝트 환경설정
- 벌크 연산
- 페이징
- 예제 도메인 모델
- JPA 활용2
- 타임리프
- 실무활용
- JPQL
- 일론머스크
- 값 타입 컬렉션
- JPA 활용 2
- 스프링
- Bean Validation
- 로그인
- 김영한
- 스프링 mvc
- 불변 객체
- Spring Data JPA
- 기본문법
- jpa 활용
- QueryDSL
- Today
- Total
RE-Heat 개발자 일지
스프링 MVC 2편 - [4] 검증 1 - Validation(상편) 본문
https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81-mvc-2/dashboard
스프링 MVC 2편 - 백엔드 웹 개발 활용 기술 - 인프런 | 강의
웹 애플리케이션 개발에 필요한 모든 웹 기술을 기초부터 이해하고, 완성할 수 있습니다. MVC 2편에서는 MVC 1편의 핵심 원리와 구조 위에 실무 웹 개발에 필요한 모든 활용 기술들을 학습할 수 있
www.inflearn.com
인프런 김영한 님의 스프링 MVC 2편 강의를 토대로 정리한 내용입니다.
[1] 검증 요구사항
<요구사항>
타입검증
- 가격, 수량에 문자가 들어가면 검증 오류 처리
필드검증
- 상품명 : 필수, 공백 불가
- 가격 : 1000~1000000
- 수량 : 최대 9999
특정 필드 범위 넘어서는지 검증
- 가격*수량의 합 10000원 이상
이전까지 만든 웹 애플리케이션에서 폼 입력 시 숫자를 잘못입력하는 등의 검증 오류가 발생하면 오류 페이지로 넘어가게 된다. 그러면 사용자는 처음부터 해당 폼으로 이동해 다시 입력해야 하는 번거로움을 겪게 된다.
이를 막기 위해선 기존의 값을 유지한 상태로 어떤 오류가 발생했는지 페이지 상에서 친절하게 알려주는 게 필요하다.
참고] 클라이언트 검증 vs 서버 검증
- 클라이언트 검증(자바스크립트)
단점 : 조작할 수 있어 보안에 취약하다.
ex) 포스트맨으로 거치지 않고 바로 불러올 수도 있다.
- 서버 검증
단점 : 즉각적인 고객 사용성이 부족해진다.
=> JavaScript 검증과 서버 검증 둘 다 섞어서 사용해야 한다.
=> API 방식을 사용하면 API 스펙을 잘 정의해 검증 오류를 API 응답 결과에 잘 남겨줘야 한다.
[2] 검증 직접 처리 - 소개
- 상품 저장 성공
PRG 방식(POST-GET-Redirect)으로 구성. 새로고침(가장 최근 작업 재반복)을 해도 값이 다시 추가되는 것을 막았다.
- 상품 저장 실패
상품명을 입력하지 않거나 가격, 수량 등이 검증 범위를 넘어서면 검증 로직 실패 처리를 해야 한다. 이렇게 검증에 실패하면 실패한 값을 model에 담아 다시 상품 등록 폼으로 넘기고 어떤 값을 잘못 입력했는지 친절하게 설명해야 한다.
[3] 검증 직접 처리 - 개발
- 상품 등록 검증
ValidationConotrollerV1 - 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", "가격은 1,000~1,000,000 까지 허용합니다.");
}
if (item.getQuantity() == null || item.getQuantity() > 9999) {
errors.put("quantity", "수량은 최대 9999까지 허용합니다.");
}
//특정 필드가 아닌 복합 룰 검증
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)를 보관할 HashMap() 만듦
② 검증 로직 작성(특정 필드)
!StringUtils.hasText(item.getItemName()) => 상품명이 있는지 확인
errors에 key, value 타입으로 오류가 발생한 필드명, 메시지 담음.
③ 특정 필드가 아닌 복합 룰 검증
price와 quantity 중 하나라도 null이 아님
가격과 수량의 곱이 10000원 이하임.
복합 룰이어서 필드명을 넣을 수 없으므로 globalError라는 key를 사용. + 입력한 현재값도 명시해 줌.
④ 검증에 실패하면 다시 입력 폼으로
!errors.isEmpty() => erros가 비지 않았다면. 에러가 발생했다면
model에 errors를 담아주고 addForm으로 보냄
⑤ addForm으로 보내기 전 @ModelAttribute로 인해 검증 실패여도 잘못 입력한 Item값이 넘어감.
@ModelAttribute Item item => model.addAttribute("item", item) 대신해 준다.
addForm.html에서 th:object="${item}"으로 값을 받으므로 기존 값을 담아 보내려고 따로 수정할 필요는 없다.
addForm.html
- 오류 메시지 강조 위해 CSS 추가
<style>
.container {
max-width: 560px;
}
.field-error {
border-color: #dc3545;
color: #dc3545;
}
</style>
- 글로벌 오류 메시지
<div th:if="${errors?.containsKey('globalError')}">
<p class="field-error" th:text="${errors['globalError']}">전체 오류 메시지</p>
</div>
① th:if로 errors에 내용이 있을 때만 출력된다. null이면 출력되지 않음.
② errors['키값(globalError)']로 값 불러온 후 th:text로 전체 오류 메시지 대체
참고] Safe Navigation Operator
제일 처음에 등록폼에 진입한 시점엔 errors값이 null이다. 그래서 errors.containsKye()를 호출하면 NullPointerException이 발생한다. 그래서 errors에 물음표는 붙으는 방식으로 NullPointerException대신 null을 반환하게 만들었다.
- 필드 오류 처리
첫 번째 방법
<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>
errors?.containKey('ItemName')이 존재하면 class를 'form-control field-error'로 아니면 'form-control'로 바꾼다.
타임리프의 삼항연산자 사용
두 번째 방법
<input type="text" th:classappend="${errors?.containsKey('itemName')} ? 'field-error' : _"
class="form-control">
th:classappend를 사용해 해당 필드에 오류가 있으면 기존 클래스 정보에 field-error를 더하고 아니면 _(No-Opertation)을 사용해서 아무것도 하지 않는다.
타임리프 기본기능인 속성값 관련 내용은 아래 링크에서 확인 가능
스프링 MVC 2편 - [1] 타임리프 - 기본기능(하편)
실행화면
V1 결과
- 만약 검증 오류가 발생하면 입력 폼을 다시 보여준다.
- 검증 오류들을 고객에게 친절하게 안내해서 다시 입력할 수 있게 한다.
- 검증 오류가 발생해도 고객이 입력한 데이터가 유지된다.
문제점
1. 뷰 템플릿에서 중복 처리가 많다.
2. 타입 오류 처리가 안 된다. 현 상태에서 가격(Item의 Integer)에 문자를 입력하면 400번 예외가 발생.
=> 이러한 오류는 컨트롤러에 진입하기도 전에 발생하기 때문에 다른 방법을 써야 한다.
3. Item price에 문자를 입력해 타입오류가 발생해도 고객이 입력한 문자를 화면에 남겨야 한다.
그렇다고 String으로 처리하기엔 상당히 번거롭다.
스프링에서 제공하는 검증방법으로 위 문제를 해결할 예정이다.
[4] BindingResult1
주의 : BindingResult 파라미터 위치는 @ModelAttribute Item item 다음에 나와야 한다.
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", "가격은 1,000~1,000,000 까지 허용합니다."));
}
if (item.getQuantity() == null || item.getQuantity() > 9999) {
bindingResult.addError(new FieldError("item", "quantity", "수량은 최대 9999까지 허용합니다."));
}
//특정 필드가 아닌 복합 룰 검증
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);
//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}";
}
① errors 대신 스프링에서 제공하는 BindingResult 사용
② 필드 오류는 FiledError 사용
public FieldError(String objectName, String field, String defaultMessage) {}
- objectName: @ModelAttribute 이름
- field : 오류가 발생한 필드
- defaultMessage: 오류 기본 메시지
③ 글로벌 오류는 ObjectError 사용
public ObjectError(String objectName, String defaultMessage) {}
- objectName: @ModelAttribute 이름
- defaultMessage: 오류 기본 메시지
=>필드가 아니므로 필드명은 없음
참고] 각 메소드의 파라미터를 알아보고 싶으면 윈도우 기준 ctrl+p를 누르면 됨.
addForm.html
■ 필드 오류 수정 (V2가 최신 버전)
<!-- V2 -->
<input type="text" id="itemName" th:field="*{itemName}"
th:errorClass="field-error"
class="form-control" placeholder="이름을 입력하세요">
<div class="field-error" th:errors="*{itemName}">
상품명 오류
</div>
<!-- V1 -->
<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>
■ 글로벌 오류 수정
<!-- V2 -->
<div th:if="${#fields.hasGlobalErrors()}">
<p class="field-error" th:each="err : ${#fields.globalErrors()}" th:text="${err}">전체 오류 메시지</p>
</div>
<!-- V1 -->
<div th:if="${errors?.containsKey('globalError')}">
<p class="field-error" th:text="${errors['globalError']}">전체 오류 메시지</p>
</div>
① #fields로 BindingResult가 제공하는 검증 오류에 접근할 수 있다.
② th:errors : 해당 필드에 오류가 있을 때 태그를 출력한다. th:if의 편의 버전
V2 : th:errors="*{itemName}"
V1 : th:if="${errors?.containsKey('itemName')}" th:text="${errors['itemName']}"
③ th:errorclass : th:field에서 지정한 필드에 오류가 있으면 class 정보를 추가(append)한다.
V2: th:errorclass="field-error"
V1: 1] th:class="${errors?.containsKey('itemName')} ? 'form-control field-error' : 'form-control'"
2] th:classappend="${errors?.containsKey('itemName')} ? 'field-error' : _"
[5] BindingResult2
BindingResult가 없으면 400번 오류가 발생하고 컨트롤러가 호출되지 않아 그대로 오류 페이지로 이동했다. 하지만 BindingResult가 있으면 오류 정보를 BindingResult에 담아서 컨트롤러를 정상 호출한다.
타입이 틀려도 400번 오류 페이지로 넘어가지 않는 것을 확인할 수 있다.
■ BindingResult에 검증 오류를 적용하는 3가지 방법
① @ModelAttribute의 객체 타입 오류 등으로 바인딩에 실패하면 스프링이 FieldError를 생성해 BindingResult에 삽입
② 개발자가 직접 넣는다.
③ Validator 사용
참고] BindingResult는 Errors 인터페이스를 상속한다. 하지만, Errors는 단순 오류 저장과 조회 기능만 제공하므로 추가적인 기능을 제공하는 BindingResult를 사용하자.
[6] FieldError, ObjectError
BindingResult를 사용하면 @ModelAttribute가 담아줬던 사용자 입력 오류 메시지가 화면에 남지 않게 된다. 그래서 스프링에선 입력 오류 메시지가 남도록 FileError에 두 가지 생성자를 제공한다.
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 : 기본오류 메시지
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, "가격은 1,000~1,000,000 까지 허용합니다."));
}
if (item.getQuantity() == null || item.getQuantity() > 9999) {
bindingResult.addError(new FieldError("item", "quantity", item.getQuantity(), false, null, null, "수량은 최대 9999까지 허용합니다."));
}
//특정 필드가 아닌 복합 룰 검증
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));
}
}
상품명 누락 (new FiledError)
- objectName : "item"
- field : "itemName"
- rejectedValue : item.getItemName()
- bindingFailure : 타입오류 같은 바인딩 실패인지, 검증실패인지 구분값 => false
- codes : 메시지 코드 => null
- arguments : 메시지에서 사용하는 인자 => null
- defaultMessage : 기본오류 메시지 => "상품 이름은 필수입니다."
복합 룰 검증 (new ObjectError)
- objectName : "item"
- codes : 메시지 코드 => null
- arguments : 메시지에서 사용하는 인자 => null
- defaultMessage : 기본오류 메시지 => "가격 * 수량의 합은 10,000원 이상이어야 합니다."
사용자 입력 데이터가 컨트롤러의 @ModelAttribute에 바인딩되는 시점에 오류가 발생하면 모델 객체에 사용자 입력 값을 유지하기 어렵다. 예를 들어 가격에 숫자가 아닌 문자를 입력하면 Integer타입이 아니므로 보관할 방법이 없다.
그래서 스프링은 rejectedValue에 사용자 입력 값을 저장하는 필드를 따로 제공한다.
bindingFailure는 타입 오류 같이 바인딩이 실패했는지 여부를 적어주면 된다.
■ th:field 사용자 입력 값 유지
th:field="*{price}"
타임리프 th:field는 정상적인 상황에선 model item 객체 getPrice()로 모델 객체 값을 사용하지만, 오류가 발생하면 FieldError에 보관한 값을 사용해 출력한다.
'백엔드 > 스프링' 카테고리의 다른 글
스프링 MVC 2편 - [5] 검증2- Bean Validation(상편) (1) | 2023.07.19 |
---|---|
스프링 MVC 2편 - [4] 검증 1 - Validation(하편) (1) | 2023.07.16 |
스프링 MVC 2편 - [3] 메시지·국제화 (0) | 2023.07.13 |
스프링 MVC 2편 - [2] 스프링 통합과 폼 (0) | 2023.07.12 |
스프링 MVC 2편 - [1] 타임리프 - 기본기능(하편) (0) | 2023.07.09 |