1. Bean Validation
- 특정 구현체 X, 기술 표준 (검증 어노테이션 + 여러 인터페이스)
- 어노테이션으로 검증 로직을 모든 프로젝트에 적용할 수 있도록 공통화, 표준화한 것
- 검증 어노테이션 모음 : https://docs.jboss.org/hibernate/validator/6.2/reference/en-US/html_single/#validator-defineconstraints-spec
public class Item {
private Long id;
@NotBlank
private String itemName;
@NotNull
@Range(min = 1000, max = 1000000)
private Integer price;
@NotNull
@Max(9999)
private Integer quantity;
//...
}
2. Bean Validation - 스프링 통합X, 직접 사용하는 방법
1) Bean Validation 의존관계 추가
* build.gradle
implementation 'org.springframework.boot:spring-boot-starter-validation'
2) Bean Validation 어노테이션 적용
* Item
package hello.itemservice.domain.item;
import lombok.Data;
import org.hibernate.validator.constraints.Range;
import javax.validation.constraints.Max;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
@Data
public class Item {
private Long id;
@NotBlank
private String itemName;
@NotNull
@Range(min = 1000, max = 1000000)
private Integer price;
@NotNull
@Max(9999)
private Integer quantity;
public Item() {
}
public Item(String itemName, Integer price, Integer quantity) {
this.itemName = itemName;
this.price = price;
this.quantity = quantity;
}
}
(1) 검증 어노테이션
- @NotBlank : 빈값 + 공백만 있는 경우를 허용 X
- @NotNull : null 허용 X
- @Range(min = 1000, max = 1000000) : 범위 안의 값
- @Max(9999) : 최대 9999
(2) Jakarta Bean Validation
- jakarta.validation-api : Bean Validation 인터페이스 (특정 구현에 관계없이 제공되는 표준 인터페이스)
- hibernate-validator : 구현체 (하이버네이트 validator 구현체를 사용할 때만 제공되는 검증 기능 - 근데 대부분 하이버네이트 validator 사용)
3) 테스트 코드 작성
* Bean ValidationTest
package hello.itemservice.validation;
import hello.itemservice.domain.item.Item;
import org.junit.jupiter.api.Test;
import javax.validation.ConstraintViolation;
import javax.validation.Validation;
import javax.validation.Validator;
import javax.validation.ValidatorFactory;
import java.util.Set;
public class BeanValidationTest {
@Test
void beanValidation() {
ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
Validator validator = factory.getValidator();
Item item = new Item();
item.setItemName(" "); // 공백
item.setPrice(0);
item.setQuantity(10000);
Set<ConstraintViolation<Item>> violations = validator.validate(item);
for (ConstraintViolation<Item> violation : violations) {
System.out.println("violation = " + violation);
System.out.println("violation = " + violation.getMessage());
}
}
}
(1) 검증기 생성
스프링과 통합하면 직접 코드 작성 X -> 참고만 하기
ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
Validator validator = factory.getValidator();
(2) 검증 실행
검증 대상(item)을 직접 검증기에 넣고 Set으로 그 결과 받음
Set에는 ConstraintViolation이라는 검증 오류가 담김
Set<ConstraintViolation<Item>> violations = validator.validate(item);
(3) 실행 결과
검증 오류가 발생한 객체, 필드, 메시지 정보 등을 확인
--> Bean Validation을 직접 사용하는 방법
3. Bean Validation - 스프링 적용
1) 라이브러리 추가
* build.gradle
implementation 'org.springframework.boot:spring-boot-starter-validation'
2) 어노테이션 적용
* item
package hello.itemservice.domain.item;
import lombok.Data;
import org.hibernate.validator.constraints.Range;
import javax.validation.constraints.Max;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
@Data
public class Item {
private Long id;
@NotBlank
private String itemName;
@NotNull
@Range(min = 1000, max = 1000000)
private Integer price;
@NotNull
@Max(9999)
private Integer quantity;
public Item() {
}
public Item(String itemName, Integer price, Integer quantity) {
this.itemName = itemName;
this.price = price;
this.quantity = quantity;
}
}
3) 컨트롤러
* Controller
- ItemValidator 제거
package hello.itemservice.web.validation;
import hello.itemservice.domain.item.Item;
import hello.itemservice.domain.item.ItemRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
import java.util.List;
@Slf4j
@Controller
@RequestMapping("/validation/v3/items")
@RequiredArgsConstructor
public class ValidationItemControllerV3 {
private final ItemRepository itemRepository;
...
@PostMapping("/add")
public String addItem(@Validated @ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model) {
// 검증에 실패하면 다시 입력 폼으로 (에러가 있으면)
if (bindingResult.hasErrors()) {
log.info("errors = {}", bindingResult);
return "validation/v3/addForm";
}
// 성공 로직
Item savedItem = itemRepository.save(item);
redirectAttributes.addAttribute("itemId", savedItem.getId());
redirectAttributes.addAttribute("status", true);
return "redirect:/validation/v3/items/{itemId}";
}
...
}
4) 동작 방식
- 스프링 부트 라이브러리 추가
- LocalValidatorFactoryBean을 글로벌 Validator로 등록 (스프링 부트에서 사용되는 Bean Validator 생성하는 객체)
- LocalValidatorFactoryBean이 @NotNull같은 어노테이션을 보고 검증 수행
- 글로벌 Validator이기 때문에 @Valid, @Validated만 적용하면 됨
- 검증 오류 발생 시 FieldError, ObjectError 생성 -> BindingResult에 담아줌
@Validated : 스프링 전용 검증 어노테이션 (groups 기능 포함)
@Valid : 자바 표준 검증 어노테이션
5) 검증 순서
- @ModelAttribute 각각의 필드에 타입 변환 시도
- 성공하면 다음으로
- 실패하면 typeMismatch -> FieldError 추가
- 타입 변환(바인딩) 성공한 필드에만 Bean Validation 적용
예)
- itemName(String 타입) -> 문자 "A" 입력 -> 타입 변환 성공 -> itemName 필드에 BeanValidation 적용
- price(Integer 타입) -> 문자 "A" 입력 -> 타입 변환 실패 -> typeMismatch FieldError 추가 -> price 필드는 BeanValidation 적용 X
4. Bean Validation - 에러 코드
1) Bean Validation이 기본으로 제공하는 오류 메시지
2) BindingResult에 등록된 검증 오류 코드 확인
- 상품명 검증 실패 시
콘솔 메시지의 codes
Field error in object 'item' on field 'itemName': rejected value []; codes [NotBlank.item.itemName,NotBlank.itemName,NotBlank.java.lang.String,NotBlank]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [item.itemName,itemName]; arguments []; default message [itemName]]; default message [공백일 수 없습니다]
-> 오류 코드가 어노테이션 이름으로 등록됨 (@NotBlank)
-> 오류 코드를 기반으로 MessageCodesResolver를 통해 메시지 코드 순서대로 생성
-> 에러 메시지 등록하면 됨
- @NotBlack
- NotBlank.item.itemName
- NotBlank.itemName
- NotBlank.java.lang.String
- NotBlank
- @Range
- Range.item.price
- Range.price
- Range.java.lang.Integer
- Range
3) 메시지 등록
- {0} -> 필드명
{1}, {2} -> 각 어노테이션마다 다름
#Bean Validation 추가
NotBlank={0} 공백X
Range={0}, {2} ~ {1} 허용
Max={0}, 최대 {1}
4) BeanValidation 메시지 찾는 순서
- 생성된 메시지 코드 순서대로 messageSource에서 메시지 찾기
- 어노테이션의 message 속성 -> @NotBlank(message = "공백은 입력할 수 없습니다.")
- 라이브러리가 제공하는 기본값 -> 공백일 수 없습니다.
5. Bean Validation - 오브젝트 오류
특정 필드(FieldError)가 아닌 해당 오브젝트 관련 오류(ObjectError) 해결 방법
1) @ScrpitAssert() - 비추
제약 많고 복잡함
@Data
@ScriptAssert(lang = "javascript", script = "_this.price * _this.quantity >= 10000", message = "총합이 10000원 넘게 입력해주세요.")
public class Item {
...
}
2) 컨트롤러에 글로벌 오류 추가
* Controller
특정 필드가 아닌 복합 룰 검증 (글로벌 오류) 추가
@PostMapping("/add")
public String addItem(@Validated @ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model) {
// 특정 필드가 아닌 복합 룰 검증 추가
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/v3/addForm";
}
// 성공 로직
Item savedItem = itemRepository.save(item);
redirectAttributes.addAttribute("itemId", savedItem.getId());
redirectAttributes.addAttribute("status", true);
return "redirect:/validation/v3/items/{itemId}";
}
📌 정리
필드 오류 : 어노테이션
오브젝트 오류 : 컨트롤러에 따로 검증 추가
6. Bean Validation - 상품 수정에도 적용
1) 컨트롤러
- ModelAttribute 앞에 @Validated 추가
- BindingResult 추가
- 검증 로직 추가
- 검증 오류 발생 시 editForm 으로 이동하는 경로로 수정
@PostMapping("/{itemId}/edit")
public String edit(@PathVariable Long itemId, @Validated @ModelAttribute Item item, BindingResult bindingResult) {
// 특정 필드가 아닌 복합 룰 검증
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/v3/editForm";
}
itemRepository.update(itemId, item);
return "redirect:/validation/v3/items/{itemId}";
}
2) editForm.html
- .field-error css 추가
- 글로벌 오류 메시지 추가
- 각 필드에 검증 기능, 오류 메시지 추가
<!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.updateItem}">상품 수정</h2>
</div>
<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="id" th:text="#{label.item.id}">상품 ID</label>
<input type="text" id="id" th:field="*{id}" class="form-control" readonly>
</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">
<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">
<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">
<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='item.html'"
th:onclick="|location.href='@{/validation/v3/items/{itemId}(itemId=${item.id})}'|"
type="button" th:text="#{button.cancel}">취소</button>
</div>
</div>
</form>
</div> <!-- /container -->
</body>
</html>
7. Bean Validation - 한계
등록할 때의 요구사항 ≠ 수정할 때의 요구사항 일 때
* 등록시 기존 요구사항
- 타입 검증
- 가격, 수량에 문자가 들어가면 검증 오류 처리
- 필드 검증
- 상품명: 필수, 공백X
- 가격: 1000원 이상, 1백만원 이하
- 수량: 최대 9999
- 특정 필드의 범위를 넘어서는 검증
- 가격 * 수량의 합은 10,000원 이상
* 수정시 요구사항
- 수량 : 무제한으로 변경
- id 값 : 필수
* Item 객체에서 검증 어노테이션을 적용하면
- id : @NotNull 추가
- quantity : @Max(9999) 제거
@Data
public class Item {
@NotNull //수정 요구사항 추가
private Long id;
@NotBlank
private String itemName;
@NotNull
@Range(min = 1000, max = 1000000)
private Integer price;
@NotNull
//@Max(9999) //수정 요구사항 추가
private Integer quantity;
}
-> 수정은 적용되지만 등록에서 문제 발생
-> 등록과 수정은 같은 BeanValidation 적용 X
8. Bean Validation - 등록, 수정 별도 검증
등록할 때와 수정할 때 각각 다르게 검증하는 방법 2가지
- BeanValidation의 groups 기능
- Item 객체를 직접 사용하지 않고, ItemSaveForm, ItemUpdateForm으로 별도의 모델 객체를 만들어서 사용
8-1. groups 기능
1) groups 인터페이스 생성
* 저장용 groups
package hello.itemservice.domain.item;
public interface SaveCheck {
}
* 수정용 groups
package hello.itemservice.domain.item;
public interface UpdateCheck {
}
2) Item 객체에 groups 적용
package hello.itemservice.domain.item;
import lombok.Data;
import org.hibernate.validator.constraints.Range;
import org.hibernate.validator.constraints.ScriptAssert;
import javax.validation.constraints.Max;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
@Data
public class Item {
@NotNull(groups = UpdateCheck.class) // 수정 요구사항
private Long id;
@NotBlank(groups = {SaveCheck.class, UpdateCheck.class}) // 둘다 적용
private String itemName;
@NotNull(groups = {SaveCheck.class, UpdateCheck.class})
@Range(min = 1000, max = 1000000, groups = {SaveCheck.class, UpdateCheck.class})
private Integer price;
@NotNull(groups = {SaveCheck.class, UpdateCheck.class})
@Max(value = 9999, groups = SaveCheck.class)
private Integer quantity;
public Item() {
}
public Item(String itemName, Integer price, Integer quantity) {
this.itemName = itemName;
this.price = price;
this.quantity = quantity;
}
}
3) 컨트롤러에 적용
* 저장 로직에 SaveCheck Groups 적용
- 등록시에는 SaveCheck 그룹의 요구사항만 적용
@PostMapping("/add")
public String addItem2(@Validated(SaveCheck.class) @ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes) {
}
* 수정 로직에 UpdateCheck Groups 적용
- 수정시에는 UpdateCheck 그룹의 요구사항만 적용
@PostMapping("/{itemId}/edit")
public String edit2(@PathVariable Long itemId, @Validated(UpdateCheck.class) @ModelAttribute Item item, BindingResult bindingResult) {
}
-> 복잡함
8-2. Form 전송 객체 분리
등록 시 : Item 관련 데이터 + 약관 정보 등 Item 객체와 관련 없는 수많은 부가 데이터도 넘어옴
수정 시 : 등록 시 요구사항과 다를 수 있음
-> Item 객체가 아닌 별도의 객체 만들어서 전달
-> HTML Form -> ItemSaveForm -> Controller -> Item 생성 -> Repository
1) Item 객체 원복
@Data
public class Item {
private Long id;
private String itemName;
private Integer price;
private Integer quantity;
}
2) Item 폼
(1) 저장용 폼 - ItemSaveForm
package hello.itemservice.web.validation.form;
import lombok.Data;
import org.hibernate.validator.constraints.Range;
import javax.validation.constraints.Max;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
@Data
public class ItemSaveForm {
@NotBlank
private String itemName;
@NotNull
@Range(min = 1000, max = 1000000)
private Integer price;
@NotNull
@Max(9999)
private Integer quantity;
}
(2) 수정용 폼 - ItemUpdateForm
package hello.itemservice.web.validation.form;
import lombok.Data;
import org.hibernate.validator.constraints.Range;
import javax.validation.constraints.Max;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
@Data
public class ItemUpdateForm {
@NotNull
private Long id;
@NotBlank
private String itemName;
@NotNull
@Range(min = 1000, max = 1000000)
private Integer price;
// 수정할 때는 수량을 자유롭게 변경 가능
private Integer quantity;
}
3) 컨트롤러
(1) 등록 로직
@PostMapping("/add")
public String addItem(@Validated @ModelAttribute("item") ItemSaveForm form, BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model) {
// 특정 필드가 아닌 복합 룰 검증
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}";
}
- 폼 객체 바인딩
- ItemSaveForm을 전달받음
- @ModelAttribute("item") 이름 주의 -> 이름 안넣으면 바인딩 된 객체 itemSaveForm이라는 이름으로 모델에 담김 -> 뷰 템플릿에서 접근하는 th:object 이름도 변경해야 함
public String addItem(@Validated @ModelAttribute("item") ItemSaveForm form, BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model) {
}
- 폼 객체를 Item으로 변환
- 전달받은 저장 객체 ItemSaveForm 검증 -> 새로운 Item 객체 생성 -> ItemSaveForm 값을 Item에 옮기기 -> Item 객체를 저장
//성공 로직
Item item = new Item();
item.setItemName(form.getItemName());
item.setPrice(form.getPrice());
item.setQuantity(form.getQuantity());
Item savedItem = itemRepository.save(item);
(2) 수정 로직
@PostMapping("/{itemId}/edit")
public String edit(@PathVariable Long itemId, @Validated @ModelAttribute("item") ItemUpdateForm form, BindingResult bindingResult) {
// 특정 필드가 아닌 복합 룰 검증
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/editForm";
}
// 수정 성공 로직
Item itemParam = new Item();
itemParam.setItemName(form.getItemName());
itemParam.setPrice(form.getPrice());
itemParam.setQuantity(form.getQuantity());
itemRepository.update(itemId, itemParam);
return "redirect:/validation/v4/items/{itemId}";
}
- 폼 객체 바인딩
- 수정 정보를 입력한 ItemUpdateForm 전달받음
- @ModelAttribute("item") 모델에 담기는 이름 주의
- 수정 폼 객체를 Item 객체로 변환
- 수정 폼 객체 데이터 -> 새로운 Item 객체로 복사 -> update 로직에 전달
9. Bean Validation - HTTP 메시지 컨버터
@ModelAttribute : HTTP 요청 파라미터 (쿼리스트링, POST Form)
@RequestBody : HTTP Body의 데이터를 객체로 변환할 때 (API JSON)
* HTTP 메시지 컨버터(@RequestBody)에 @Validated 적용 테스트
1) 컨트롤러 ValidationItemApiController
package hello.itemservice.web.validation;
import hello.itemservice.web.validation.form.ItemSaveForm;
import lombok.extern.slf4j.Slf4j;
import org.springframework.validation.BindingResult;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@Slf4j
@RestController
@RequestMapping("/validation/api/items")
public class ValidationItemApiController {
@PostMapping("/add")
public Object addItem(@RequestBody @Validated ItemSaveForm form, BindingResult bindingResult) {
log.info("API 컨트롤러 호출");
if (bindingResult.hasErrors()) {
log.info("검증 오류 발생 errors={}", bindingResult);
return bindingResult.getAllErrors();
}
log.info("성공 로직 실행");
return form;
}
}
2) Postman으로 테스트
- 성공 요청 : 성공
- 실패 요청 : JSON을 객체로 생성하는 것 자체가 실패
- 검증 오류 요청 : JSON을 객체로 생성하는 것은 성공, 검증은 실패
(1) 성공 요청
성공 요청 로그
- 컨트롤러 호출 성공
- 로직 성공
(2) 실패 요청
실패 요청 로그
- Http 메시지 컨버터에서 요청 JSON을 ItemSaveForm 객체로 생성하는 것 실패
-> 예외 발생
-> 컨트롤러 자체가 호출 X
-> Validator 실행 X
(3) 검증 오류 요청
검증 오류 요청 로그
- 컨트롤러 호출
- 검증 오류 발생 -> FieldError 발생
3) @ModelAttribute vs @RequestBody
- @ModelAttribute
- 필드 단위로 세밀하게 바인딩 적용
- 특정 필드에 바인딩 오류가 발생해도 나머지 필드는 정상 바인딩 O, 검증 O
- @RequestBody
- HttpMessageConverter 단계에서 전체 객체 단위로 적용
- JSON 데이터를 객체로 변경 실패 -> 예외 발생 -> 컨트롤러 호출 X, 검증 X
- 메시지 컨버터 작동 성공 -> ItemSaveForm 객체 만들어야 -> @Validated 적용
'Spring' 카테고리의 다른 글
[인프런/스프링 MVC 2편] 7. 로그인 처리2 - 필터, 인터셉터 (0) | 2023.05.26 |
---|---|
[인프런/스프링 MVC 2편] 6. 로그인 처리1 - 쿠키, 세션 (0) | 2023.05.25 |
[인프런/스프링 MVC 2편] 4. 검증, 오류처리 (1) - Validation (0) | 2023.05.21 |
[인프런/스프링 MVC 2편] 3. 메시지, 국제화 (0) | 2023.05.18 |
[인프런/스프링 MVC 2편] 2. 타임리프 - 스프링 통합과 폼 (0) | 2023.05.17 |