검증 요구사항
- 요구사항 - 검증 로직 추가
1. 타입 검증
가격, 수량에 문자가 들어가면 검증 오류 처리
2. 필드 검증
상품명: 필수, 공백X
가격: 1000원 이상, 1백만원 이하
수량: 최대 9999
3. 특정 필드의 범위를 넘어서는 검증
가격 * 수량의 합은 10,000원 이상
- 서버 검증
폼 입력시 오류 발생할 때, 고객 데이터 유지한 채로 어떤 오류가 발생했는지 친절하게 알려줘야 함
컨트롤러 => HTTP 요청이 정상인지 검증해야 함
클라이언트 검증은 조작할 수 있어 보안에 취약
서버만으로 검증하면 즉각적인 고객 사용성이 부족
∴ 클라이언트 & 서버 검증 모두 사용하되, 최종적으로 서버 검증은 필수
∴ API 방식 => API 스펙을 잘 정의해 API 응답 결과에 검증 오류사항을 잘 남겨야 함
검증 직접 처리
- 검증 실패 로직
서버 검증 로직 실패시 => 상품 등록 폼 다시 표시 & 어떤 값을 잘못 입력했는지 친절하게 알려줌
- ValidationItemControllerV1 수정
@PostMapping("/add")
public String addItem(@ModelAttribute Item item, RedirectAttributes redirectAttributes, Model model) { //// model 추가
//// 검증 오류 보관
HashMap<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", "수량은 0 ~ 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); //// 현 메소드 파라미터의 item도 함께 전달됨
return "validation/v1/addForm";
}
// 성공 로직
Item savedItem = itemRepository.save(item);
redirectAttributes.addAttribute("itemId", savedItem.getId());
redirectAttributes.addAttribute("status", true);
return "redirect:/validation/v1/items/{itemId}";
}
- addForm.html
<!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: #dc3545;
color : #dc3545;
}
</style>
</head>
<body>
<div class="container">
<div class="py-5 text-center">
<h2 th:text="#{page.addItem}">상품 등록</h2>
</div>
<form action="item.html" th:action th:object="${item}" method="post">
<div th:if="${errors?.containsKey('globalError')}"> <!-- ?. : NPE 방지, null 그대로 반환 -> 태그 표시 X-->
<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="이름을 입력하세요"> <!--오류 있으면 field-error 클래스도 추가-->
<!--th:classappend="${errors?.containsKey('itemName')} ? 'field-error" : _
오류 있으면 해당 클래스 추가, 없으면 아무것도 하지 않음-->
<div th:if="${errors?.containsKey('itemName')}" class="field-error" th:text="${errors['itemName']}">
상품명 오류
</div>
</div>
<div>
<label for="price" th:text="#{label.item.price}">가격</label>
<input type="text" id="price" th:field="*{price}" class="form-control" placeholder="가격을 입력하세요">
<div th:if="${errors?.containsKey('price')}" class="field-error" th:text="${errors['price']}">
가격 오류
</div>
</div>
<div>
<label for="quantity" th:text="#{label.item.quantity}">수량</label>
<input type="text" id="quantity" th:field="*{quantity}" class="form-control" placeholder="수량을 입력하세요">
<div th:if="${errors?.containsKey('quantity')}" class="field-error" th:text="${errors['quantity']}">
수량 오류
</div>
</div>
<hr class="my-4">
<div class="row">
<div class="col">
<button class="w-100 btn btn-primary btn-lg" type="submit" th:text="#{button.save}">상품 등록</button>
</div>
<div class="col">
<button class="w-100 btn btn-secondary btn-lg"
onclick="location.href='items.html'"
th:onclick="|location.href='@{/validation/v1/items}'|"
type="button" th:text="#{button.cancel}">취소</button>
</div>
</div>
</form>
</div> <!-- /container -->
</body>
</html>
※ Safe Navigation Operator
errors?.containsKey('xxx')
등록 폼 첫 진입 시 errors 객체 존재 X -> NPE 발생함
?. => errors 가 null 일 때 NPE 막고 null 반환
th:if 에서 null은 실패로 처리 -> 오류 메시지 출력 X
- 남은 문제점
1. 뷰 템플릿에 중복 처리할 코드가 많음
2. 타입 오류 처리가 안 됨
ex) 숫자 타입에 문자 들어올 떄, 컨트롤러 진입도 전에 예외 발생되어 400 에러 발생
3. 타입 오류 발생 시 입력한 문자를 화면에 남겨야 함
ex) Item의 price 필드는 Integer이므로 문자 보관 불가
BindingResult
- BindingResult
: 스프링이 제공하는 검증 오류를 보관하는 객체
BindingResult 활용 시 @ModelAttribute 데이터 바인딩 오류 발생해도 컨트롤러가 호출됨
cf) BindingResult 없으면 => 400 오류 발생해 오류 페이지 이동, 컨트롤러 호출 X
BindingResult => Errors 인터페이스( 단순한 오류 저장, 조회 ) 상속받는 인터페이스
- 오류 객체
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)
FieldError(@ModelAttribute 이름, 오류 발생 필드 이름, 오류 메시지)
FieldError(@ModelAttribute 이름, String 오류 필드 이름, @Nullable 입력값, boolean 바인딩실패 구분값, @Nullable 메시지 코드들, @Nullable 메시지에서 사용하는 파라미터들, @Nullable 오류 메시지)
ObjectError(@ModelAttribute 이름, 오류 메시지) => 특정 필드 넘어서는 오류 있을 때
※
rejectedValue : 오류 발생 시 사용자 입력값 저장
bindingFailure : 타입 오류 등 바인딩이 실패했는지 여부 확인
- ValidationItemControllerV2
- addItemV1
@PostMapping("/add")
public String addItemV1(@ModelAttribute Item item,
BindingResult bindingResult, // 순서 중요 (item에 binding된 결과 담음)
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", "수량은 0 ~ 9,999개까지 허용합니다."));
}
// 복합 룰
if (item.getPrice() != null && item.getQuantity() != null){
int resultPrice = item.getPrice() * item.getQuantity();
if (resultPrice < 10000){
//Field 아닌 Object Error
bindingResult.addError(new ObjectError("item", "가격 총합은 10,000원 이상이어야 합니다. 현재 값 = " + resultPrice));
}
}
// 검증 실패 시 다시 입력 폼으로
if (bindingResult.hasErrors()){
log.info("errors = {}", bindingResult);
//model.addAttribute("errors", bindingResult); //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}";
}
- addForm.html 수정
<form action="item.html" th:action th:object="${item}" method="post">
<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}"> <!--itemName에 오류 있으면 출력 X-->
상품명 오류
</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>
<hr class="my-4">
<div class="row">
<div class="col">
<button class="w-100 btn btn-primary btn-lg" type="submit" th:text="#{button.save}">상품 등록</button>
</div>
<div class="col">
<button class="w-100 btn btn-secondary btn-lg"
onclick="location.href='items.html'"
th:onclick="|location.href='@{/validation/v2/items}'|"
type="button" th:text="#{button.cancel}">취소</button>
</div>
</div>
</form>
※ 사용자 입력값 유지
th:field => 오류 발생 시 FieldError에서 보관한 값 사용해 출력
- ValidationItemControllerV2
- addItemV2
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, "수량은 0 ~ 9,999개까지 허용합니다."));
}
// 복합 룰
if (item.getPrice() != null && item.getQuantity() != null){
int resultPrice = item.getPrice() * item.getQuantity();
if (resultPrice < 10000){
//Field 아닌 Object Error
bindingResult.addError(new ObjectError("item", null, null, "가격 총합은 10,000원 이상이어야 합니다. 현재 값 = " + resultPrice));
}
}
생성자 활용
오류 코드와 메시지 처리
※ 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)
- errors.properties 생성
※ erros_en.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}
- ValidationItemControllerV2
@PostMapping("/add")
public String addItemV3(@ModelAttribute Item item, BindingResult bindingResult,
RedirectAttributes redirectAttributes) {
if (!StringUtils.hasText(item.getItemName())) {
bindingResult.addError(new FieldError("item", "itemName",
item.getItemName(), false, new String[]{"required.item.itemName"}, null, null));
}
if (item.getPrice() == null || item.getPrice() < 1000 || item.getPrice() >
1000000) {
bindingResult.addError(new FieldError("item", "price", item.getPrice(),
false, new String[]{"range.item.price"}, new Object[]{1000, 1000000}, null));
}
if (item.getQuantity() == null || item.getQuantity() > 10000) {
bindingResult.addError(new FieldError("item", "quantity",
item.getQuantity(), false, new String[]{"max.item.quantity"}, new Object[]
{9999}, null));
}
//특정 필드 예외가 아닌 전체 예외
if (item.getPrice() != null && item.getQuantity() != null) {
int resultPrice = item.getPrice() * item.getQuantity();
if (resultPrice < 10000) {
bindingResult.addError(new ObjectError("item", new String[]
{"totalPriceMin"}, new Object[]{10000, resultPrice}, null));
}
}
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}";
}
codes : 메시지 코드 지정 ex) new String[] {"range.item.price"}
arguments : 코드의 파라미터에 대입 ex) new Object[]{1000, 100000}
- BindingResult.rejectValue() 사용
컨트롤러에서 BindingResult는 검증해야 하는 객체인 target 바로 다음에 옴
=> BindingResult는 이미 검증할 객체 target을 인식하고 있음
=> FieldError, ObjectError 직접 생성하는 대신 BindingResult가 제공하는 rejectValue(), reject() 사용 가능
void rejectValue(@Nullable String field, String errorCode,
@Nullable Object[] errorArgs, @Nullable String defaultMessage);
// 오류 필드, 에러코드 (messageResolver), {0} 치환 값, 기본 오류 메시지
void reject(String errorCode, @Nullable Object[] errorArgs, @Nullable String defaultMessage);
- ValidationItemControllerV2
@PostMapping("/add")
public String addItemV4(@ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model) {
log.info("objectName={}", bindingResult.getObjectName());
log.info("target={}", bindingResult.getTarget());
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, 10000000}, null); /////
}
if (item.getQuantity() == null || item.getQuantity() >= 9999) {
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); /////
}
}
//검증에 실패하면 다시 입력 폼으로
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}";
}
- MessageCodesResolver 기반 우선순위 활용
#Level1
required.item.itemName: 상품 이름은 필수 입니다.
#Level2
required: 필수 값 입니다
상세히 입력된 코드일수록 우선순위높음
크게 중요하지 않으면 required 같은 범용성 높은 메시지 사용,
중요한 메시지는 구체적으로 적어 사용하는 방식이 효과적
- 필드 오류
1.: code + "." + object name + "." + field
2.: code + "." + field
3.: code + "." + field type
4.: code
4개 메시지코드 생성 (생성 순서대로 보관)
- 객체 오류
1.: code + "." + object name
2.: code
2개 메시지 코드 생성
- error.properties
#required.item.itemName=상품 이름은 필수입니다.
#range.item.price=가격은 {0} ~ {1} 까지 허용합니다.
#max.item.quantity=수량은 최대 {0} 까지 허용합니다.
#totalPriceMin=가격 * 수량의 합은 {0}원 이상이어야 합니다. 현재 값 = {1}
#==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} 까지 허용합니다.
- ValidationUtils
검증 로직은 ValidationUtils 클래스 활용
if (!StringUtils.hasText(item.getItemName())) {
bindingResult.rejectValue("itemName", "required", "기본: 상품 이름은 필수입니다.");
}
->
ValidationUtils.rejectIfEmptyOrWhitespace(bindingResult, "itemName", "required");
- 스프링이 직접 만든 오류 메시지 처리
1. 개발자가 직접 설정한 오류 코드 => rejectValue() 를 직접 호출
2. 스프링이 직접 검증 오류에 추가한 경우 => 주로 타입 정보가 맞지 않을 때 (typeMismatch)
ex. price 필드에 문자 입력 시 =>
typeMismatch.item.price
typeMismatch.price
typeMismatch.java.lang.Integer
typeMismatch
- error.properties
#추가
typeMismatch.java.lang.Integer=숫자를 입력해주세요.
typeMismatch=타입 오류입니다.
Validator 분리
복작한 검증 로직은 별도 클래스에 분리
- ItemValidator
@Component
public class ItemValidator implements Validator { ////
@Override
public boolean supports(Class<?> clazz) {
return Item.class.isAssignableFrom(clazz);
}
@Override
public void validate(Object target, Errors errors) {
Item item = (Item) target; ////
if (!StringUtils.hasText(item.getItemName())) {
errors.rejectValue("itemName", "required");
}
if (item.getPrice() == null || item.getPrice() < 1000 || item.getPrice() > 1000000) {
errors.rejectValue("price", "range", new Object[]{1000, 10000000}, null);
}
if (item.getQuantity() == null || item.getQuantity() >= 9999) {
errors.rejectValue("quantity", "max", new Object[]{9999}, null);
}
//특정 필드가 아닌 복합 룰 검증
if (item.getPrice() != null && item.getQuantity() != null) {
int resultPrice = item.getPrice() * item.getQuantity();
if (resultPrice < 10000) {
errors.reject("totalPriceMin", new Object[]{10000, resultPrice}, null);
}
}
}
}
- ValidationItemControllerV2
private final ItemValidator itemValidator; ////
@PostMapping("/add")
public String addItemV5(@ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model) {
itemValidator.validate(item, bindingResult); ////
//검증에 실패하면 다시 입력 폼으로
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}";
}
- 스프링 기능 추가 활용 (WebDataBinder)
- ValidationItemControllerV2
@InitBinder
public void init(WebDataBinder dataBinder) { // 현 컨트롤러에 databinder 추가
dataBinder.addValidators(itemValidator); // databinder 에 검증기 추가
}
@PostMapping("/add")
// validator 호출 코드 작성하는 대신 어노테이션 통해 검증기 실행 (@Validated)
public String addItemV6(@Validated @ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model) { /////
//검증에 실패하면 다시 입력 폼으로
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}";
}
※ 모든 컨트롤러에 적용 (글로벌)
@SpringBootApplication
public class ItemServiceApplication implements WebMvcConfigurer {
public static void main(String[] args) {
SpringApplication.run(ItemServiceApplication.class, args);
}
@Override
public Validator getValidator() { ////
return new ItemValidator(); ////
}
}
@Validated : 스프링 전용 검증 어노테이션
@Valid : 자바 표준 검증 어노테이션 -> build.gradle 의존관계 추가 필요
'Programming > 스프링' 카테고리의 다른 글
[김영한 스프링 MVC 2] 로그인 처리1 - 쿠키, 세션 (0) | 2024.01.19 |
---|---|
[김영한 스프링 MVC 2] 검증2 - Bean Validation (0) | 2024.01.16 |
[김영한 스프링 MVC 2] 타임리프 - 스프링 통합과 폼 (0) | 2023.12.02 |
[김영한 스프링 MVC 2] 타임리프 - 기본 기능 (0) | 2023.11.26 |
[김영한 스프링 MVC 1] 스프링 MVC - 웹 페이지 만들기 (0) | 2023.11.19 |