소개
- Bean Validation
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;
//...
}
어노테이션으로 검증 로직 편리하게 적용 가능
Bean Validation
=> 특정한 구현체 X, Bean Validation 2.0(JSR-380)이라는 기술 표준 O
=> 검증 애노테이션과 여러 인터페이스의 모음 (일반적으로 사용하는 구현체 => 하이버네이트 Validator)
- 관련 링크
공식 사이트: http://hibernate.org/validator/
공식 메뉴얼: https://docs.jboss.org/hibernate/validator/6.2/reference/en-US/html_single/
검증 애노테이션 모음: https://docs.jboss.org/hibernate/validator/6.2/reference/en-US/html_single/#validator-defineconstraints-spec
시작
스프링과 통합하지 않고 Bean Validation부터 사용해보기
- 의존관계 추가 (build.gradle)
implementation 'org.springframework.boot:spring-boot-starter-validation'
jakarta.validation-api : Bean Validation 인터페이스
hibernate-validator : 구현체
- 테스트 코드
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 // 빈값 + 공백 허용 X
private String itemName;
@NotNull // null 허용 X
@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;
}
}
※
javax.validation.constraints.NotNull
org.hibernate.validator.constraints.Range
javax.validation 으로 시작 => 특정 구현에 관계없이 제공되는 표준 인터페이스
org.hibernate.validator 로 시작 => 하이버네이트 validator 구현체를 사용할 때만 제공 (실무에서 대부분 하이버네이트 validator를 사용하므로 자유롭게 사용 가능)
스프링 적용
public class ValidationItemControllerV3 {
private final ItemRepository itemRepository;
// 기존 itemValidator 코드 삭제
@GetMapping
public String items(Model model) {
List<Item> items = itemRepository.findAll();
model.addAttribute("items", items);
return "validation/v3/items";
}
@GetMapping("/{itemId}")
public String item(@PathVariable long itemId, Model model) {
Item item = itemRepository.findById(itemId);
model.addAttribute("item", item);
return "validation/v3/item";
}
@GetMapping("/add")
public String addForm(Model model) {
model.addAttribute("item", new Item());
return "validation/v3/addForm";
}
@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}";
}
@GetMapping("/{itemId}/edit")
public String editForm(@PathVariable Long itemId, Model model) {
Item item = itemRepository.findById(itemId);
model.addAttribute("item", item);
return "validation/v3/editForm";
}
@PostMapping("/{itemId}/edit")
public String edit(@PathVariable Long itemId, @Validated @ModelAttribute Item item, BindingResult bindingResult) {
//특정 필드가 아닌 복합 룰 검증a
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}";
}
}
- 스프링 MVC & Bean Validator
스프링부트에 spring-boot-starter-validation 라이브러리 적용 => 자동으로 Bean Validator - 스프링 통합됨
LocalValidatorFactoryBean을 글로벌 Validator로 등록함
컨트롤러에 @Valid 또는 @Validated 어노테이션만 적용하면 검증 오류 발생시 BindingResult에 담음
※ 직접 글로벌 Validator을 작성해 등록 시 스프링부트는 Bean Validator을 글로벌 Validator로 등록 X
@SpringBootApplication
public class ItemServiceApplication implements WebMvcConfigurer {
// 글로벌 검증기 추가
@Override
public Validator getValidator() {
return new ItemValidator();
}
// ...
}
※ @Valid, @Validated
javax.validation.@Valid =>
@Validated : 스프링 전용 검증 애노테이션 / 내부에 groups 기능 포함
@Valid : 자바 표준 검증 애노테이션 -> build.gradle 의존관계 추가 필요
- 검증 순서
1. @ModelAttribute 각 필드에 타입 변환 시도
2. 성공 시 다음으로 / 실패 시 typeMismatch로 Fielderror 추가
3. Validator 적용
모델 객체에 바인딩 실패 시 Bean Validation 적용 X (타입 변환 성공해야 Bean Validation 적용)
에러 코드
- Bean Validation이 제공하는 기본 오류 메시지
@NotBlank
NotBlank.item.itemName
NotBlank.itemName
NotBlank.java.lang.String
NotBlank
- 메시지 수정 (error.properties)
NotBlank={0} 공백X
Range={0}, {2} ~ {1} 허용
Max={0}, 최대 {1}
※ {0} : 필드명
- Bean Validation 메시지 탐색 순서
1. 생성된 메시지 코드 순서대로 messageSource 에서 메시지 찾기
2. 애노테이션의 message 속성 사용 ex. @NotBlank(message = "공백! {0}")
3. 라이브러리가 제공하는 기본 값 ex. 공백일 수 없습니다
오브젝트 오류
- @ScriptAssert()
@Data
@ScriptAssert(lang="javascript", script = "_this.price * _this.quantity >= 10000",
message = "총합이 10000 이상이어야 합니다")
public class Item {
...
메시지 코드 =>
ScriptAssert.item
ScriptAssert
but 실제로 사용 시 제약 많고 복잡
검증 기능이 객체의 범위 넘어서는 경우 대응 어려움
∴ 오브젝트(글로벌) 오류 => 직접 자바 코드 작성 권장
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);
}
}
//...
}
수정에 검증 기능 추가 => 한계
- ValidationItemControllerV3
////@Validated 추가
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}"; /////
}
- editForm.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="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>
검증 태그 추가
- 수정 요구사항
등록: quantity 수량을 최대 9999까지 등록 / 수정: 수량을 무제한으로 변경 가능
등록: id에 값 없어도 됨 / 수정: id 값 필수
- 요구사항 반영
@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;
//...
}
=> 등록시 id에 값이 없으므로 notNull 오류 발생, 수량 제한도 사라짐
등록 & 수정에서 검증 조건의 충돌 발생
groups
검증 충돌 해결 방법
1. Bean Validation의 groups 기능 사용
2. Item 직접 사용하는 대신 폼 전송 위한 별도 모델 객체 생성
- 저장/수정용 groups 생성
public interface SaveCheck {
}
public interface UpdateCheck {
}
- Item
@Data
public class Item {
@NotNull(groups = UpdateCheck.class) ////
private Long id;
@NotBlank(groups = {SaveCheck.class, UpdateCheck.class}, message = "공백 X") ////
private String itemName;
@NotNull(groups = {SaveCheck.class,UpdateCheck.class}) ////
@Range(min = 1000, max = 1000000)
private Integer price;
@NotNull(groups = {SaveCheck.class,UpdateCheck.class}) ////
@Max(9999)
private Integer quantity;
//...
- ValidationItemControllerV3
@PostMapping("/add")
public String addItem2(@Validated(SaveCheck.class) @ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes) {
//...
Validated 어노테이션에 적용할 groups 클래스 작성
=> 전반적으로 복잡도 증가
=> 실무에서는 주로 등록/수정용 폼 객체 분리해 사용
Form 전송 객체 분리
실무에서는 groups 잘 사용 안함 => 등록 시 폼에서 전달하는 데이터가 도메인 객체와 딱 맞지 않기 때문
ex. 회원 등록 => 회원 관련 데이터 + 약관 정보 등 Item과 관련없는 데이터 넘어옴
=> Item 직접 전달받는 대신 복잡한 폼의 데이터를 컨트롤러까지 전달할 별도 객체 만들어 전달
ex. ItemSaveForm 이라는 폼 전용 객체 생성해 @ModelAttribute로 사용
- 폼 데이터 전달 방법
1. Item 도메인 객체 사용
HTML Form -> Item -> Controller -> Item -> Repository
장점: Item 도메인 객체를 컨트롤러, 리포지토리까지 직접 전달 -> 중간에 Item을 생성 안 해서 단순
단점: 간단한 경우에만 적용 가능 (수정시 검증이 중복될 수 있고, groups를 사용해야 함)
2. 폼 데이터 전달 전용 객체 사용
HTML Form -> ItemSaveForm -> Controller -> Item 생성 -> Repository
장점: 맞춤형 폼 객체를 사용해서 복잡한 데이터 전달 가능, 등록/수정용 폼 객체를 별도로 만들어 검증 중복 X
단점: 폼 데이터를 기반으로 컨트롤러에서 Item 객체를 생성하는 변환 과정이 추가됨
- Form 전송 객체 분리
- Item
@Data
public class Item {
private Long id;
private String itemName;
private Integer price;
private Integer quantity;
}
- Item 저장용 폼 (ItemSaveForm)
@Data
public class ItemSaveForm {
@NotBlank
private String itemName;
@NotNull
@Range(min = 1000, max = 1000000)
private Integer price;
@NotNull
@Max(value = 9999)
private Integer quantity;
}
- Item 수정용 폼 (ItemUpdateForm)
@Data
public class ItemUpdateForm {
@NotNull
private Long id;
@NotBlank
private String itemName;
@NotNull
@Range(min = 1000, max = 1000000)
private Integer price;
//수정시 수량 자유롭게 변경 가능
private Integer quantity;
}
- ValidationItemControllerV4
// 저장
@PostMapping("/add")
// 폼으로부터 Item 대신 ItemSaveForm 전달받음
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 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}";
}
// 수정
@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}";
}
※ @ModelAttribute("item")
item 추가 설정 안 하면 itemSaveForm이라는 이름으로 MVC Model에 담기게 됨
-> 뷰 템플릿 th:object 값 변경 필요하게 됨
Bean Validation - HTTP 메시지 컨버터
@Valid , @Validated => HttpMessageConverter (@RequestBody)에도 적용 가능
※
@ModelAttribute : HTTP 요청 파라미터(URL 쿼리 스트링, POST Form) 다룰 때 사용
@RequestBody : HTTP Body 데이터를 객체로 변환할 때 사용 ex) API JSON 요청
- @RestController, @RequestBody 로 데이터 전송
- ValidationItemApiController
@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("검증 오류 발생 = {}", bindingResult);
return bindingResult.getAllErrors();
}
log.info("성공 로직 실행");
return form;
}
}
- 실패
1. JSON을 객체로 생성 실패 (Controller 호출 실패)
POST http://localhost:8080/validation/api/items/add
{"itemName":"hello", "price":"A", "quantity": 10}
price 에 숫자가 아닌 문자 전달
=>
HTTP 메시지
{
"timestamp": "2021-04-20T00:00:00.000+00:00",
"status": 400,
"error": "Bad Request",
"message": "",
"path": "/validation/api/items/add"
}
로그
[org.springframework.http.converter.HttpMessageNotReadableException: JSON parse
error: Cannot deserialize value of type `java.lang.Integer` from String "A": not
a valid Integer value; nested exception is
com.fasterxml.jackson.databind.exc.InvalidFormatException: Cannot deserialize
value of type `java.lang.Integer` from String "A": not a valid Integer value
at [Source: (PushbackInputStream); line: 1, column: 30] (through reference
chain: hello.itemservice.domain.item.Item["price"])]
Json -> ItemSaveForm 객체 생성 실패 -> 컨트롤러 호출 X
2. JSON 객체 생성 후 검증에서 실패
POST http://localhost:8080/validation/api/items/add
{"itemName":"hello", "price":1000, "quantity": 10000}
Bean Validation @Max에 걸림
=>
[
{
"codes": [
"Max.itemSaveForm.quantity",
"Max.quantity",
"Max.java.lang.Integer",
"Max"
],
"arguments": [
{
"codes": [
"itemSaveForm.quantity",
"quantity"
],
"arguments": null,
"defaultMessage": "quantity",
"code": "quantity"
},
9999
],
"defaultMessage": "9999 이하여야 합니다",
"objectName": "itemSaveForm",
"field": "quantity",
"rejectedValue": 10000,
"bindingFailure": false,
"code": "Max"
}
]
실제 개발 시 객체 그대로 사용하지 않고,
필요한 데이터만 뽑아 별도 API 스펙 정의하고 해당 객체 만들어 반환해야 함
- @ModelAttribute vs @RequestBody
@ModelAttribute : 각각의 필드 단위로 세밀하게 적용 -> 특정 필드에 타입 오류가 발생해도 나머지 필드는 정상 처리 가능
HttpMessageConverter : 전체 객체 단위로 적용 -> JSON 데이터를 객체로 변경 못 하면 이후 단계 진행 X, 예외 발생 (컨트롤러 호출 X, Validator 적용 X)
'Programming > 스프링' 카테고리의 다른 글
logback-spring.xml로 로그 설정하기 (+ log rotate) (0) | 2024.04.04 |
---|---|
[김영한 스프링 MVC 2] 로그인 처리1 - 쿠키, 세션 (0) | 2024.01.19 |
[김영한 스프링 MVC 2] 검증1 - Validation (0) | 2023.12.03 |
[김영한 스프링 MVC 2] 타임리프 - 스프링 통합과 폼 (0) | 2023.12.02 |
[김영한 스프링 MVC 2] 타임리프 - 기본 기능 (0) | 2023.11.26 |