'스프링 MVC 2편 - 백엔드 웹 개발 활용 기술' 수업을 듣고 정리한 내용입니다.
- 검증 기능을 지금처럼 매번 코드로 작성하는 것은 상당히 번거롭다.
- 특히 특정 필드에 대한 검증 로직은 대부분 빈 값인지 아닌지, 특정 크기를 넘는지 아닌지와 같이 매우 일반적인 로직이다.
- 빈(
null
) 값인지 체크- 범위를 넘는지 체크
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
이다.
➡️ Bean Validation
을 잘 활용하면, 애노테이션 하나로 검증 로직을 매우 편리하게 적용할 수 있다.
- 특정한 구현체가 아니라 Bean Validation 2.0(JSR-380)이라는 기술 표준이다.
- 검증 애노테이션과 여러 인터페이스의 모음이다.
- 마치
JPA
가 표준 기술이고 그 구현체로 하이버네이트가 있는 것과 같다.Bean Validation
을 구현한 기술중에 일반적으로 사용하는 구현체는 하이버네이트Validator
이다.
- 이름이 하이버네이트가 붙어서 그렇지
ORM
과는 관련이 없다.
💡 참고
하이버네이트 Validator 관련 링크
Bean Validation
기능을 어떻게 사용하는지 코드로 알아보자.- 먼저 스프링과 통합하지 않고, 순수한
Bean Validation
사용법 부터 테스트 코드로 알아보자.
✓ Bean Validation
의존관계 추가
build.gradle
implementation 'org.springframework.boot:spring-boot-starter-validation'
spring-boot-starter-validation
의존관계를 추가하면 라이브러리가 추가 된다.
Jakarta Bean Validation
jakarta.validation-api
: Bean Validation 인터페이스hibernate-validator
구현체
Item - Bean Validation 애노테이션 적용
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;
}
}
📣 검증 애노테이션
@NotBlank
: 빈값 + 공백만 있는 경우를 허용하지 않는다.@NotNull
:null
을 허용하지 않는다.@Range(min = 1000, max = 1000000)
: 범위 안의 값이어야 한다.@Max(9999)
: 최대 9999까지만 허용한다.
💡 참고
javax.validation.constraints.NotNull
,org.hibernate.validator.constraints.Range
javax.validation
으로 시작하면 특정 구현에 관계없이 제공되는 표준 인터페이스org.hibernate.validator
로 시작하면 하이버네이트validator
구현체를 사용할 때만 제공되는 검증 기능- 실무에서 대부분 하이버네이트
validator
를 사용하므로 자유롭게 사용해도 된다.
BeanValidationTest - Bean Validation 테스트 코드 작성
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.message = " + violation.getMessage());
}
}
}
✓ 검증기 생성
ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
Validator validator = factory.getValidator();
✓ 검증 실행
item
)을 직접 검증기에 넣고 그 결과를 받는다. Set
에는 ConstraintViolation
이라는 검증 오류가 담긴다. Set<ConstraintViolation<Item>> violations = validator.validate(item);
실행 결과
violation={interpolatedMessage='공백일 수 없습니다', propertyPath=itemName, rootBeanClass=class hello.itemservice.domain.item.Item, messageTemplate='{javax.validation.constraints.NotBlank.message}'} violation.message=공백일 수 없습니다
violation={interpolatedMessage='9999 이하여야 합니다', propertyPath=quantity, rootBeanClass=class hello.itemservice.domain.item.Item, messageTemplate='{javax.validation.constraints.Max.message}'} violation.message=9999 이하여야 합니다
violation={interpolatedMessage='1000에서 1000000 사이여야 합니다', propertyPath=price, rootBeanClass=class hello.itemservice.domain.item.Item, messageTemplate='{org.hibernate.validator.constraints.Range.message}'} violation.message=1000에서 1000000 사이여야 합니다
➡️ ConstraintViolation
출력 결과를 보면, 검증 오류가 발생한 객체, 필드, 메시지 정보등 다양한 정보를 확인할 수 있다.
📌 정리
- 이와 같이 빈 검증기(
Bean Validation
)를 직접 사용하는 방법을 알아보았다.- 아마 지금까지 배웠던 스프링 MVC 검증 방법에 빈 검증기를 어떻게 적용하면 좋을지 여러가지 생각이 들 것이다.
- 스프링은 이미 개발자를 위해 빈 검증기를 스프링에 완전히 통합해두었다.
ValidationItemControllerV3 코드 수정
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;
@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) {
// 검증에 실패하면 다시 입력 폼으로
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, @ModelAttribute Item item) {
itemRepository.update(itemId, item);
return "redirect:/validation/v3/items/{itemId}";
}
}
실행
Bean Validation
이 정상 동작한다.
💡 참고
- 특정 필드의 범위를 넘어서는 검증(가격 * 수량의 합은 10,000원 이상) 기능은 나중에 공부한다. (현재는 빠져있다.)
✓ 스프링 MVC는 어떻게 Bean Validator 사용법
스프링 부트가 spring-boot-starter-validation
라이브러리를 넣으면 자동으로 Bean Validator
를 인지하고 스프링에 통합한다.
✓ 스프링 부트는 자동으로 글로벌 Validator로 등록한다.
LocalValidatorFactoryBean
을 글로벌 Validator
로 등록한다.Validator
는 @NotNull
같은 애노테이션을 보고 검증을 수행한다.Validator
가 적용되어 있기 때문에, @Valid
, @Validated
만 적용하면 된다.FieldError
, ObjectError
를 생성해서 BindingResult
에 담아준다.
⚠️ 주의
- 다음과 같이 직접 글로벌
Validator
를 직접 등록하면 스프링 부트는Bean Validator
를 글로벌Validator
로 등록하지 않는다.- 따라서 애노테이션 기반의 빈 검증기가 동작하지 않는다.
- 다음 부분은 제거하자.
다음 부분
@SpringBootApplication
public class ItemServiceApplication implements WebMvcConfigurer {
// 글로벌 검증기 추가
@Override
public Validator getValidator() {
return new ItemValidator();
}
// ...
}
💡 참고
- 검증시
@Validated
,@Valid
둘다 사용가능하다.javax.validation.@Valid
를 사용하려면build.gradle
의존관계 추가가 필요하다. (이전에 추가했다.)
implementation 'org.springframework.boot:spring-boot-starter-validation'
@Validated
: 스프링 전용 검증 애노테이션@Valid
: 자바 표준 검증 애노테이션
- 둘중 아무거나 사용해도 동일하게 작동한다.
@Validated
는 내부에groups
라는 기능을 포함하고 있다.
1.
@ModelAttribute
각각의 필드에 타입 변환 시도
➡️ 성공하면 다음으로
➡️ 실패하면typeMismatch
로FieldError
추가2. Validator 적용
✓ 바인딩에 성공한 필드만 Bean Validation 적용
BeanValidator
는 바인딩에 실패한 필드는 BeanValidation
을 적용하지 않는다.BeanValidation
적용이 의미 있다.
✓ @ModelAttribute
→ 각각의 필드 타입 변환시도 → 변환에 성공한 필드만 BeanValidation
적용
ex)
itemName
에 문자 "A"
입력 → 타입 변환 성공 → itemName
필드에 BeanValidation
적용price
에 문자 "A"
입력 → "A"
를 숫자 타입 변환 시도 실패 → typeMismatch FieldError
추가 → price
필드는 BeanValidation
적용하지 않는다.
Bean Validation
이 기본으로 제공하는 오류 메시지를 좀 더 자세히 변경하고 싶으면 어떻게 하면 될까?Bean Validation
을 적용하고bindingResult
에 등록된 검증 오류 코드를 보자.- 오류 코드가 애노테이션 이름으로 등록된다.
- 마치
typeMismatch
와 같이NotBlank
라는 오류 코드를 기반으로MessageCodesResolver
를 통해 다양한 메시지 코드가 순서대로 생성된다.
🔭 @NotBlank
NotBlank.item.itemName
NotBlank.itemName
NotBlank.java.lang.String
NotBlank
🔭 @Range
Range.item.price
Range.price
Range.java.lang.Integer
Range
원하는 형태로 출력되도록 메시지를 등록해보자!
errors.properties
#Bean Validation 추가
NotBlank={0} 공백X
Range={0}, {2} ~ {1} 허용
Max={0}, 최대 {1}
{0}
: 필드명{1} , {2} ...
은 각 애노테이션 마다 다르다.
실행
✓ BeanValidation 메시지 찾는 순서
1. 생성된 메시지 코드 순서대로 messageSource
에서 메시지 찾기
2. 애노테이션의 message
속성 사용 → @NotBlank(message = "공백! {0}")
3. 라이브러리가 제공하는 기본 값 사용 → 공백일 수 없습니다.
✓ 애노테이션의 message 사용 예
@NotBlank(message = "공백은 입력할 수 없습니다.")
private String itemName;
특정 필드(
FieldError
)가 아닌 해당 오브젝트 관련 오류(ObjectError
) 처리 방법에 대해 알아보자!
@ScriptAssert()
을 사용시 해당 오브젝트 관련 오류(ObjectError
)를 처리할 수 있다.
@Data
@ScriptAssert(lang = "javascript", script = "_this.price * _this.quantity >= 10000")
public class Item {
//...
}
✓ 메시지 코드
ScriptAssert.item
ScriptAssert
따라서
➡️ 오브젝트 오류(글로벌 오류)의 경우 @ScriptAssert
을 억지로 사용하는 것 보다는 다음과 같이 오브젝트 오류 관련 부분만 직접 자바 코드로 작성하는 것을 권장한다.
➡️ 입력한 @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}";
}
edit()
: 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">
<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>
.field-error
css 추가
실행 결과
참고