[Spring/Spring Boot] Validation을 위한 @Validated 사용 방법 및 원리 이해하기(Bean Validation, BindingResult)
서비스를 개발할 때, 정상적인 비즈니스 로직을 작성하는 것만큼이나 중요한 부분이 요청 데이터 검증이다. 클라이언트들은 항상 다양하고 창의적인 방법으로 데이터를 입력해 서버로 요청하기 때문에 개발자 입장에서 이를 다 예측하고 걸러내는 작업이 쉽지 않다. 정말 사소한 오류들도 있겠지만, 몇몇 제대로 검증하지 못한 요청 데이터들이 결국 서버 오류로 이어지면, 클라이언트에게 불편한 사용감을 주게 되어 소중한 고객을 잃을 수 있다. 따라서 보통 정상적인 로직보다 검증을 위한 코드가 더 복잡하고, 이를 작성하는 시간이 더 많다고 한다.
검증은 보통 다양한 계층에서 이루어질 수 있다. 크게는 클라이언트와 서버로 나누어 볼 수 있다.
- 클라이언트 검증 : 사용자의 입력 폼(form)에서 HTML, Javascript로 실시간 검증
- 서버 검증 : 클라이언트의 HTTP 요청 데이터를 비즈니스 로직 수행 전에 검증 후 오류가 발생하면 적절한 오류 코드 응답
클라이언트 검증은 잘못된 입력이 곧바로 표시되므로, 사용자에게 편리한 UX를 선사해 줄 수 있다. 하지만 HTML이나 Javascript 코드는 마음만 먹으면 조작할 수 있으므로 신뢰하기 어렵다. 때문에 서버에서 2차 검증을 필수적으로 수행해야 한다.
보통 처음 개발을 공부하는 초보 개발자 입장에서, 입력 데이터를 검증해야겠다고 생각하면 여러 개의 if
문을 넣어서 요구사항에 맞게 데이터들을 걸러내는 로직을 떠올릴 것이다. 처음에는 그런 식의 검증이 간단하고 직관적일 수 있지만, 점점 서비스가 커지고 검증해야 하는 부분들이 많아지면 바로 어려움을 느낄 것이다. 그렇다면, 스프링 개발자들은 어떤 방식으로 검증을 최적화했고, 어떻게 수행되는지 알아보자.
애노테이션을 사용한 Bean Validation
먼저 상품 관리 시스템이라는 서비스를 개발하고 있는데 다음과 같은 요구사항이 있다고 해보자.
- 아이템(Item) 도메인
- 아이템 이름(
itemName
) : 필수, 공백X - 가격(
price
) : 필수, 1000원 이상, 1백만원 이하 - 수량(
quantity
) : 필수, 최대 9999
- 아이템 이름(
Item
클래스는 아래와 같이 구현되어 있다.
package hello.itemservice.domain.item;
import lombok.Data;
@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;
}
}
이제 클라이언트의 요청을 받는 컨트롤러에서 검증 로직을 추가해야 된다고 생각할 것이다. 하지만 스프링에서는 애노테이션을 사용해 item
클래스에서 간편하게 검증로직을 추가할 수 있다.
이를 위해 build.gradle
에서 의존 관계를 추가해야 한다(스프링 부트를 사용하고 있다는 가정).
dependencies {
// 추가
implementation 'org.springframework.boot:spring-boot-starter-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 = 1_000_000)
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;
}
}
코드를 보면 필드에 애노테이션들이 추가됐다. 위 임포트된 패키지를 자세히 보면 @NotNull
, @NotBlack
, @Max
같은 애노테이션들은 javax.validation.constraints
패키지에 속해있다. 사실 이 애노테이션들은 Bean Validation 2.0(JSR-380)이라는 기술 표준으로, 내부를 열어보면 다 인터페이스로 작성되어 있다.
보통 구현체로는 하이버네이트(Hibernate) Validator를 사용한다. 스프링 부트에서도 spring-boot-starter-validation
으로 의존 관계를 추가하면, 하이버네이트 구현체를 자동으로 추가해 준다.
@Range
같은 애노테이션은 하이버네이트 구현체에서만 제공하기 때문에 자바 표준 패키지가 아니다. 아래는 최신 스펙(9.0)의 하이버네이트 공식 문서로, 어떤 검증 애노테이션들이 있는지 참고하면 좋다.
빈 검증기(Bean Validator)
이제 위 Item
클래스가 검증이 되는지 테스트 코드를 작성해서 확인해 보자.
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;
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이 자바 표준 기술이므로 스프링 없이도 검증이 가능하다는 것을 보여주기 위한 테스트 코드다. 가볍게 보고 넘어가겠다.
ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
Validator validator = factory.getValidator();
위 코드는 ValidatorFactory
에서 검증기인 Validator
를 꺼내는 코드다. Validator
는 앞서 언급한 자바의 표준 인터페이스로 아래와 같이 정의되어 있다.
package javax.validation;
import java.util.Set;
import javax.validation.executable.ExecutableValidator;
import javax.validation.metadata.BeanDescriptor;
public interface Validator {
<T> Set<ConstraintViolation<T>> validate(T var1, Class<?>... var2);
<T> Set<ConstraintViolation<T>> validateProperty(T var1, String var2, Class<?>... var3);
<T> Set<ConstraintViolation<T>> validateValue(Class<T> var1, String var2, Object var3, Class<?>... var4);
BeanDescriptor getConstraintsForClass(Class<?> var1);
<T> T unwrap(Class<T> var1);
ExecutableValidator forExecutables();
}
Item
객체를 validate()
메서드의 매개변수 값으로 넣어주면, 검증 결과인 ConstraintViolation
컬렉션을 반환해 준다. ConstraintViolation
역시 표준 인터페이스로 정의되어 있으며, 검증 오류를 담고 있는 객체라고 생각하면 된다. 결과가 비어있으면 검증 오류가 없는 것이다.
검증 오류가 발생하도록 테스트 코드를 작성했기 때문에 출력 결과를 확인해 보자.
violation=ConstraintViolationImpl{interpolatedMessage='1000에서 1000000 사이여야 합니다', propertyPath=price, rootBeanClass=class hello.itemservice.domain.item.Item, messageTemplate='{org.hibernate.validator.constraints.Range.message}'}
violation.message=1000에서 1000000 사이여야 합니다
violation=ConstraintViolationImpl{interpolatedMessage='공백일 수 없습니다', propertyPath=itemName, rootBeanClass=class hello.itemservice.domain.item.Item, messageTemplate='{javax.validation.constraints.NotBlank.message}'}
violation.message=공백일 수 없습니다
violation=ConstraintViolationImpl{interpolatedMessage='9999 이하여야 합니다', propertyPath=quantity, rootBeanClass=class hello.itemservice.domain.item.Item, messageTemplate='{javax.validation.constraints.Max.message}'}
violation.message=9999 이하여야 합니다
violation
구현체로 ConstraintViolationImpl
타입 객체가 총 3개 담겨 있다. 이는 3개의 검증 오류가 발생했다는 의미다. 테스트 케이스로 생성한 Item
객체의 필드 값들을 직접 검증해 보면 이해할 수 있을 것이다.
ConstraintViolationImpl
클래스는 ConstraintViolation
를 구현한 하이버네이트 구현체로 검증 오류 결과에 관련된 필드 및 메서드들이 구현되어 있다. 우리가 지금 주목할건 이 검증 오류 객체가 어떤 메세지를 생성해 줬다는 것이다. 어떤 방식인지는 모르겠지만, 클래스에 작성한 검증 애노테이션의 특징에 맞게 필드 값들을 가지고 디폴트 메세지를 생성해 줬다. 지금은 이 부분만 기억하고 넘어가면 된다(이 부분에 대한 내용이 궁금하면, ‘스프링의 메세지 기능’이라는 키워드와 함께 검색해 보기를 바란다).
이제 스프링에서 이 빈 검증기(Bean Validator)를 어떻게 사용하는지 알아보자.
@Validated
우리는 앞서 Bean Validator를 사용하기 위해 스프링 부트에서 의존성을 추가했다. 그러면 스프링 부트는 LocalValidatorFactoryBean
을 글로벌 Validator로 등록한다.
이 클래스는 스프링의 org.springframework.validation.beanvalidation
패키지에 속한 Bean Validator로, 의존 관계 다이어그램을 보면 Validator
를 구현한 구현체라는 것을 알 수 있다. 이렇게 글로벌 Validator가 적용되어 있기 때문에, 해당 프로젝트 내의 어떤 클래스에서도 @NotNull
같은 애노테이션을 보고 검증을 수행할 수 있다.
예를 들어, 위와 같이 Item
클래스를 작성한 뒤, 아이템을 추가하는 컨트롤러를 구현한다고 해보자.
- 클라이언트가 HTML Form에 데이터를 작성 후 아이템 추가 요청
- 요청 파라미터를 검증한 후, 검증에 이상이 없으면 아이템 상세 페이지로 리다이렉트
클라이언트가 아이템을 추가하는 HTML Form에 작성한 데이터가 요청 파라미터로 전달될 때, 이 Item
객체를 검증하고 싶으면 아래와 같이 작성해 주면 된다(뷰 관련 코드는 예시를 간단히 하기 위해 생략하겠다).
@Controller
@RequestMapping("/items")
@RequiredArgsConstructor
public class ItemController {
private final ItemRepository itemRepository;
@GetMapping("/{itemId}")
public String item(@PathVariable long itemId, Model model) {
Item item = itemRepository.findById(itemId);
model.addAttribute("item", item);
return "item";
}
@PostMapping("/add")
public String addItem(@Validated @ModelAttribute Item item, RedirectAttributes redirectAttributes) {
// 검증 성공
Item savedItem = itemRepository.save(item);
redirectAttributes.addAttribute("itemId", savedItem.getId());
return "redirect:/items/{itemId}";
}
}
컨트롤러 메서드 파라미터 변수 중, 검증하고 싶은 객체 앞에 @Valid
또는 @Validated
만 붙여 주면 된다(단, 해당 객체는 스프링 빈으로 등록되어 있어야 함).
실제로 검증이 잘 되는지 Postman을 사용해 테스트 해보자.
위와 같이, itemName
필드를 비워두고 요청을 보내면 400 Bad Request를 서버에서 응답한다. 그리고 서버 콘솔에도 WARN
로그가 찍힌다.
2024-12-13 22:56:23.118 WARN 49248 --- [nio-8080-exec-5] .w.s.m.s.DefaultHandlerExceptionResolver : Resolved [org.springframework.validation.BindException: org.springframework.validation.BeanPropertyBindingResult: 1 errors
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 [공백일 수 없습니다]]
BindException
예외가 발생하는 것을 알 수 있고, BeanPropertyBindingResult
타입의 객체 안에 1개의 에러가 발생했다고 알려 준다. 아래를 보면, itemName
필드에 Field error가 발생했는데, 우리는 이를 이용해 따로 예외 처리를 한 뒤, 사용자에게 검증이 실패했다는 응답을 해주면 된다(이 부분에 대해서는 뒤에서 자세히 다루겠다).
BindingResult
스프링이 제공하는 검증 오류를 보관하는 인터페이스로 org.springframework.validation.Errors
인터페이스를 확장하고 있으며, 구현체는 위에서 봤던 BeanPropertyBindingResult
다.
위 예제에서는, 검증 오류가 발생하면 컨트롤러가 호출되지 않고 예외를 던져 버린다. 하지만 BindingResult
가 있으면, @ModelAttribute
에 데이터 바인딩 시 오류가 발생하더라도, FieldError
라는 객체를 생성해서 BindingResult
에 넣어준 다음 컨트롤러가 정상적으로 호출된다. 특히 애노테이션으로 추가한 검증 이외에 타입 불일치같은 기본적인 검증 오류도 처리해 준다.
위 예시 코드에 BindingResult
를 추가해 보자. BindingResult
는 검증할 대상 바로 다음에 와야한다.
@PostMapping("/add")
public String addItem(@Validated @ModelAttribute Item item,
BindingResult bindingResult,
RedirectAttributes redirectAttributes) {
log.info("BindingResult = {}", bindingResult);
// 검증 성공
Item savedItem = itemRepository.save(item);
redirectAttributes.addAttribute("itemId", savedItem.getId());
return "redirect:/items/{itemId}";
}
Item
파라미터 객체 바로 뒤에, BindingResult
룰 추가해 줬다. 그런 다음, 검증 오류를 발생시키는 요청을 보내볼 건데, bindingResult
에 어떤 값들이 담기는 지 확인하기 위해 로그를 찍어 보자.
결과를 보면, 이전에는 400 Bad Request 오류 응답이 왔는데, 이번에는 200 OK로 정상적인 HTML 페이지가 응답됐다. 필드 값을 보자.
- 상품 명 : 공백
- 가격 : 10 (1000 미만이므로 검증 오류)
- 수량 : 공백 (정수 타입에 문자 aaa를 넣어 요청했기 때문에 타입 검증 오류로 null이 들어감)
이는 정상적으로 컨트롤러가 호출됐기 때문에 오류 처리 로직이 없어서 검증 성공 코드가 그대로 실행됐다. 따라서 잘못 입력된 값들이 그대로 페이지에 노출된 것이다.
로그를 확인해 보자.
BindingResult = org.springframework.validation.BeanPropertyBindingResult: 3 errors
Field error in object 'item' on field 'quantity': rejected value [aaa]; codes [typeMismatch.item.quantity,typeMismatch.quantity,typeMismatch.java.lang.Integer,typeMismatch]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [item.quantity,quantity]; arguments []; default message [quantity]]; default message [Failed to convert property value of type 'java.lang.String' to required type 'java.lang.Integer' for property 'quantity'; nested exception is java.lang.NumberFormatException: For input string: "aaa"]
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 [공백일 수 없습니다]
Field error in object 'item' on field 'price': rejected value [10]; codes [Range.item.price,Range.price,Range.java.lang.Integer,Range]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [item.price,price]; arguments []; default message [price],1000000,1000]; default message [1000에서 1000000 사이여야 합니다]
구현체인 BeanPropertyBindingResult
에 3개의 Filed error가 담겼다. 이는 현재 3번의 필드 검증에서 오류가 발생했기 때문에 bindingResult
안에 3개의 FieldError
객체가 담긴 것이다. 지금은 codes에 담긴 값들이 무엇을 의미하는지 모르겠지만, 우리가 검증 애노테이션으로 작성한 NotBlank
와 Range
가 보일 것이다. typeMismatch
는 스프링에서 타입 일치 여부를 검증한 결과로 발생한 오류다.
bindingResult
의 메서드를 활용해 간단한 오류 처리 로직을 추가해 보자.
@PostMapping("/add")
public String addItem(@Validated @ModelAttribute Item item,
BindingResult bindingResult,
RedirectAttributes redirectAttributes) {
// 검증 실패
if (bindingResult.hasErrors()) {
log.info("BindingResult = {}", bindingResult);
return "redirect:/items/error";
}
// 검증 성공
Item savedItem = itemRepository.save(item);
redirectAttributes.addAttribute("itemId", savedItem.getId());
return "redirect:/items/{itemId}";
}
hasErrors()
메서드는 bindingResult
안에 검증 실패로 인한 오류 객체가 담기면 true
를 반환한다.
이번에는 검증 실패 로직이 실행되어, 에러 페이지로 리다이렉트된 결과를 확인할 수 있다.
이처럼 Bean Validator에 의해 검증 오류가 발생하면, bindingResult
에 2종류의 오류 정보를 담은 객체가 담긴다. 하나는 앞서 봤던 FieldError
객체고, 나머지 하나는 글로벌 오류 정보가 담긴 ObjectError
다.
FieldError
FieldError
는 이름 그대로 필드 검증에서 발생한 오류에 대한 정보가 구현된 클래스다. 뒤에서 설명할 ObjectError
클래스를 확장하고 있다.
FieldError
객체를 생성하기 위해 필요한 파라미터 목록이다.
objectName
: 오류가 발생한 객체 이름field
: 오류 필드rejectedValue
: 사용자가 입력한 값(거절된 값)bindingFailure
: 타입 오류 같은 바인딩 실패인지, 검증 실패인지 구분 값codes
: 메시지 오류 코드arguments
: 메시지에서 사용하는 인자defaultMessage
: 기본 오류 메시지
원래는 if
문으로 직접 검증 로직을 작성한 뒤, 검증이 실패하면 FieldError
를 직접 생성해 bindingResult
에 추가해 줘야 한다. 그래야 뷰 템플릿과의 연계나 타입 오류 처리 등, bindingResult
가 제공하는 장점을 활용할 수 있다. 하지만 Bean Validation 기술을 사용하면, 직접 오류 객체를 생성해 추가하는 번거로운 작업을 내부적으로 알아서 해주기 때문에 편리하다.
그래도 필드 오류 객체가 이런 값들을 가지고 있다는 것을 알면, 오류 처리를 할 때 도움이 된다. 예를 들어, rejectedValue
라는 필드에는 사용자가 이전에 입력한 값을 가지고 있어, 어떤 값을 입력해서 요청이 거절됐는지 사용자에게 보여줄 수 있다. 또한 메세지와 관련된 필드로 보다 친절한 오류 메세지를 계층별로 관리할 수 있다.
ObjectError
필드 검증이 아닌 여러 필드를 동시에 검증하거나 다른 클래스의 필드를 함께 검증해야 할 때 생성되는 글로벌 오류 객체다. 예를 들어서 다음 검증 요구사항이 추가됐다고 해보자.
- 총 가격(수량 * 가격)이 10,000원 이상일 때만 아이템을 추가할 수 있음
위 검증은 두 개의 필드를 동시에 검증해야 한다. 이럴 때는 검증 애노테이션이 아닌 검증 로직을 직접 작성한 뒤, ObjectError
객체를 생성해 bindingResult
에 직접 추가해 준다.
@PostMapping("/add")
public String addItem(@Validated @ModelAttribute Item item,
BindingResult bindingResult,
RedirectAttributes redirectAttributes) {
// 글로벌 검증 추가
if (item.getPrice() != null && item.getQuantity() != null) {
int totalPrice = item.getPrice() * item.getQuantity();
if (totalPrice < 10_000) {
bindingResult.addError(new ObjectError("item", "총 가격이 10,000원 이상이어야 합니다."));
}
}
// 검증 실패
if (bindingResult.hasErrors()) {
log.info("BindingResult = {}", bindingResult);
return "redirect:/items/error";
}
// 검증 성공
Item savedItem = itemRepository.save(item);
redirectAttributes.addAttribute("itemId", savedItem.getId());
return "redirect:/items/{itemId}";
}
생성자 안에 objectName
, defaultMessage
만 간단하게 넣어 ObjectError
객체를 생성한 다음 addError()
메서드로 추가해 줬다.
로그를 보면 Field error가 아닌 Error가 추가된 걸 확인할 수 있다.
BindingResult = org.springframework.validation.BeanPropertyBindingResult: 1 errors
Error in object 'item': codes []; arguments []; default message [총 가격이 10,000원 이상이어야 합니다.]
💡글로벌 검증 애노테이션으로 @ScriptAssert()라는 기능이 있지만, 생각보다 제약이 많고 복잡하기 때문에 잘 사용하지 않는다고 한다.
검증 순서
Bean Validator가 검증하는 순서를 유의할 필요가 있다. 먼저 각각의 필드에 타입 변환 시도를 먼저 진행한다.
- 성공하면 다음으로
- 실패하면
typeMismatch
로FieldError
추가
이러한 과정을 필드마다 수행하게 되고, 이후 글로벌 검증 로직이 있다면 진행한다. 하지만 검증할 파라미터 객체가 @ModelAttribute
가 아니라 @RequestBody
일 경우에는 조금 다르다.
@RequestBody
는 HTTP Message Body에 입력된 JSON 데이터를 파싱한 뒤, key와 객체의 필드 이름을 매칭시켜 값을 바인딩 하게 된다. 이 작업을 HttpMessageConverter
가 수행하는데, 만약 타입이 맞지 않아 바인딩이 실패하면, HttpMessageConverter
가 예외를 던지게 되고 컨트롤러가 호출되지 않는다.
@RestController
@RequestMapping("/items")
@RequiredArgsConstructor
@Slf4j
public class ItemRestController {
private final ItemRepository itemRepository;
@PostMapping("/add")
public Object addItem(@Validated @RequestBody Item item, BindingResult bindingResult) {
// 검증 실패
if (bindingResult.hasErrors()) {
log.info("BindingResult = {}", bindingResult);
return bindingResult.getAllErrors();
}
// 검증 성공
return itemRepository.save(item);
}
}
getAllErrors()
메서드는 bindingResult
에 담긴 모든 오류 객체들을 반환해 준다. 먼저 검증이 잘 되는지부터 확인해 보자.
price
의 값이 1000 이히이므로, 검증이 실패한 것을 확인할 수 있다. 이번에는 타입에 맞지 않는 값을 넣어 바인딩이 실패하도록 해보자.
컨트롤러가 정상적으로 호출되지 않고 400 Bad Request 오류를 응답한다. 로그를 확인해 보면, HttpMessageNotReadableException
예외를 던진다.
WARN 32861 --- [nio-8080-exec-2] .w.s.m.s.DefaultHandlerExceptionResolver : Resolved [org.springframework.http.converter.HttpMessageNotReadableException: JSON parse error: Cannot deserialize value of type `java.lang.Integer` from String "AA": not a valid Integer value; nested exception is com.fasterxml.jackson.databind.exc.InvalidFormatException: Cannot deserialize value of type `java.lang.Integer` from String "AA": not a valid Integer value
at [Source: (PushbackInputStream); line: 3, column: 15] (through reference chain: hello.itemservice.domain.item.Item["price"])]
JSON 데이터를 자바의 객체로 변환하지 못했기 때문에 컨트롤러도 호출되지 않고, Validator도 적용할 수 없는 것이다. 즉, 객체 단위로 검증이 적용된다고 생각하면 이해가 쉽다.
그에 반해, @ModelAttribute
는 필드 단위로 정교하게 바인딩이 적용된다. 특정 필드가 바인딩 되지 않아도 나머지 필드는 정상 바인딩 되고, 이 바인딩 된 필드들은 Validator를 사용한 검증도 적용된다.
Bean Validation의 한계
만약 아이템을 추가할 때와 수정할 때 검증 방법이 달라지면 어떻게 해야 할까?
다음과 같은 요구사항이 추가됐다고 해보자.
- 아이템 수정 시
- 아이디 값은 필수로 포함
- 수량 제한을 풀어줌
위 요구사항을 그대로 Item
클래스에 적용시켜 버리면, 아이템 추가를 할 때 충돌이 일어나 잘못된 검증이 이루어진다. 예를 들어, 아이템을 추가할 때는 클라이언트 측에서 아이템 아이디 값을 알 수 없기 때문에 넣어줄 수 없다. 또한, 수량 제한에 대한 조건도 겹친다.
이럴 때는 @Validated
에서 제공하는 groups 기능을 활용할 수 있다.
Groups 기능으로 극복하기
검증 애노테이션에는 groups
라는 클래스 타입의 필드 값을 여러 개 추가할 수 있다. 이 클래스 타입으로 추가할 때 검증하고 싶은 그룹과, 수정할 때 검증하고 싶은 싶은 그룹을 지정할 수 있다. 이 그룹 클래스는 별 다른 기능 없이 그냥 의미 있는 이름을 담은 인터페이스로 선언하면 된다.
package hello.itemservice.domain.item;
public interface SaveCheck {
}
package hello.itemservice.domain.item;
public interface UpdateCheck {
}
이제 검증 애노테이션에 요구사항대로 검증 그룹을 만들어 보자.
@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 = 1_000_000, groups = {SaveCheck.class, UpdateCheck.class})
private Integer price;
@NotNull(groups = {SaveCheck.class, UpdateCheck.class})
@Max(value = 9999, groups = SaveCheck.class)
private Integer quantity;
위와 같이 여러 개의 검증 그룹에 속하면, groups
에 여러 개의 클래스를 추가할 수도 있다. 이제 @Validated
애노테이션에도 같은 방식으로 검증 그룹 클래스들을 추가하면 된다.
@PostMapping("/add")
public String addItem(@Validated(SaveCheck.class) @ModelAttribute Item item,
BindingResult bindingResult,
RedirectAttributes redirectAttributes) {
// ...
}
@PostMapping("/edit")
public String editItem(@Validated(UpdateCheck.class) @ModelAttribute Item item,
BindingResult bindingResult,
RedirectAttributes redirectAttributes) {
// ...
}
근데 사실 groups
기능은 잘 사용하지 않는다. 왜냐하면 보통 도메인 모델 클래스에 직접적으로 검증 애노테이션을 추가하는 방법 보다는, 요청을 위한 클래스를 따로 만든 뒤, 해당 클래스에 검증 애노테이션을 추가하는 게 일반적이다.
위 예제에서도 그렇지만, 아이템 추가 요청과 수정 요청에 필요한 값이 다르다. 지금은 큰 차이가 없지만, 다양한 필드들이 추가되고 부가적인 정보들까지 넘겨줘야 하는 상황이라면 말이 다르다. 따라서 이를 하나의 도메인 모델 객체로 다루기 보단, 요청에 맞는 클래스를 새로 생성한 뒤, 해당 객체로 전송하는 것이 유지보수에 훨씬 좋다.
우리는 보통 이런 객체를 전송(Request) 객체, 폼(Form) 객체, DTO(Data Transfer Object)라고 한다.
(DTO 같은 객체를 만드는 이유는 이뿐만이 아니다. 이에 대해서는 https://bnzn2426.tistory.com/137 참고)
Form 전송 객체 분리(DTO)
위 도메인 모델 클래스를 2개의 폼 객체로 분리한 뒤, 검증 애노테이션을 옮겨 보자.
@Data
public class ItemSaveForm {
@NotBlank
private String itemName;
@NotNull
@Range(min = 1000, max = 1_000_000)
private Integer price;
@NotNull
@Max(value = 9999)
private Integer quantity;
}
@Data
public class ItemUpdateForm {
@NotNull
private Long id;
@NotBlank
private String itemName;
@NotNull
@Range(min = 1000, max = 1_000_000)
private Integer price;
@NotNull
private Integer quantity;
}
이런 식으로 폼 객체를 생성한 뒤, 전송 폼에 맞게 검증 애노테이션을 추가하면 된다. 컨트롤러에서는 파라미터로 이제 Item
객체를 받는게 아닌 기능에 맞는 폼 객체를 받게 해야 한다.
@PostMapping("/add")
public String addItem(@Validated @ModelAttribute ItemSaveForm form,
BindingResult bindingResult,
RedirectAttributes redirectAttributes) {
// ...
}
@PostMapping("/edit")
public String editItem(@Validated(UpdateCheck.class) @ModelAttribute ItemUpdateForm form,
BindingResult bindingResult,
RedirectAttributes redirectAttributes) {
// ...
}
다만, 이렇게 폼 객체를 사용하면 비즈니스 로직에서 폼 객체를 도메인 모델 객체로 변환해 주는 추가 작업이 필요하다. 그럼에도 분리하는 것이 추후 유지보수를 생각하면 훨씬 이득이라고 한다.