참고자료
이전 포스팅인 이 데이터 검증에서 BindingResult, Validator, @Validated를 사용한 데이터 검증에 대한 내용을 공부해 보았다.
근데 개발자들의 욕심은 끝이 없어서, 저 검증 관련 코드 작성하는것도 귀찮아 하게 된다. 그도 그럴게 사실 문자 필드에 빈 값이면 안되는 이 내용은 어떤 웹 애플리케이션에서도 만국 공통 아닐까? 그럼 좀 더 간단하게 공통화된 처리가 가능하지 않을까?라는 생각부터 출발한 것이다. 그래서 애노테이션을 가지고 그냥 검증이 가능하게 공통 처리를 해버렸다.
다음 코드를 보자.
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;
//...
}
이런식으로 범위는 어디서부터 어디까지이고, Null, Blank이면 안되고, 최대값은 얼마이고와 같은 공통 검증 로직을 애노테이션 하나로 처리해 버리는 방식이다.
Bean Validation
특정한 구현체가 아니라, Bean Validation 2.0(JSR-380)이라는 표준 기술이다. 쉽게 이야기해서 검증 애노테이션과 여러 인터페이스의 모음이다. 마치 JPA가 표준 기술이고 그 구현체로 하이버네이트가 있는 것처럼 말이다.
이 Bean Validation을 구현한 기술중에 일반적으로 사용하는 구현체는 하이버네이트 Validator이다. 이름에 하이버네이트가 붙었지만 ORM과는 관련이 없다.
Bean Validation 도입
우선, 의존성을 먼저 추가해야 한다.
implementation 'org.springframework.boot:spring-boot-starter-validation'
추가하면 다음과 같은 라이브러리가 추가가 된다.
- jakarta.validation-api: Bean Validation 인터페이스
- hibernate-validator: 구현체
그리고 이제 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(message = "필수 필드입니다.")
private String itemName;
@NotNull(message = "필수 필드입니다.")
@Range(min = 1_000, max = 1_000_000, message = "가격의 범위는 1,000 - 1,000,000 사이여야 합니다.")
private Integer price;
@NotNull(message = "필수 필드입니다.")
@Max(value = 9999, message = "최대값은 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 = 1_000, max = 1_000_000) → 1000 - 1000000 범위 안의 값이어야 한다.
- @Max(9999) → 최대 9999까지 허용한다.
참고로, Max, NotBlank, NotNull은 패키지가 javax.validation.constraints이다. 이 말은 javax.validation으로 시작하면 특정 구현에 관계없이 제공되는 표준 인터페이스이란 뜻이다. 그래서 어떤 구현체든 상관없이 사용할 수가 있다. 근데, Range같은 경우는 패키지가 org.hibernate.validator.constraints이다. 이 말은 이 hibernate 구현체를 사용해야만 사용 가능한 애노테이션이라는 의미다. 결론은 거의 대부분 하이버네이트 validator를 사용하기 때문에 자유롭게 사용해도 된다. 스프링도 기본적으로 구현체를 하이버네이트 validator로 다운받아 주기 때문에.
아래는 스프링을 사용하는 것과 상관없이 검증을 실행할 수가 있는데 이건 그냥 참고사항이고 우리는 스프링과 통합해서 사용할 거니까 그냥 아 이렇게 하는구나? 정도로 넘어가면 된다.
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.getMessage() = " + violation.getMessage());
}
factory.close();
}
}
- 스프링과 통합하면 이런 코드를 직접 작성할 필요가 없어진다.
- ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
- Validator validator = factory.getValidator();
- 검증 실행은 이 코드를 호출하면 된다.
- Set<ConstraintViolation<Item>> violations = validator.validate(item);
- 호출해서 결과가 비어있으면 검증 오류가 없는 것이고 결과가 비어있지 않으면 뭔가 검증에 실패한 것이다.
실행결과
violation = ConstraintViolationImpl{interpolatedMessage='must be less than or equal to 9999', propertyPath=quantity, rootBeanClass=class hello.itemservice.domain.item.Item, messageTemplate='{javax.validation.constraints.Max.message}'}
violation.getMessage() = must be less than or equal to 9999
violation = ConstraintViolationImpl{interpolatedMessage='must not be blank', propertyPath=itemName, rootBeanClass=class hello.itemservice.domain.item.Item, messageTemplate='{javax.validation.constraints.NotBlank.message}'}
violation.getMessage() = must not be blank
violation = ConstraintViolationImpl{interpolatedMessage='must be between 1000 and 1000000', propertyPath=price, rootBeanClass=class hello.itemservice.domain.item.Item, messageTemplate='{org.hibernate.validator.constraints.Range.message}'}
violation.getMessage() = must be between 1000 and 1000000
스프링과 통합하기
이제 스프링과 통합해서 사용해보자. 그러기 위해 저번 포스팅에서 사용했던 컨트롤러를 복사해서 새로 만들고 코드를 좀 정리하자.
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.debug("[addItemV2] bindingResult 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}";
}
}
- ItemValidator, @InitBinder 등의 코드를 삭제했다. 필요없다.
- addItem() 메서드는 여전히 동일하게 남아 있다. 이 메서드에 검증할 객체인 @ModelAttribute Item item 앞에 @Validated 애노테이션을 붙여주었고, 검증한 결과를 담고 있을 BindingResult는 @ModelAttribute Item item 바로 뒤에 넣어주었다. (이 내용은 지난번 포스팅에서 설명했다. 이해가 안된다면 저번 포스팅을 참고)
이렇게 해두고, 테스트를 해보면? 신기하게 검증이 그대로 된다.
아무것도 한 게 없는데, 그저 Item에 애노테이션만 달았을 뿐인데 자동으로 검증이 적용됐다. 어떻게 된 일일까?
스프링 부트가 다음 의존성을 추가하면 자동으로 Bean Validator를 인지하고 스프링에 통합한다.
spring-boot-starter-validation
그래서 스프링 부트가 하는 일은 자동으로 글로벌 Validator를 등록한다. 저번 포스팅에서 글로벌로 Validator를 적용하면, 주입받고 @InitBinder 애노테이션으로 Validator 등록할 필요없이 그저 검증하고자 하는 컨트롤러에 @Validated 애노테이션만 검증 객체 바로 옆에 넣어주면 된다고 했다. 그게 지금 적용된 것이다.
스프링 부트가 LocalValidatorFactoryBean이라는 글로벌 Validator를 등록한다. 그래서 이 Validator는 @NotNull과 같은 애노테이션을 보고 검증을 수행한다. 검증을 해서 검증 오류가 발생하면 FieldError, ObjectError를 생성해서 BindingResult에 담아준다.
주의할 점은, 글로벌 Validator를 개발자가 직접 등록한 게 있다면 스프링 부트는 Bean Validator를 글로벌 Validator로 등록하지 않는다. 따라서 애노테이션 기반의 빈 검증기가 동작하지 않으니까 만약, 직접 아래와 같이 저번에 배웠던 것처럼 글로벌 Validator를 등록했다면 빈 검증기가 동작하지 않을 것이다.
@Override
public Validator getValidator() {
return new ItemValidator();
}
Bean Validator의 검증 로직
이렇게 간단하게 빈 검증기로 검증을 해봤는데, 이 검증 로직(?), 순서(?)는 어떤식으로 진행될까?
- @ModelAttribute 각각의 필드에 타입 변환 시도
- 사용자가 입력한 값을 해당하는 필드에 집어넣는다.
- 성공한 경우 다음으로 넘어간다.
- 실패한 경우 typeMismatch로 FieldError를 생성해서 BindingResult에 추가한다.
- Bean Validator를 적용한다.
- 위에 타입 바인딩을 했을 때 성공한 필드에만 검증을 시도한다.
- 생각해보면, 타입 자체가 맞지 않는데 추가적인 검증이 중요한게 아니라 사용자에게 먼저 타입부터 고치라는 말을 해줘야한다. 그래서 타입 바인딩을 실패하면 검증을 하지 않는다.
- 타입 바인딩에 성공한 필드는 검증을 시도한다.
- 예시) itemName에 문자 "A"를 입력 → 타입 변환 성공 → itemName 필드에 BeanValidation 적용
- 예시) price에 문자 "A"를 입력 → 타입 변환 시도 실패 → typeMismatch FieldError 추가 → price 필드는 BeanValidation 적용하지 않음
Bean Validator의 에러 코드
우선, 기본 에러 메시지를 다루는 방법은 이미 처리해봤다. 바로 아래처럼 애노테이션에 message 속성에 에러 메시지를 입력하면 된다.
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(message = "필수 필드입니다.")
private String itemName;
@NotNull(message = "필수 필드입니다.")
@Range(min = 1_000, max = 1_000_000, message = "가격의 범위는 1,000 - 1,000,000 사이여야 합니다.")
private Integer price;
@NotNull(message = "필수 필드입니다.")
@Max(value = 9999, message = "최대값은 9999입니다.")
private Integer quantity;
public Item() {
}
public Item(String itemName, Integer price, Integer quantity) {
this.itemName = itemName;
this.price = price;
this.quantity = quantity;
}
}
근데, Bean Validator가 검증 오류가 있으면 어디에 뭘 담는다고 했었나?! 바로 BindingResult에 FieldError 또는 ObjectError를 만들어서 넣어둔다고 했다. 그 말은 codes가 사용될 것이라는 얘기가 된다. 그래서 실제로 검증 오류를 발생시켜서 어떤 에러 코드를 만드는지 보자. 검증 오류가 있는 경우 로그를 찍어보면 다음과 같이 나온다.
Field error in object 'item' on field 'quantity': rejected value [null];
codes [NotNull.item.quantity,NotNull.quantity,NotNull.java.lang.Integer,NotNull];
arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [item.quantity,quantity]; arguments []; default message [quantity]]; default message [필수 필드입니다.]
- NotNull.item.quantity
- NotNull.quantity
- NotNull.java.lang.Integer
- NotNull
계속 봤던거랑 똑같다. 대신 애노테이션 이름으로 맨 앞을 채울 뿐이다. 그래서 Bean Validatior를 사용해서 애노테이션 기반으로 검증하고 에러 메시지를 유연하게 사용하려면 이 코드들로 에러 메시지를 만들면 된다.
errors.properties
NotBlank=공백일 수 없습니다.
Range={0}의 범위는 {2} ~ {1} 범위를 허용합니다.
Max={0}의 값은 최대 {1}까지 허용합니다.
- Bean Validation 사용시,
- {0}은 필드 이름으로 들어간다.
- Range에서 {1}은 최대값, {2} 최소값으로 들어간다.
- Max에서 {1}은 최대값으로 들어간다.
이렇게 만들어두고 실행해보자.
위에서 설정한 에러 메시지가 출력된 것을 볼 수 있다. 이게 다 Bean Validation으로 검증 처리를 하는 것도 결국 검증 오류가 생기면BindingResult를 사용하기 때문에 사용할 수 있는 편리함이다. BindingResult는 메시지를 출력하는 코드를 만들때 MessageCodesResolver를 사용하기 때문이고.
이걸 화면으로 뿌려주는 게 아니라 만약, API로 사용하는 거라고 해도 충분히 이것들을 사용해서 잘못된 데이터라는 응답을 보내줄 수 있다.
왜냐하면 BindingResult에 결국 모든게 다 담겨있기 때문이다. 아래 코드를 보자.
if (bindingResult.hasErrors()) { // 에러가 있는 경우, 다시 폼을 보여줌
for (FieldError fieldError : bindingResult.getFieldErrors()) {
log.debug("fieldError. field: {}", fieldError.getField());
log.debug("fieldError. rejectedValue: {}", fieldError.getRejectedValue());
log.debug("fieldError. message: {}", fieldError.getDefaultMessage());
log.debug("fieldError.getCode(): {}", fieldError.getCode());
}
}
- getFieldErrors() 메서드를 통해 모든 필드 에러를 전부 불러올 수 있다.
- 불러온 모든 FieldError의 정보엔 어떤 필드인지, 어떤값을 사용자가 입력했는지, 기본 에러 메시지는 무엇인지, 에러 코드는 무엇인지를 알 수 있다.
- 에러 코드를 안다면 에러 코드를 통해 Message를 불러올 수 있다. 다음 코드를 보자.
@Autowired
private MessageSource messageSource;
...
String message = messageSource.getMessage(Objects.requireNonNull(fieldError.getCode()), null, Locale.KOREA);
그럼 이 정보들을 통해 충분히 API 응답도 깔끔하게 내보낼 수 있을 것이다.
Bean Validation을 사용했을 때 ObjectError 처리
그럼 이제 필드 에러는 이렇게 깔끔하게 애노테이션으로 처리가 가능했다. 그럼 필드 에러 말고 우리가 했던것처럼 globalError는 어떻게 다루지?에 대한 의문이 남았다면 아주 정상이다. 생각해보면, price * quantity 가 10,000원이 넘어야 하는 어떤 필드 종속적 검증이 아닌 globalError 검증도 있었다. 그러나, 저런 필드가 없으니 애노테이션을 어디 달 곳이 애매하다.
이런 경우에 또 지원하는 방식이 있는데 @ScriptAssert 라는 애노테이션이다.
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
@ScriptAssert(lang = "javascript", script = "_this.price * _this.quantity >= 10000", message = "price * quantity의 결과는 10,000원 이상이어야 합니다.")
public class Item {
private Long id;
@NotBlank(message = "필수 필드입니다.")
private String itemName;
@NotNull(message = "필수 필드입니다.")
@Range(min = 1_000, max = 1_000_000, message = "가격의 범위는 1,000 - 1,000,000 사이여야 합니다.")
private Integer price;
@NotNull(message = "필수 필드입니다.")
@Max(value = 9999, message = "최대값은 9999입니다.")
private Integer quantity;
public Item() {
}
public Item(String itemName, Integer price, Integer quantity) {
this.itemName = itemName;
this.price = price;
this.quantity = quantity;
}
}
- 이렇게 클래스 레벨에 @ScriptAssert 라는 애노테이션을 붙여서 해결할 수도 있다.
- script, message만 원하는대로 수정하면 된다. message는 기본 에러 메시지를 의미한다.
- 이 ScriptAssert 검증으로부터 만들어지는 codes는 다음과 같다.
- ScriptAssert.item
- ScriptAssert
globalError가 잘 보여진다.
근데! 간단한 것 정도는 저렇게 해도 상관없을 듯 하지만 그냥 저렇게 하지말고 자바 코드로 푸는것을 추천한다. 실제로는 사용하기도 애매하고 굉장히 지저분하다. 그래서 그냥 저렇게 globalError가 필요한 경우에는 아래처럼 그냥 코드로 검증하는 방식으로 하자.
// 특정 필드가 아닌 복합 룰 검증
if (item.getPrice() != null && item.getQuantity() != null) {
int resultPrice = item.getPrice() * item.getQuantity();
if (resultPrice < 10_000) {
bindingResult.reject("totalPriceMin", new Object[]{10_000, resultPrice}, null);
}
}
현재까지 최종 코드
그래서 현재까지의 컨트롤러, Item, errors.properties의 최종 코드는 이렇게 되면 될 것 같다.
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(message = "필수 필드입니다.")
private String itemName;
@NotNull(message = "필수 필드입니다.")
@Range(min = 1_000, max = 1_000_000, message = "가격의 범위는 1,000 - 1,000,000 사이여야 합니다.")
private Integer price;
@NotNull(message = "필수 필드입니다.")
@Max(value = 9999, message = "최대값은 9999입니다.")
private Integer quantity;
public Item() {
}
public Item(String itemName, Integer price, Integer quantity) {
this.itemName = itemName;
this.price = price;
this.quantity = quantity;
}
}
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 (item.getPrice() != null && item.getQuantity() != null) {
int resultPrice = item.getPrice() * item.getQuantity();
if (resultPrice < 10_000) {
bindingResult.reject("totalPriceMin", new Object[]{10_000, resultPrice}, null);
}
}
if (bindingResult.hasErrors()) { // 에러가 있는 경우, 다시 폼을 보여줌
log.debug("[addItemV2] bindingResult 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}";
}
}
errors.properties
totalPriceMin=가격 * 수량의 합은 {0}원 이상이어야 합니다. 현재 값 = {1}
typeMismatch.java.lang.Integer=숫자 필드입니다.
typeMismatch.java.lang.String=문자 필드입니다.
typeMismatch=타입 오류입니다.
NotNull=필수 필드입니다.
NotBlank=공백일 수 없습니다.
Range={0}의 범위는 {2} ~ {1} 범위를 허용합니다.
Max={0}의 값은 최대 {1}까지 허용합니다.
Bean Validation의 한계
이렇게 편리하고 좋은 기능도 한계점이란 게 존재한다. 어떤 문제가 있을까?
만약, 이런 요구사항이 들어왔다고 해보자.
생성 폼 검증 요구사항
- 수량의 최대값은 9999
수정 폼 검증 요구사항
- 수량의 최대값을 두지 않는다.
그러면 생성과 수정 시 검증 요구사항이 다르기 때문에 같은 Item 객체를 사용하는 순간에는 이 애노테이션을 구분하기가 굉장히 껄끄럽다. 사실 구분할 수 없다. 아래 코드가 Item 객체인데 생성폼, 수정폼 둘 다 이 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(message = "필수 필드입니다.")
private String itemName;
@NotNull(message = "필수 필드입니다.")
@Range(min = 1_000, max = 1_000_000, message = "가격의 범위는 1,000 - 1,000,000 사이여야 합니다.")
private Integer price;
@NotNull(message = "필수 필드입니다.")
@Max(value = 9999, message = "최대값은 9999입니다.")
private Integer quantity;
public Item() {
}
public Item(String itemName, Integer price, Integer quantity) {
this.itemName = itemName;
this.price = price;
this.quantity = quantity;
}
}
Bean Validation 한계 돌파
문제를 해결할 수 있는 대표적인 두 가지 방법이 있다.
- Bean Validation의 groups 기능
- Item 객체를 분리 (예: SaveItemDto, UpdateItemDto)
Bean Validation의 groups
이런 문제를 해결하기 위해 Bean Validation은 groups 라는 기능을 지원한다. 코드로 보면 바로 이해가 되니까 코드로 바로 시작하자.
우선, 그룹을 지정하기 위한 인터페이스가 필요하다. 생성 용 그룹 인터페이스와 업데이트 용 그룹 인터페이스 두개를 만들자.
SaveCheck
package hello.itemservice.domain.item;
public interface SaveCheck {
}
UpdateCheck
package hello.itemservice.domain.item;
public interface UpdateCheck {
}
그리고 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 {
@NotNull(groups = UpdateCheck.class)
private Long id;
@NotBlank(message = "필수 필드입니다.", groups = {SaveCheck.class, UpdateCheck.class})
private String itemName;
@NotNull(message = "필수 필드입니다.", groups = {SaveCheck.class, UpdateCheck.class})
@Range(min = 1_000, max = 1_000_000, message = "가격의 범위는 1,000 - 1,000,000 사이여야 합니다.", groups = {SaveCheck.class, UpdateCheck.class})
private Integer price;
@NotNull(message = "필수 필드입니다.", groups = {SaveCheck.class, UpdateCheck.class})
@Max(value = 9999, message = "최대값은 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;
}
}
- id의 @NotNull은 그룹으로 UpdateCheck.class를 지정했다. 이렇게 되면 업데이트 할 때만 이 검증이 요구된다.
- itemName의 @NotBlank는 그룹으로 SaveCheck.class, UpdateCheck.class둘 다 지정했다. 이렇게 되면 생성과 업데이트 시 모두 검증이 요구된다.
- price는 @NotNull, @Range 모두 그룹으로 SaveCheck.class, UpdateCheck.class둘 다 지정했다. 이렇게 되면 생성과 업데이트 시 모두 검증이 요구된다.
- quantity는 @NotNull은 그룹으로 SaveCheck.class, UpdateCheck.class둘 다 지정했다. 그리고 @Max는 그룹으로 SaveCheck.class만 지정했다.
이렇게 각 검증 요구사항을 그룹으로 지정해 놓은 다음 컨트롤러에서 검증을 하겠다는 의미를 가지는 @Validated에 그룹을 지정해주면 된다.
@PostMapping("/add")
public String addItem(@Validated(value = SaveCheck.class) @ModelAttribute Item item,
BindingResult bindingResult,
RedirectAttributes redirectAttributes) {
...
}
@PostMapping("/{itemId}/edit")
public String edit(@PathVariable Long itemId,
@Validated(value = UpdateCheck.class) @ModelAttribute Item item,
BindingResult bindingResult) {
...
}
- 아이템을 생성하는 addItem 메서드의 @Validated는 SaveCheck.class를 지정했다. 생성 시 검증은 이 SaveCheck가 그룹으로 할당된 애노테이션만 적용된다는 의미이다.
- 아이템을 수정하는 edit 메서드의 @Validated는 UpdateCheck.class를 지정했다. 업데이트 시 검증은 이 UpdateCheck가 그룹으로 할당된 애노테이션만 적용된다는 의미이다.
이렇게 적용하면 생성과 업데이트 간 같은 객체를 사용하면서 검증 처리는 분리할 수 있다.
그런데, 생각보다 불편하기도 하고 복잡하다. 우선 그룹을 지정하기 위해 인터페이스를 만드는 것부터 시작해서 Item 객체를 보면 꽤나 지저분해 진 모습이 보일것이다. 그래서 이 방식은 잘 사용하지 않는다. 위의 이유도 있지만 더 큰 이유는 실제 업무 세상에서는 생성 폼과 업데이트 폼은 매우 다르다. 일반적으로 유명한 사이트의 회원 가입 시 폼과 회원 수정 시 폼을 생각해보라.
그래서 실제 업무 세상에서는 DTO 객체를 분리하여 이 문제를 처리한다. 사실 분리하면 문제를 처리하는 게 아니라 문제가 그냥 없어진다.
Item 객체를 ItemSaveForm, ItemUpdateForm으로 분리
우선, 이제 더 이상 Item 객체로 검증을 하지 않을거니까, 검증 관련 애노테이션을 전부 주석처리 하거나 없애자.
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 {
private Long id;
private String itemName;
private Integer price;
private Integer quantity;
public Item() {
}
public Item(String itemName, Integer price, Integer quantity) {
this.itemName = itemName;
this.price = price;
this.quantity = quantity;
}
}
그리고, 이제 생성과 업데이트 시 DTO로 사용할 클래스 두 개를 만들자.
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(value = 9999)
private Integer quantity;
}
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;
}
- 생성시에는 quantity 필드에 검증을 하지만 업데이트 시에는 quantity에 아무런 검증을 하지 않는것을 볼 수 있다.
- id도 생성시에는 아예 필드조차 없지만, 업데이트 시에는 @NotNull 애노테이션이 붙어있다.
이 객체를 가지고 컨트롤러에 적용해보자. 먼저 생성 시 메서드이다.
@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 = 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}";
}
- @ModelAttribute 애노테이션의 타입을 ItemSaveForm으로 변경했다. 그래서 이제 사용자가 입력한 값은 이 객체로 변환될 것이다. 그리고 주의할 점이 있다. @ModelAttribute에 어떠한 값도 주지 않으면 Model에 담기는 키가 기본으로 타입의 첫글자를 소문자로 바꾼 `itemSaveForm`이 될 것이다. 근데 우리의 뷰 템플릿은 item으로 받고 있으니 `item`이라고 직접 명시해주자.
다음은 업데이트 시 메서드이다.
@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 애노테이션의 타입을 ItemUpdateForm으로 변경했다. 그래서 이제 사용자가 입력한 값은 이 객체로 변환될 것이다. 마찬가지로 @ModelAttribute의 속성값을 `item`으로 지정한다.
이렇게 생성과 업데이트에 사용되는 DTO를 분리하므로써 더 간결하고 더 명확하게 검증도 나눌 수 있다. 그리고 검증을 하고 말고를 떠나서 실무에서는 항상 이런식으로 DTO가 분리된다. 생성 시 필요한 데이터와 업데이트 시 필요한 데이터가 거의 100% 다르기 때문에.
이렇게 Bean Validation의 한계를 두 가지 방법으로 해결해 보았다.
HTTP API일 때 Bean Validation 처리
개발을 하다보면 앞단 처리를 할 때도 있지만 API 통신을 할 때가 거의 대부분이다. 그리고 그 때도 물론 당연히 검증처리는 되어야 한다.
그리고 스프링은 완전 비슷하게 검증 처리를 할 수 있다.
다시 이전 포스팅을 복기해보면, @Controller가 붙은 클래스 메서드들 중 @ResponseBody가 붙은 메서드는 HTTP API로 통신한다고 했다. 그리고 이 둘을 합친것이 @RestController라고 했다. 그래서 이 경우 어떻게 검증하는지 확인해보자.
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(@Validated @RequestBody ItemSaveForm itemSaveForm, BindingResult bindingResult) {
log.info("API 컨트롤러 호출");
if (bindingResult.hasErrors()) {
log.info("검증 오류 발생 errors = {}", bindingResult);
return bindingResult.getAllErrors();
}
log.info("성공 로직 실행");
return itemSaveForm;
}
}
- REST API로 요청 바디에 있는 값을 객체로 바로 컨버팅 할 수 있다고 했고 그게 바로 HTTP 메시지 컨버터가 해주는 것이라고 했다. 그리고 컨버터가 인지할 수 있도록 파라미터로 @RequestBody ItemSaveForm itemSaveForm이라고 @RequestBody 애노테이션을 붙여주면 된다. 그럼 사용자가 입력한 요청 바디값이 ItemSaveForm 객체로 변환될 것이다.
- 그리고 그 이후부터는 기존에 하던 방식과 똑같다. 검증하고자 하는 객체에 @Validated 애노테이션을 붙이고, 그 바로 뒤에는 BindingResult를 넣으면 된다.
- HTTP API도 검증을 이런식으로 할 수 있음을 보여주기 위한 예시 코드라 리턴값은 아무런 의미를 두지 말자. 검증 오류가 나면 검증 오류 결과가 반환되고, 검증 오류가 없으면 그냥 사용자가 던진 데이터를 그대로 돌려받는다.
정상 응답 결과
검증 오류 결과
- BindingResult가 가지고 있는 오류 내용을 그대로 반환하고 있다. 실제로는 이대로 반환하면 절대 안된다.
메시지 컨버터가 컨버팅을 실패하는 결과
- 여기서는 검증 오류에 대한 내용이 나오지 않고, 뭔가 스프링에서 에러를 그냥 던진것 같은 응답이 돌아왔다. 맞다. 이 경우 컨트롤러가 실행조차 되지 않았다.
- 그 이유는, 사용자가 입력한 값이 HTTP 메시지 컨버터에 의해 객체로 컨버팅될 수가 없기 때문이다.
- JSON 데이터는 필드 하나하나가 아니라 저 `{...}` 이 한뭉텅이가 곧 하나다. 그래서 그 데이터가 변환하려고 하는 객체에 일치하지 않는 부분이 있으면 안된다. 그래서 아예 변환 과정에서 실패했기 때문에 컨트롤러가 호출도 되지 않았고, 검증 자체도 실행되지 않았다.
- 즉, HttpMessageConverter 단계에서 실패하면 위처럼 예외가 발생한다.예외 발생 시 원하는 모양으로 예외를 처리하는 방법은 이후에 자세히 배워보자!
"어? 근데, API가 아니라 뷰 레이어로 검증할때는 타입이 맞지 않아도 컨트롤러도 잘 호출되고 검증도 잘 됐잖아요!?"
→ 맞다. @ModelAttribute랑 @RequestBody는 처리 방식이 좀 다르다. HTTP 요청 파라미터(QueryString, Form Data)를 처리하는 @ModelAttribute는 각각의 필드 단위로 세밀하게 적용된다. 그래서 특정 필드에 타입이 맞지 않는 오류가 발생해도 나머지 필드는 정상 처리할 수 있었다.HttpMessageConverter는 @ModelAttribute와 다르게 각각의 필드 단위로 적용되는 것이 아니라, 전체 객체 단위로 적용된다. 따라서 메시지 컨버터의 작동이 성공해서 ItemSaveForm 객체를 만들어야 @Validated가 적용된다. 그래서 @ModelAttribute는 Bean Validation을 적용하면 바인딩 된 필드만 검증 처리가 실행되고 바인딩부터 실패한 필드는 검증을 그냥 무시해버리고 바로 typeMismatch로 필드에러 생성 후 넘어가 버린다고 했다. @RequestBody는 메시지 컨버터에 의해 객체를 만들어 내지 못하면 아예 그냥 컨트롤러도 호출되지 않고 @Validated도 적용할 수 없다.
'Spring MVC' 카테고리의 다른 글
로그인 처리2 - Servlet Filter, Spring MVC Interceptor (0) | 2024.09.04 |
---|---|
로그인 처리 (0) | 2024.09.03 |
데이터 검증 (BindingResult, Validator, @Validated) (0) | 2024.08.31 |
스프링의 메시지, 국제화 (2) | 2024.08.30 |
Spring MVC 사용하기 Part.3 (0) | 2024.08.02 |