Notice
Recent Posts
Recent Comments
Link
일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
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 | 31 |
Tags
- 자바의 정석 기초편 ch5
- 스프링 mvc1 - 스프링 mvc
- jpa - 객체지향 쿼리 언어
- 스프링 db2 - 데이터 접근 기술
- 스프링 입문(무료)
- 자바의 정석 기초편 ch14
- 자바의 정석 기초편 ch7
- 2024 정보처리기사 수제비 실기
- jpa 활용2 - api 개발 고급
- 스프링 db1 - 스프링과 문제 해결
- 스프링 mvc2 - 로그인 처리
- 2024 정보처리기사 시나공 필기
- 자바의 정석 기초편 ch9
- 자바의 정석 기초편 ch2
- 스프링 mvc2 - 검증
- 스프링 고급 - 스프링 aop
- 자바의 정석 기초편 ch1
- 자바의 정석 기초편 ch3
- 스프링 mvc1 - 서블릿
- 자바의 정석 기초편 ch6
- 스프링 mvc2 - 타임리프
- 자바의 정석 기초편 ch4
- 자바의 정석 기초편 ch13
- 타임리프 - 기본기능
- 게시글 목록 api
- 자바의 정석 기초편 ch11
- 자바의 정석 기초편 ch12
- @Aspect
- 자바의 정석 기초편 ch8
- 코드로 시작하는 자바 첫걸음
Archives
- Today
- Total
나구리의 개발공부기록
검증1 - Validation, 검증 요구사항 및 프로젝트 설정 V1, 검증 직접 처리(소개 및 개발), 프로젝트 준비 V2, BindingResult1, BindingResult2, FieldError, ObjectError 본문
인프런 - 스프링 완전정복 코스 로드맵/스프링 MVC 2편 - 백엔드 웹 개발 핵심 기술
검증1 - Validation, 검증 요구사항 및 프로젝트 설정 V1, 검증 직접 처리(소개 및 개발), 프로젝트 준비 V2, BindingResult1, BindingResult2, FieldError, ObjectError
소소한나구리 2024. 9. 3. 20:29 출처 : 인프런 - 스프링 MVC 2편 - 백엔드 웹 개발 핵심 기술 (유료) / 김영한님
유료 강의이므로 정리에 초점을 두고 코드는 일부만 인용
1. 검증 요구사항 및 프로젝트 설정 V1
1) 요구사항 : 검증 로직 추가
(1) 타입 검증
- 가격, 수량에 문자가 들어가면 검증 오류 처리
(2) 필드 검증
- 상품명 : 필수, 공백 x
- 가격 : 1000원 이상, 1백만원 이하
- 수량 : 최대 9999
(3) 특정 필드의 범위를 넘어서는 검증
- 가격 * 수량의 합은 10,000원 이상
2) 현재까지 만든 웹 애플리케이션의 문제점
- 폼 입력시 숫자를 문자로 작성하여 검증 오류가 발생하면 오류 화면으로 바로 이동해서 사용자가 처음부터 해당 폼으로 다시 이동해서 입력해야함
- 값을 넣지 않아도 상품이 등록되어버림
- 이러한 서비스라면 사용자는 금방 떠나갈 것이 분명하므로 웹 서비스는 폼 입력시 오류가 발생하면 고객이 입력한 데이터를 유지한 상태로 어떤 오류가 발생했는지 친절하게 알려주어야 함
- 컨트롤러의 중요한 역할 중 하나는 HTTP 요청이 정상인지 검증하는 것, 그리고 정상 로직보다 이런 검증 로직을 잘 개발하는 것이 어쩌면 더 어려울 수 있음
** 참고 : 클라이언트 검증, 서버 검증
- 클라이언트 검증은 조작할 수 있으므로 보안에 취약함 (postman 등으로 쉽게 조작이 가능)
- 서버만으로 검증하면 즉각적인 고객 피드백이 부족함(사용성 부족)
- 둘을 적절히 섞어서 사용하고 최종적인 서버 검증은 필수
- API 방식을 사용하면 API 스펙을 잘 정의해서 검증 오류를 API 응답 결과에 잘 남겨주어야 함
3) 프로젝트 설정
(1) 프로젝트
- 제공된 소스의 validation-start 디렉토리를 사용하였음
- Gradle
- Java 11
- Spring Boot 2.4.4
(2) Metadata
- Group: hello
- Artifact: validation
- Packaging: Jar
(3) Dependencies
- 스프링 웹
- 타임리프
- 롬복
2. 검증 직접 처리(소개 및 개발)
1) 상품 저장 성공과 실패
(1) 프로세스 소개
- 사용자가 상품 등록 폼에서 정상 범위의 데이터를 입력하면 서버에서는 검증 로직이 통과하고, 상품을 저장하고, 상품 상세 화면으로 redirect 함
- 하지만 검증 오류가 발생되면 서버 검증 로직이 실패해야 하는데, 이렇게 검증에 실패한 경우 다시 상품 등록 폼을 보여주고 어떤 값이 잘못 입력했는지 친절하게 알려주어야 한다
- 즉, 컨트롤러에서 이미 사용자가 입력한 값들을 모델에 데이터를 모두 담아서 다시 전달해야 한다는 뜻
2) 상품 등록 검증 - 직접 개발
(1) ValidationItemControlelrV1 - addItem() 수정
- error가 발생하면 errors에 정보를 담아두고 발생한 필드명을 key로 사용해서 이후 뷰에서 이를 이용해 고객에게 오류 메시지를 출력하기 위해 활용
- 컨트롤러만 수정하면 뷰에서는 아무 변화가 없어 보이지만(@ModelAttribute로 다시 입력폼으로 데이터를 담아서 보냈기 때문) log를 찍어보면 에러 메시지가 계속 출력되는 것을 확인 할 수 있음
@PostMapping("/add")
public String addItem(@ModelAttribute Item item, RedirectAttributes redirectAttributes, Model model) {
// 검증 오류 결과를 보관
Map<String, String> errors = new HashMap<>();
// 검증 로직 - org.springframework
// itemName에 글자가 없으면 에러 메시지를 출력
if (!StringUtils.hasText(item.getItemName())) {
errors.put("itemName", "상품 이름은 필수 입니다");
}
// 가격이 null or 1000원 미만, 100만원 초과면 에러 메시지 출력
if (item.getPrice() == null || item.getPrice() < 1000 || item.getPrice() > 1000000) {
errors.put("price", "가격은 1,000 ~ 1,000,000원 까지 허용합니다");
}
// 수량이 9999개 넘어가면 에러메시지 출력
if (item.getQuantity() == null || item.getQuantity() > 9999 || item.getQuantity() < 1) {
errors.put("quantity", "수량은 1 ~ 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}";
}
(2) addForm.html
- 뷰페이지에 에러메시지를 보여주기 위해 수정
- 오류 메시지는 errors에 내용이 있을 때만 출력하면 되므로 타임리프의 th:if를 사용하여 조건에 만족할 때 해당 HTML 태그를 출력하도록 작성
- css추가: .field-error를 추가하여 해당 부분을 빨간색으로 강조
<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="utf-8">
<link th:href="@{/css/bootstrap.min.css}"
href="../css/bootstrap.min.css" rel="stylesheet">
<style>
.container {
max-width: 560px;
}
.field-error {
border-color: red;
color: red;
}
</style>
</head>
<body>
<div class="py-5 text-center">
<h2 th:text="#{page.addItem}">상품 등록</h2>
</div>
<form action="item.html" th:action th:object="${item}" method="post">
<!-- errors의 Key에 globalError라는 값이 있으면 에러메시지 출력, ?.의 의미는 null일경우 무시-->
<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 테두리도 빨간색으로 하기 -->
<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>
<!-- classappend 사용 했을 때 -> 문법이 조금 더 간단해짐-->
<input type="text" id="price" th:field="*{price}"
th:classappend="${errors?.containsKey('price')} ? 'field-error' : _"
class="form-control" placeholder="가격을 입력하세요">
<div class="field-error" th:if="${errors?.containsKey('price')}" th:text="${errors['price']}">가격 오류</div>
</div>
<div>
<label for="quantity" th:text="#{label.item.quantity}">수량</label>
<input type="text" id="quantity" th:field="*{quantity}"
th:class="${errors?.containsKey('quantity')} ? 'form-control field-error' : 'form-control'"
class="form-control" placeholder="수량을 입력하세요">
<div class="field-error" th:if="${errors?.containsKey('quantity')}" th:text="${errors['quantity']}">수량 오류</div>
</div>
** 참고: Safe Navigation Operator
- 최초 get 요청으로 등록 폼에 진입 시점에는 errors가 없기 때문에(null) errors.containsKey()를 호출하는 순간 NullPointerException이 발생하게 됨
- errors?. 를 활용하면 errors가 null일때 NullPointerException대신 null을 반환하게 되고, th:if 에서 null은 실패로 처리가 되므로 오류 메시지가 출력되지 않게 됨
- SpringEL이 제공하는 문법이므로 자세한 내용은 공식 문서를 참고하면 좋음!
- https://docs.spring.io/spring-framework/reference/core/expressions/language-ref/operator-safe-navigation.html
3) 정리 및 남은 문제점
(1) 정리
- 검증 오류가 발생하명 입력 폼을 다시 보여줌
- 검증 오류들을 고객에게 친절하게 안내해서 다시 입력할 수 있게 함
- 검증 오류가 발생해도 고객이 입력한 데이터가 유지됨
(2) 남은 문제점
- 뷰 템플릿에서 중복 처리가 많고 뭔가 비슷함
- 타입 오류 처리가 안됨, Item의 price, quantity 같은 숫자 필드는 타입이 Integer이므로 문자 타입으로 설정하는 것이 불가능
- 스프링에서 MVC에서 컨트롤러에 진입하기도 전에 예외가 발생하기 때문에 컨트롤러가 호출되지도 않고 400 예외가 발생해버림
- 타입 오류가 발생해도 고객이 입력한 문자를 화면에 남겨야 하는데 컨트롤러가 호출 된다고 해도 애초에 타입이 Integer이므로 문자를 보관할 수 없어 고객이 입력한 문자가 사라지게 됨 -> 결국 어딘가에 별도로 관리가 되어야 함
- 스프링이 제공하는 검증 방법으로 하나씩 해결!
3. 프로젝트 준비 V2
1) 프로젝트 준비 V2
- 앞서 만든 기능을 유지하기 위해 컨트롤러와 템플릿 파일을 복사해서 사용할 예정
(1) ValidationItemControllerV2 컨트롤러 생성
- validationItemControllerV1을 복사 붙혀넣기 해서 V2로 변경
- URL경로를 validation/v1/ -> validation/v2/로 변경
- 인텔리제이의 커맨드 + r 을하면 변경 기능을 활용할 수 있음
(2) 템플릿 파일 복사
- ~/validation/v1 디렉토리의 모든 템플릿을 ~/validation/v2디렉토리로 복사
- 복사된 ~/validation/v2/ 하위 4개 파일의 URL 경로를 validation/v2/로 변경
- 폴더를 눌러서 커맨드 + 쉬프트 + r을 하면 하위경로의 파일의 내용을 모두 바꿀 수 있음
4. BindingResult1
1) 핵심은 BindingResult
(1) ValidationItemControllerV2 - addItemV1 수정
- FieldError: 필드에 오류가 있을 때 사용
- ObjectError: 글로벌 오류에 사용 (FieldError는 ObjectError의 자식)
- BindingResult bindingResult파라미터의 위치는 꼭 @ModelAttribute Item item 다음에 와야하며 그렇지 않으면 동작 안함
- FieldError 생성자
- objectName: @ModelAttribuete 이름
- field: 오류가 발생한 필드 이름
- defaultMessage: 오류 기본 메시지 - ObjectError 생성자도 objectName이 없을 뿐 위와 같음
- bindingResult.hasErrors()로 error()가 있는지 확인
// BindingResult 파라미터의 위치는 @ModelAttribute Item item 다음에 와야함 - 중요! 다른곳에 있으면 작동하지 않음
@PostMapping("/add")
public String addItemV1(@ModelAttribute Item item, BindingResult bindingResult,
RedirectAttributes redirectAttributes) {
// objectName은 ModelAttribute의 item을 입력하면 됨
// 특정 필드 에러는 FieldError
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 || item.getQuantity() < 1) {
bindingResult.addError(
new FieldError("item", "quantity", "수량은 1 ~ 9,999 까지 허용합니다"));
}
// 특정 필드 오류가 아닐 때 (globalError -> ObjectError)
if (item.getPrice() != null && item.getQuantity() != null) {
int resultPrice = item.getPrice() * item.getQuantity();
if (resultPrice < 10000) {
bindingResult.addError(
new ObjectError("item",
"가격 * 수량의 합은 10,000원 이상 이어야 합니다. 현재 값 = " + resultPrice));
}
}
// bindingResult에 에러가 있으면
if (bindingResult.hasErrors()) {
log.info("errors = {}", bindingResult); // 에러 찍어보기
// BindingResult는 addAttribute에 담지 않아도 알아서 담김
return "validation/v2/addForm";
}
// 성공 로직
Item savedItem = itemRepository.save(item);
redirectAttributes.addAttribute("itemId", savedItem.getId());
redirectAttributes.addAttribute("status", true);
return "redirect:/validation/v2/items/{itemId}";
}
(2) v2/addForm.html 수정
- 타임리프는 스프링의 BindingResult를 활용해서 편리하게 검증 오류를 표현하는 기능을 제공함
- #fields: #fields로 BindingResult가 제공하는 검증 오류에 접근할 수 있음
- th:errors: 해당 필드에 오류가 있는 경우에 태그를 출력함 (th:if의 편의 버전)
- th:errorclass: th:field에서 지정한 필드에 오류가 있으면 class 정보를 추가함
- th:each: 해당 문법으로 여러개의 에러메시지가 있으면 한번에 출력할 수 있음
- 검증과 오류 메시지 공식 메뉴얼 참고
- https://www.thymeleaf.org/doc/tutorials/3.0/thymeleafspring.html#validation-and-error-messages
<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>
<div>
<label for="price" th:text="#{label.item.price}">가격</label>
<input type="text" id="price" th:field="*{price}"
th:errorclass="field-error"
class="form-control" placeholder="가격을 입력하세요">
<div class="field-error" th:errors="*{price}">가격 오류</div>
</div>
<div>
<label for="quantity" th:text="#{label.item.quantity}">수량</label>
<input type="text" id="quantity" th:field="*{quantity}"
th:errorclass="field-error"
class="form-control" placeholder="수량을 입력하세요">
<div class="field-error" th:errors="*{quantity}">수량 오류</div>
</div>
5.BindingResult2
- 스프링이 제공하는 검증 오류를 보관하는 객체이며 검증 오류가 발생하면 여기에 보관하면 됨
- BindingResult가 있으면 @ModelAttribute에 데이터 바인딩 시 오류가 발생해도 컨트롤러가 호출 됨
(1) @ModelAttribute에 바인딩 시 타입 오류가 발생하면
- BindingResult가 없으면 -> 400 오류가 발생하면서 컨트롤러가 호출되지 않고 오류페이지로 이동
- BindingResult가 있으면 -> 오류 정보(FieldError)를 BindingResult에 담아서 컨트롤러를 정상 호출 함
(2) BindingResult에 검증 오류를 적용하는 3가지 방법
- @ModelAttribute의 객체에 타입 오류 등으로 바인딩이 실패하면 스프링이 FieldError를 생성해서 BindingResult에 넣어줌
-> 바인딩 자체가 실패 - 개발자가 직접 넣어줌 -> 비즈니스와 관련된 검증 오류
- Validator 사용 -> 뒤에서 설명
(3) 순서
- 위에도 설명 했지만 BindingResult는 검증할 대상 바로 다음에 와야함(순서가 중요)
- @ModelAttribute Item item 바로 다음에 BindingResult가 와야하며, BindingResult는 Model에 자동으로 포함됨
(4) BindingResult와 Errors
- 둘다 인터페이스이고 BindingResult가 Errors를 상속 받고 있음
- 실제 구현체는 BeanPropertyBindingResult이며 둘다 구현하고 있으므로 BindingResult대신 Errors를 사용해도 가능
- 하지만 Errors는 단순한 오류 저장과 조회기능을 제공하고 BindingResult는 추가적인 기능들을 많이 제공함(addError() 등)
- 주로 관례상 BindingResult를 많이 사용함
6. FieldError, ObjectError
1) 문제해결
- BindingResult로 오류 메시지를 처리 했지만 오류가 발생하면 고객이 입력한 내용이 사라지는 문제를 해결해야함
(1) ValidationItemControllerV2 - addItemV2 추가
// 고객이 입력한 내용을 사라지지 않게 하는 v2 버전 -> 값을 보존 해야함
// rejectedValue값 작성: item.getItemName(), false, null, null,
@PostMapping("/add")
public String addItemV2(@ModelAttribute Item item, BindingResult bindingResult,
RedirectAttributes redirectAttributes) {
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 || item.getQuantity() < 1) {
bindingResult.addError(
new FieldError("item", "quantity",
item.getQuantity(), false, null, null,
"수량은 1 ~ 9,999 까지 허용합니다"));
}
// ObjectError는 별도의 필드값이 넘어오는 것이 아니기 때문에 해당사항 없음
// 생성자가 넘어오는 값을 찍어보기 위해 null을 추가해보기
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}";
}
(2) 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)
public ObjectError(String objectName, String field, boolean bindingFailure,
@Nullable String[] codes, @Nullable Object[] arguments, @Nullable String defaultMessage)
- objectName: 오류가 발생한 객체 이름
- field: 오류 필드
- rejectedValue: 사용자가 입력한 값(거절된 값)
- bindingFailure: 타입 오류 같은 바인딩 실패인지, 검증 실패인지 구분 값
- codes: 메시지 코드
- arguments: 메시지에서 사용하는 인자
- defaultMessage: 기본 오류 메시지
(3) 오류 발생 시 사용자 입력값 유지되는 이유
- 타입 오류와 같은 바인딩 되는 시점에 오류가 발생하면 FieldError가 오류 발생 시 사용자 입력 값을 저장하는 기능을 제공함
- rejectedValue에 오류 메시지 담겨진 후 컨트롤러에 호출이 되기 때문에 그대로 값을 사용할 수 있음
(4) 타임리프의 사용자 입력 값 유지
- 타임리프의 th:field가 정상 상황에서는 모델 객체의 값을 사용하지만, 오류가 발생하면 FieldError에서 보관한 값을 사용해서 값을 출력함