일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
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 |
- 요약
- MSSQL
- 앵커멤버
- dm_exec_requests
- 교육법
- 아이
- 스프링
- getChannel()
- 진경혜
- java
- 디스패처서블릿
- spring
- transferTo
- TRANCOUNT
- SQLSTATE=42705
- 튜닝
- 자바
- ERROR_MESSAGE
- 함수
- 배치
- 프론트컨트롤러
- XWPF
- SQL
- HWPF
- 재귀멤버
- XACT_STATE
- renameTo
- 요청매핑
- 홈스쿨링
- git
- Today
- Total
필기노트
김영한 스프링 강의 요약 - 검증(유효성 검사) 본문
목차
1. 검증 요구사항
2. Bean Validation
3. @Validated
4. Form 전송 객체 분리
5. Bean Validation - 에러 메시지
6. Bean Validation - 오브젝트 오류
7. BindingResult
8. 오류 코드와 메시지 처리
1. 검증 요구사항
클라이언트 검증은 조작할 수 있으므로 보안에 취약하다. 서버만으로 검증하면, 즉각적인 고객 사용성이 부족해진다. 둘을 적절히 섞어서 사용하되, 최종적으로 서버 검증은 필수이다. API 방식을 사용하면 API 스펙을 잘 정의해서 검증 오류를 API 응답 결과에 잘 남겨주어야 한다.
고객이 상품 등록 폼에서 상품명을 입력하지 않거나, 가격, 수량 등이 너무 작거나 커서 검증 범위를 넘어서면, 서버 검증 로직이 실패해야 한다. 이렇게 검증 오류가 발생하면 ①고객에게 다시 상품 등록 폼을 다시 보여주고, ②검증 오류들을 고객에게 친절하게 안내해서 다시 입력할 수 있게 하고 ③검증 오류가 발생해도 고객이 입력한 데이터가 유지되게 해야한다.
2. Bean Validation
1) Bean Validation을 잘 활용하면, 애노테이션 하나로 검증 로직을 매우 편리하게 적용할 수 있다.
2) Bean Validation을 사용하려면 다음 의존관계를 추가해야 한다.
#build.gradle
implementation 'org.springframework.boot:spring-boot-starter-validation'
@PostMapping("/add")
public String addItem(@Validated @ModelAttribute("item") ItemSaveForm form, BindingResult bindingResult, RedirectAttributes redirectAttributes) {
//특정 필드 예외가 아닌 전체 예외
if (form.getPrice() != null && form.getQuantity() != null) {
int resultPrice = form.getPrice() * form.getQuantity();
if (resultPrice < 10000) {
bindingResult.reject("totalPriceMin", new Object[]{10000, resultPrice}, null);
}
}
if (bindingResult.hasErrors()) {
log.info("errors={}", bindingResult);
return "validation/v4/addForm";
}
//성공 로직
Item item = new Item();
item.setItemName(form.getItemName());
item.setPrice(form.getPrice());
item.setQuantity(form.getQuantity());
Item savedItem = itemRepository.save(item);
redirectAttributes.addAttribute("itemId", savedItem.getId());
redirectAttributes.addAttribute("status", true);
return "redirect:/validation/v4/items/{itemId}";
}
3. @Validated
@Validated는 검증기를 실행하라는 애노테이션이다. spring-boot-starter-validation 라이브러리를 넣으면 스프링 부트가 Bean Validator를 인지하고 자동으로 글로벌 Validator로 등록하기 때문에 Validator는 @NotNull 같은 애노테이션을 보고 검증을 수행한다.
4. @ModelAttribute("item") ItemSaveForm form
1) 복잡한 폼의 데이터를 컨트롤러까지 전달할 별도의 객체를 만들어서 전달한다. 예를 들면 ItemSaveForm 이라는 별도의 객체를 만들어서 @ModelAttribute 로 사용하면 이것을 통해 컨트롤러에서 폼 데이터를 전달 받을 수 있다. 이후 폼 데이터를 기반으로 컨트롤러에서 Item 객체를 생성하는 과정이 추가된다.
(소위 "Hello World" 예제에서는 폼에서 전달하는 데이터와 Item 도메인 객체가 딱 맞는다. 하지만 실무에서는 회원 등록시 회원과 관련된 데이터만 전달받는 것이 아니라, 약관 정보도 추가로 받는 등 Item 과 관계없는 수 많은 부가 데이터가 넘어온다. 또한, 수정의 경우 등록과 수정은 완전히 다른 데이터가 넘어온다. 생각해보면 회원 가입시 다루는 데이터와 수정시 다루는 데이터는 범위에 차이가 있다. 예를 들면 등록시에는 로그인id, 주민번호 등등을 받을 수 있지만, 수정시에는 이런 부분이 빠진다. 그리고 검증 로직도 많이 달라진다.)
2) @ModelAttribute("item") 에 item 이름을 넣어준 부분을 주의하자. 이것을 넣지 않으면 ItemSaveForm 의 경우 규칙에 의해 itemSaveForm 이라는 이름으로 MVC Model에 담기게 된다. 이렇게 되면 뷰 템플릿에서 접근하는 th:object 이름도 함께 변경해주어야 한다.
3) Item 의 검증은 사용하지 않으므로 검증 코드가 없어도 된다.
@Data
public class Item {
private Long id;
private String itemName;
private Integer price;
private Integer quantity;
}
4) ItemSaveForm - ITEM 저장용 폼
@Data
public class ItemSaveForm {
@NotBlank
private String itemName;
@NotNull
@Range(min = 1000, max = 1000000)
private Integer price;
@NotNull
@Max(value = 9999)
private Integer quantity;
}
5) 검증 애노테이션
@NotBlank : 빈값 + 공백만 있는 경우를 허용하지 않는다.
@NotNull : null 을 허용하지 않는다.
@Range(min = 1000, max = 1000000) : 범위 안의 값이어야 한다.
@Max(9999) : 최대 9999까지만 허용한다.
5. BindingResult bindingResult
BindingResult 는 검증할 대상 바로 다음에 와야한다. 순서가 중요하다. 예를 들어서 @ModelAttribute Item item, 바로 다음에 BindingResult bindingResult 파라미터 가 와야 한다. 스프링이 제공하는 검증 오류를 보관하는 객체이다. 검증 오류가 발생하면 여기에 보관하면 된다. 또한 BindingResult 는 Model에 자동으로 포함된다.
1) 스프링의 바인딩 오류 처리
타입 오류로 바인딩에 실패하면 스프링은 FieldError 를 생성하면서 사용자가 입력한 값을 넣어둔다. 그리고 해당 오류를 BindingResult 에 담아서 컨트롤러를 호출한다. 따라서 타입 오류 같은 바인딩 실패시에도 사용자의 오류 메시지를 정상 출력할 수 있다.
2) 타임리프의 사용자 입력 값 유지
th:field="*{price}"
타임리프의 th:field 는 매우 똑똑하게 동작하는데, 정상 상황에는 모델 객체의 값을 사용하지만, 오류가 발생하면 FieldError 에서 보관한 값을 사용해서 값을 출력한다.
3) 타임리프 스프링 검증 오류 통합 기능
타임리프는 스프링의 BindingResult 를 활용해서 편리하게 검증 오류를 표현하는 기능을 제공한다.
#fields : #fields 로 BindingResult 가 제공하는 검증 오류에 접근할 수 있다.
th:errors : 해당 필드에 오류가 있는 경우에 태그를 출력한다. th:if 의 편의 버전이다. 그리고 생성된 오류 메시지 코드를 순서대로 돌아가면서 메시지를 찾는다. 그리고 없으면 디폴트 메시지를 출력한다.
th:errorclass : th:field 에서 지정한 필드에 오류가 있으면 class 정보를 추가한다.
필드 오류 처리
<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 th:if="${#fields.hasGlobalErrors()}">
<p class="field-error" th:each="err : ${#fields.globalErrors()}" th:text="${err}">전체 오류 메시지</p>
</div>
6. Bean Validation - 에러 코드
Bean Validation이 기본으로 제공하는 오류 메시지를 좀 더 자세히 변경하고 싶으면 어떻게 하면 될까? bindingResult 에 등록된 검증 오류 코드를 보자. 오류 코드가 애노테이션 이름으로 등록된다. MessageCodesResolver 를 통해 다양한 메시지 코드가 순서대로 생성된다.
@NotBlank
NotBlank.item.itemName
NotBlank.itemName
NotBlank.java.lang.String
NotBlank
@Range
Range.item.price
Range.price
Range.java.lang.Integer
Range
이제 Bean Validation 메시지를 추가해보자.
#errors.properties
NotBlank={0} 공백X
Range={0}, {2} ~ {1} 허용
Max={0}, 최대 {1}
{0} 은 필드명이고, {1} , {2} ...은 각 애노테이션 마다 다르다.
BeanValidation 메시지 찾는 순서
1. 생성된 메시지 코드 순서대로 messageSource 에서 메시지 찾기
2. 애노테이션의 message 속성 사용 -> @NotBlank(message = "공백은 입력할 수 없습니다.")
3. 라이브러리가 제공하는 기본 값 사용 -> 공백일 수 없습니다.
7. Bean Validation - 오브젝트 오류
Bean Validation에서 특정 필드( FieldError )가 아닌 해당 오브젝트 관련 오류( ObjectError )는 오브젝트 오류 관련 부분만 직접 자바 코드로 작성하는 것을 권장한다.
//특정 필드 예외가 아닌 전체 예외
if (form.getPrice() != null && form.getQuantity() != null) {
int resultPrice = form.getPrice() * form.getQuantity();
if (resultPrice < 10000) {
bindingResult.reject("totalPriceMin", new Object[]{10000, resultPrice}, null);
}
}
8. 오류 코드와 메시지 처리
errors 메시지 파일 생성
messages.properties 를 사용해도 되지만, 오류 메시지를 구분하기 쉽게 errors.properties 라는 별도의 파일로 관리해보자.
먼저 스프링 부트가 해당 메시지 파일을 인식할 수 있게 다음 설정을 추가한다. 이렇게하면 messages.properties , errors.properties 두 파일을 모두 인식한다. (생략하면 messages.properties 를 기본으로 인식한다.)
스프링 부트 메시지 설정 추가
#application.properties
spring.messages.basename=messages,errors
errors.properties 추가
required.item.itemName=상품 이름은 필수입니다.
range.item.price=가격은 {0} ~ {1} 까지 허용합니다.
max.item.quantity=수량은 최대 {0} 까지 허용합니다.
totalPriceMin=가격 * 수량의 합은 {0}원 이상이어야 합니다. 현재 값 = {1}
(참고: errors_en.properties 파일을 생성하면 오류 메시지도 국제화 처리를 할 수 있다.)
이제 errors 에 등록한 메시지를 사용하도록 코드를 변경해보자.
1) 개발자가 직접 설정한 오류 코드 rejectValue() 를 직접 호출
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.getQuantity() == null || item.getQuantity() > 10000) {
bindingResult.rejectValue("quantity", "max", new Object[]{9999}, 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);
}
}
오류 메시지가 정상 출력된다. 그런데 errors.properties 에 있는 코드를 직접 입력하지 않았는데 어떻게 된 것일까?
rejectValue() , reject()
void rejectValue(@Nullable String field, String errorCode,
@Nullable Object[] errorArgs, @Nullable String defaultMessage);
field : 오류 필드명
errorCode : 오류 코드(이 오류 코드는 메시지에 등록된 코드가 아니다. 뒤에서 설명할 messageResolver를 위한 오류 코드이다.)
errorArgs : 오류 메시지에서 {0} 을 치환하기 위한 값
defaultMessage : 오류 메시지를 찾을 수 없을 때 사용하는 기본 메시지
bindingResult.rejectValue("price", "range", new Object[]{1000, 1000000}, null)
앞에서 BindingResult 는 어떤 객체를 대상으로 검증하는지 target을 이미 알고 있다고 했다. 따라서 target(item)에 대한 정보는 없어도 된다. 오류 필드명은 동일하게 price 를 사용했다.
동작방식
rejectValue() , reject() 는 내부에서 MessageCodesResolver 를 사용해서 메시지 코드들을 생성하고 생성된 순서대로 오류 코드를 보관한다.
이 부분을 BindingResult 의 로그를 통해서 확인해보자.
codes [range.item.price, range.price, range.java.lang.Integer, range]
FieldError rejectValue("itemName", "required")
다음 4가지 오류 코드를 자동으로 생성
required.item.itemName
required.itemName
required.java.lang.String
required
ObjectError reject("totalPriceMin")
다음 2가지 오류 코드를 자동으로 생성
totalPriceMin.item
totalPriceMin
오류 코드를 만들 때 다음과 같이 자세히 만들 수도 있고, required.item.itemName : 상품 이름은 필수 입니다. range.item.price : 상품의 가격 범위 오류 입니다. 또는 다음과 같이 단순하게 만들 수도 있다. required : 필수 값 입니다. range : 범위 오류 입니다. 단순하게 만들면 범용성이 좋아서 여러곳에서 사용할 수 있지만, 메시지를 세밀하게 작성하기 어렵다. 반대로 너무 자세하게 만들면 범용성이 떨어진다. 가장 좋은 방법은 범용성으로 사용하다가, 세밀하게 작성해야 하는 경우에는 세밀한 내용이 적용되도록 메시지에 단계를 두는 방법이다.
예를 들어서 required 라고 오류 코드를 사용한다고 가정해보자. 다음과 같이 required 라는 메시지만 있으면 이 메시지를 선택해서 사용하는 것이다.
required: 필수 값 입니다.
그런데 오류 메시지에 required.item.itemName 와 같이 객체명과 필드명을 조합한 세밀한 메시지 코드가 있으면 이 메시지를 높은 우선순위로 사용하는 것이다.
#Level1
required.item.itemName: 상품 이름은 필수 입니다.
#Level2
required: 필수 값 입니다.
우선 다음처럼 만들어보자.
errors.properties
#==ObjectError==
#Level1
totalPriceMin.item=상품의 가격 * 수량의 합은 {0}원 이상이어야 합니다. 현재 값 = {1}
#Level2 - 생략
totalPriceMin=전체 가격은 {0}원 이상이어야 합니다. 현재 값 = {1}
#==FieldError==
#Level1
required.item.itemName=상품 이름은 필수입니다.
range.item.price=가격은 {0} ~ {1} 까지 허용합니다.
max.item.quantity=수량은 최대 {0} 까지 허용합니다.
#Level2 - 생략
#Level3
required.java.lang.String = 필수 문자입니다.
required.java.lang.Integer = 필수 숫자입니다.
min.java.lang.String = {0} 이상의 문자를 입력해주세요.
min.java.lang.Integer = {0} 이상의 숫자를 입력해주세요.
range.java.lang.String = {0} ~ {1} 까지의 문자를 입력해주세요.
range.java.lang.Integer = {0} ~ {1} 까지의 숫자를 입력해주세요.
max.java.lang.String = {0} 까지의 문자를 허용합니다.
max.java.lang.Integer = {0} 까지의 숫자를 허용합니다.
#Level4
required = 필수 값 입니다.
min= {0} 이상이어야 합니다.
range= {0} ~ {1} 범위를 허용합니다.
max= {0} 까지 허용합니다.
크게 객체 오류와 필드 오류를 나누었다. 그리고 범용성에 따라 레벨을 나누어두었다.
itemName 의 경우 required 검증 오류 메시지가 발생하면 다음 코드 순서대로 메시지가 생성된다.
1. required.item.itemName
2. required.itemName
3. required.java.lang.String
4. required
그리고 이렇게 생성된 메시지 코드를 기반으로 순서대로 MessageSource 에서 메시지에서 찾는다.
정리
1. rejectValue() 호출
2. MessageCodesResolver 를 사용해서 검증 오류 코드로 메시지 코드들을 생성
3. new FieldError() 를 생성하면서 메시지 코드들을 보관
4. th:erros 에서 메시지 코드들로 메시지를 순서대로 메시지에서 찾고, 노출
2) 스프링이 직접 검증 오류에 추가한 경우(주로 타입 정보가 맞지 않음)
price 필드에 문자 "A"를 입력해보자.
로그를 확인해보면 BindingResult 에 FieldError 가 담겨있고, 다음과 같은 메시지 코드들이 생성된 것을 확인할 수 있다.
codes[typeMismatch.item.price,typeMismatch.price,typeMismatch.java.lang.Integer,typeMismatch]
다음과 같이 4가지 메시지 코드가 입력되어 있다.
typeMismatch.item.price
typeMismatch.price
typeMismatch.java.lang.Integer
typeMismatch
그렇다. 스프링은 타입 오류가 발생하면 typeMismatch 라는 오류 코드를 사용한다. 이 오류 코드가 MessageCodesResolver 를 통하면서 4가지 메시지 코드가 생성된 것이다.
실행해보자.
아직 errors.properties 에 메시지 코드가 없기 때문에 스프링이 생성한 기본 메시지가 출력된다.
Failed to convert property value of type java.lang.String to required type
java.lang.Integer for property price; nested exception is
java.lang.NumberFormatException: For input string: "A"
error.properties 에 다음 내용을 추가하자
#추가
typeMismatch.java.lang.Integer=숫자를 입력해주세요.
typeMismatch=타입 오류입니다.
다시 실행하면 결과적으로 소스코드를 하나도 건들지 않고, 원하는 메시지를 단계별로 설정할 수 있다.
REFERENCE
'김영한 강의 요약' 카테고리의 다른 글
김영한 스프링 강의 요약 - 필터, 인터셉터 (0) | 2023.12.20 |
---|---|
김영한 스프링 강의 요약 - 로그인(세션, 쿠키) (0) | 2023.12.15 |
스프링 메시지, 국제화 (0) | 2023.11.17 |
타임리프 스프링 통합과 폼 (0) | 2023.11.03 |
타임리프 기본 기능 (0) | 2023.10.23 |