Bean Validation - 소개
검증 기능을 항상 코드로 작성하는 것은 매우 번거롭다.
이런 검증 로직을 모든 프로젝트에 적용할 수 있게 공통화하고, 표준화된 것이 Bean Validation
이다.
Bean Validation
을 잘 활용하면, 애노테이션 하나로 검증 로직을 매우 편리하게 적용할 수 있다.
Bean Validation
은 특정한 구현체가 아니라 Bean Validation 2.0(JSR-380)
이라는
검증 애노테이션과 여러 인터페이스의 모음으로 이루어진 기술 표준이다.
Bean Validation - 시작
- 우선 build.gradle에 아래 의존관계를 추가해주자.
implementation 'org.springframework.boot:spring-boot-starter-validation'
- 해당 라이브러리를 추가하면 빈 검증 애노테이션을 사용할 수 있다.
Item 클래스에 적용해보자.
package hello.validation.domain.item;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import org.hibernate.validator.constraints.Range;
@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;
}
}
@NotBlank
- 빈 값이나 공백만 있는 경우를 허용하지 않는다.
@NotNull
- null을 허용하지 않는다.
@Range(min = 1000, max = 1000000)
- 범위 안(min ~ max)의 값이어야 한다.
@Max(9999)
- 최대 9999까지만 허용한다.
테스트 코드를 작성해보자.
package hello.validation;
import hello.validation.domain.item.Item;
import jakarta.validation.ConstraintViolation;
import jakarta.validation.Validation;
import jakarta.validation.Validator;
import jakarta.validation.ValidatorFactory;
import org.junit.jupiter.api.Test;
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.message=" + violation.getMessage());
}
}
}
Bean Validation - 프로젝트 준비 V3
- 이전 버전과의 차이점을 확인하기 위해 이번에는 v3를 생성해보자.
- ValidationItemControllerV3 컨트롤러 생성
hello.itemservice.web.validation.ValidationItemControllerV2
복사hello.itemservice.web.validation.ValidationItemControllerV3
로 붙여넣기- URL 경로를
validation/v2/
에서validation/v3/
로 변경
- 템플릿 파일 복사
validation/v2
디렉토리의 모든 템플릿 파일을validation/v3
디렉토리로 복사
- ValidationItemControllerV3에서 리소스 경로를 모두
validation/v2/
에서validation/v3/
로 변경
Bean Validation - 스프링 적용
불필요한 코드 제거
- ValidationItemControllerV3에서 addItemV1() ~ addItemV5() 제거하기
- ValidationItemControllerV3에서 addItemV6()를 addItem()로 변경하기
- ValidationItemControllerV3에서 @InitBinder 제거하기
- 제거하지 않으면 오류 검증기가 중복 적용된다.
http://localhost:8080/validation/v3/items
로 접속해보면 빈 유효성 검사가 정상 동작하는 것을 확인할 수 있다.
스프링 MVC는 어떻게 Bean Validator를 사용하는 걸까?
- 스프링 부트에
spring-boot-starter-validation
에 대한 의존성이 있다면
자동으로 Bean Validatior를 인지하고 스프링에 등록한다. LocalValidatorFactoryBean
을글로벌 Validator
로 자동으로 등록하기 때문에 애노테이션만 적용하면 된다.글로벌 Validator
는@NotNull
같은 애노테이션을 보고 검증을 수행한다.- 이렇게
글로벌 Validator
가 적용되어 있기 때문에@Valid
나@Validated
만 적용하면 된다. - 검증 오류가 발생하면
FieldError
나ObjectError
를 생성해서BindingResult
에 담아준다.
- 이렇게
- 주의점
글로벌 Validator
를 직접 등록하면 스프링 부트는 Bean Validatior를글로벌 Validator
로 등록하지 않는다.- 그래서
글로벌 Validator
를 직접 등록하면 애노테이션 기반의 빈 검증기가 동작하지 않는다.
@Valid와 @Validated
- 검증 시
@Valid
와@Validated
둘 다 사용가능하다. @Valid
를 사용하려면 build.gradle에 의존관계 추가가 필요하다.@Valid
는 자바 표준 검증 애노테이션이다.@Validated
는 스프링 전용 검증 애노테이션이다.- 둘 중 아무거나 사용해도 동일하게 동작한다.
- 그저 차이점은
@Validated
는 내부에groups
라는 기능을 포함하고 있다는 것 뿐이다.
Bean Validation - 에러 코드
- Bean Validation이 기본으로 제공하는 오류 메시지를 좀 더 자세히 변경할 방법은 없을까?
- Bean Validation을 적용하고 bindingResult 에 등록된 검증 오류 코드를 확인해보면
마치 typeMismatch처럼 오류 코드가 애노테이션 이름으로 등록된다. - 그래서 MessageCodesResolver를 통한 다양한 메시지 코드가 순서대로 생성된다.
errors.properties에 추가해보자.
#Bean Validation 추가
NotBlank={0} 공백X
Range={0}, {2} ~ {1} 허용
Max={0}, 최대 {1}
- {0} 은 필드명이고, {1} , {2} …은 각 애노테이션 마다 다르다.
BeanValidation 메시지 찾는 순서
- 생성된 메시지 코드 순서대로 messageSource에서 메시지 찾기
- 애노테이션의 message 속성 사용
- 라이브러리가 제공하는 기본 값 사용
Bean Validation - 오브젝트 오류
- Bean Validation에서 특정 필드(FieldError)가 아닌 해당 오브젝트 관련 오류(ObjectError)는 어떻게 처리해야 할까?
- @ScriptAssert()를 사용하면 된다.
@Data
@ScriptAssert(lang = "javascript", script = "_this.price * _this.quantity >= 10000")
public class Item {
//...
}
- 실행해보면 메시지 코드가 아래와 같이 생성된다.
- ScriptAssert.item
- ScriptAssert
문제점
- 실제 사용해보면 제약이 많고 복잡하다.
- 실무에서는 검증 기능이 해당 객체의 범위를 넘어서는 경우들도 종종 등장하는데, 그런 경우 대응이 어렵다.
ValidationItemControllerV3에 글로벌 오류를 추가해보자.
@PostMapping("/add")
public String addItem(@Validated @ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes) {
//특정 필드 예외가 아닌 전체 예외
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}";
}
Bean Validation - 수정에 적용
- 상품 수정에도 빈 검증을 적용해보자.
ValidationItemControllerV3의 edit() 변경
//상품 수정
@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}";
}
- Item 모델 객체에
@Validated
를 추가하였다. - 검증 오류가 발생하면 editForm으로 이동하는 코드 추가하였다.
validation/v3/editForm.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.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"
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>
<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>
.field-error
css를 추가하였다.- 글로벌 오류 메시지를 추가하였다.
- 상품명, 가격, 수량 필드에 검증 기능을 추가하였다.
Bean Validation - 한계
- 상품을 등록하거나 수정할 때 현재는 동일한 모델 객체를 사용하고 있다.
- 그런데 상품을 등록할 때와 수정할 때의 요구사항이 다를 수가 있다.
- 두 경우의 요구사항이 다를 때 한 쪽에만 맞추게 된다면 다른 한 쪽에서 문제가 발생할텐데 이런 경우에는 어떻게 해야 할까?
Bean Validation - groups
- 동일한 모델 객체를 사용할 때 등록할 때와 수정할 떄 각각 다르게 검증하는 방법은 2가지가 있다.
- BeanValidation의 groups 기능을 사용한다.
- Item을 직접 사용하지 않고, ItemSaveForm나 ItemUpdateForm처럼
폼 전송을 위한 별도의 모델 객체를 만들어서 사용한다.
groups
- 검증할 기능을 그룹으로 나누어서 적용하는 기술이다.
groups 적용해보기
- 등록용 groups 생성
package hello.validation.domain.item;
public interface SaveCheck {
}
- 수정용 groups 생성
package hello.validation.domain.item;
public interface UpdateCheck {
}
- Item에 groups를 적용해보자.
package hello.validation.domain.item;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import org.hibernate.validator.constraints.Range;
@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;
}
}
- addItem()를 복사해서 addItemV2() 생성 후 SaveCheck.class를 적용해보자.
@PostMapping("/add")
public String addItemV2(@Validated(SaveCheck.class) @ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes) {
//특정 필드 예외가 아닌 전체 예외
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}";
}
- edit()를 복사해서 editV2() 생성 후 UpdateCheck.class를 적용해보자.
//상품 수정
@PostMapping("/{itemId}/edit")
public String editV2(@PathVariable Long itemId, @Validated(UpdateCheck.class) @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}";
}
알아야 하는 점
- groups 기능을 사용해서 등록과 수정시에 각각 다르게 검증을 할 수 있었다.
- 그런데 groups 기능을 사용하니 Item은 물론이고, 전반적으로 복잡도가 올라갔다.
- 그래서 실무에서는 보통 groups를 사용하지는 않고, 그냥 등록용 폼 객체와 수정용 폼 객체를 분리해서 사용한다.
Form 전송 객체 분리 - 프로젝트 준비 V4
- 이전 버전과의 차이점을 확인하기 위해 이번에는 v4를 생성해보자.
- ValidationItemControllerV4 컨트롤러 생성
hello.itemservice.web.validation.ValidationItemControllerV3
복사hello.itemservice.web.validation.ValidationItemControllerV4
로 붙여넣기- URL 경로를
validation/v3/
에서validation/v4/
로 변경
- 템플릿 파일 복사
validation/v3
디렉토리의 모든 템플릿 파일을validation/v4
디렉토리로 복사
- ValidationItemControllerV4에서 리소스 경로를 모두
validation/v3/
에서validation/v4/
로 변경 http://localhost:8080/validation/v4/items
로 접속해서 잘 동작하는지 확인해보자.
Form 전송 객체 분리 - 소개
폼 데이터를 받는 방법
- 데이터 전달에 Item 도메인 객체 사용
HTML Form -> Item -> Controller -> Item -> Repository
- 장점
- Item 도메인 객체를 컨트롤러, 리포지토리 까지 직접 전달한다.
- 중간에 Item을 만드는 과정이 없어서 간단하다.
- 단점
- 간단한 경우에만 적용할 수 있다.
- 수정시 검증이 중복될 수 있고, groups를 사용해야 한다.
- 폼 데이터 전달을 위한 별도의 객체 사용
HTML Form -> ItemSaveForm -> Controller -> Item 생성 -> Repository
- 장점
- 전송하는 폼 데이터가 복잡해도 거기에 맞춘 별도의 폼 객체를 사용해서 데이터를 전달 받을 수 있다.
- 보통 등록과, 수정용으로 별도의 폼 객체를 만들기 때문에 검증이 중복되지 않는다.
- 단점
- 폼 데이터를 기반으로 컨트롤러에서 Item 객체를 생성하는 변환 과정이 추가된다.
폼 데이터를 받는 객체를 분리하는 이유
- 정말 간단한 단일 서비스거나 등록과 수정이 동일한 경우면 굳이 분리하지는 않아도 된다.
- 하지만 실무에서는 복잡한 케이스가 많고, 등록과 수정이 다른 경우가 많은 편이라 어지간하면 분리하는게 좋다.
폼 데이터 객체 작명하기
- 사실 의미가 있는 이름이면 크게 상관없다.
- ItemSaveForm이든 ItemSaveDto든 뭐든 상관없다.
- 다만 해당 회사나 프로젝트에 업무 규칙이 있다면 그건 반드시 지켜야 한다. (중요★)
폼 데이터가 발생하는 뷰 템플릿은 분리해야 할까?
- 단순한 페이지면 분리하지 않아도 상관없다.
- 다만 조건문이 좀 많아지기 시작했다? 그러면 분리하는게 정신건강에 이롭다.
Form 전송 객체 분리 - 개발
Item 원복
- 이제 Item에서는 검증을 사용하지 않으니 검증 코드를 제거하자.
Item 등록용 폼 생성하기
package hello.validation.domain.item;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import org.hibernate.validator.constraints.Range;
@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 수정용 폼 생성하기
package hello.validation.domain.item;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import org.hibernate.validator.constraints.Range;
@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에서 기존 코드 제거하기
- addItem()
- addItemV2()
- edit()
- editV2()
ValidationItemControllerV4에 신규 코드 추가하기
- addItem()
- Item 대신에 ItemSaveform을 전달 받는다.
- @Validated로 검증을 수행한다.
- BindingResult 로 검증 결과를 받는다.
@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 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}";
}
- edit()
@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)에도 적용할 수 있다.
ValidationItemApiController 생성
package hello.validation.web.item.validation;
import hello.validation.domain.item.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;
}
}
@ModelAttribute vs @RequestBody
- HTTP 요청 파리미터를 처리하는
@ModelAttribute
는 각각의 필드 단위로 세밀하게 적용된다.- 그래서 특정 필드에 타입이 맞지 않는 오류가 발생해도 나머지 필드는 정상 처리할 수 있었다.
HttpMessageConverter
는@ModelAttribute
와 다르게 각각의 필드 단위로 적용되는 것이 아니라,
전체 객체 단위로 적용된다.- 따라서 메시지 컨버터의 작동이 성공해서 ItemSaveForm 객체를 만들어야
@Valid
나@Validated
가 적용된다.
- 따라서 메시지 컨버터의 작동이 성공해서 ItemSaveForm 객체를 만들어야
@ModelAttribute
는 필드 단위로 정교하게 바인딩이 적용된다.- 특정 필드가 바인딩 되지 않아도 나머지 필드는 정상 바인딩 되고, Validator를 사용한 검증도 적용할 수 있다.
@RequestBody
는HttpMessageConverter
단계에서 JSON 데이터를 객체로 변경하지 못하면
이후 단계 자체가 진행되지 않고 예외가 발생한다.- 컨트롤러도 호출되지 않고, Validator도 적용할 수 없다.