장바구니 미션에서 처음으로 Bean Validation을 이용한 검증을 사용해봤다.
public class ProductAddRequestDto {
@NotBlank(message = "상품 이름은 필수입니다.")
private final String name;
...
}
@RestController
@RequestMapping("/products")
public class ProductController {
private final ProductService productService;
public ProductController(ProductService productService) {
this.productService = productService;
}
@PostMapping
public ResponseEntity<Integer> productAdd(@Valid @RequestBody ProductAddRequestDto product) {
int productId = productService.save(product);
return ResponseEntity.status(HttpStatus.CREATED).location(URI.create("/products/" + productId)).build();
}
}
검증을 통과 못했을 경우 MethodArgumentNotValidException
이라는 예외가 발생하는데,
@ExceptionHandler로 해당 예외를 잡고 내가 지정한 예외 메시지를 찍어주고 싶었다.
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<String> handleMethodArgumentNotValidException(MethodArgumentNotValidException e) {
String errorMessage = e.getBindingResult().getAllErrors().get(0).getDefaultMessage();
log.info(errorMessage);
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorMessage);
}
구글링을 해보니 위와 같이
e.getBindingResult().getAllErrors().get(0).getDefaultMessage();
이렇게 구구절절 뭔가를 해줘야하길래 관련 개념에 대해 학습해봤다!
얘를 이해하기 위해서는 BindingResult
를 알아야한다.
BindingResult란 스프링이 제공하는 검증 오류를 보관하는 객체이다. 검증 오류가 발생하면 여기에 보관된다.
FieldError는 두가지 생성자를 제공한다.
ObjectError도 두가지 생성자를 제공한다.
public class Item {
private final String name;
private final Integer price;
private final Integer quantity;
...
}
@Controller
public class ProductController {
private final ProductService productService;
public ProductController(ProductService productService) {
this.productService = productService;
}
@PostMapping
public ResponseEntity<Integer> productAdd(@ModelAttribute Product product,
BindingResult bindingResult) {
// 특정 필드 예외
if (!StringUtils.hasText(product.getItemName())) {
bindingResult.addError(new FieldError("product", "name",
product.getName(), false, null, null,
"상품 이름은 필수입니다.")); }
//특정 필드 예외가 아닌 전체 예외
if (product.getPrice() != null && product.getQuantity() != null) {
int resultPrice = product.getPrice() * product.getQuantity();
if (resultPrice < 10000) {
bindingResult.addError(new ObjectError("item",
null, null,
"가격 * 수량의 합은 10,000원 이상이어야 합니다. 현재 값 = " + resultPrice));
} }
int productId = productService.save(productAddRequestDto);
return ResponseEntity.status(HttpStatus.CREATED).location(URI.create("/products/" + productId)).build();
}
}
스프링은 검증을 체계적으로 제공하기 위해 Validator라는 인터페이스를 제공한다.
public class ProductValidator implements Validator {
@Override
public boolean supports(Class<?> clazz) {
return Product.class.isAssignableFrom(clazz);
}
@Override
public void validate(Object target, BindingResult bindingResult) {
Product product = (Product) target;
// 특정 필드 예외
if (!StringUtils.hasText(product.getName())) {
bindingResult.addError(new FieldError("product", "name",
product.getName(), false, null, null,
"상품 이름은 필수입니다.")); }
//특정 필드 예외가 아닌 전체 예외
if (product.getPrice() != null && product.getQuantity() != null) {
int resultPrice = product.getPrice() * product.getQuantity();
if (resultPrice < 10000) {
bindingResult.addError(new ObjectError("product",
null, null,
"가격 * 수량의 합은 10,000원 이상이어야 합니다. 현재 값 = " + resultPrice));
}
}
}
}
이렇게 직접 만든 Validator를 사용하는 방법에는 3가지가 있다.
1) Validator 직접 호출
@RestController
@RequestMapping("/products")
public class ProductController {
private final ProductValidator productValidator;
private final ProductService productService;
public ProductController(ProductService productService) {
this.productService = productService;
}
@PostMapping
public ResponseEntity<Integer> productAdd(@RequestBody Product product,
BindingResult bindingResult) {
productValidator.validate(product, bindingResult);
int productId = productService.save(productAddRequestDto);
return ResponseEntity.status(HttpStatus.CREATED).location(URI.create("/products/" + productId)).build();
}
}
2) WebDataBinder
@InitBinder
public void init(WebDataBinder dataBinder) {
dataBinder.addValidators(productValidator);
}
@RestController
@RequestMapping("/products")
public class ProductController {
private final ProductService productService;
public ProductController(ProductService productService) {
this.productService = productService;
}
@PostMapping
public ResponseEntity<Integer> productAdd(@Valid @RequestBody Product product,
BindingResult bindingResult) {
int productId = productService.save(productAddRequestDto);
return ResponseEntity.status(HttpStatus.CREATED).location(URI.create("/products/" + productId)).build();
}
}
validator를 직접 호출하는 부분이 사라지고, 대신에 검증 대상 앞에 @Validated
(검증기를 실행하라는 애노테이션)가 붙었다.
3) WebMvcConfigurer
하지만 우리에게는 Bean Validation
이라는 멋진 기능이 있기 때문에, 사실 직접 Validator를 만들 일이 별로 없다.
앞에서 여러 방법을 알아봤지만,
사실 검증 기능을 하나하나 코드로 작성하는 것은 상당히 번거롭다.
특히 특정 필드에 대한 검증 로직은 대부분 빈 값인지 아닌지, 특정 크기를 넘는지 아닌지와 같이 매우 일반적인 로직이다.
그래서 스프링은 Bean Validation
이라는 기능을 제공한다.
Bean Validation이란 검증 로직을 모든 프로젝트에 적용할 수 있게 공통화하고, 표준화 한 것이다.
Bean Validation을 잘 활용하면, 애노테이션만으로 검증 로직을 매우 편리하게 적용할 수 있다.
implementation 'org.springframework.boot:spring-boot-starter-validation'
Bean Validation을 사용하려면 build.gradle에 위의 의존성을 추가해야한다.
public class ProductAddRequestDto {
@NotBlank(message = "상품명은 필수 입력 값입니다.")
private final String name;
@NotBlank(message = "이미지 Url은 필수 입력 값입니다.")
private final String imgUrl;
@NotNull(message = "상품가격은 필수 입력 값입니다.")
@PositiveOrZero(message = "상품가격은 0 이상이어야 합니다.")
private final Integer price;
@NotNull(message = "상품수량은 필수 입력 값입니다.")
@PositiveOrZero(message = "상품수량은 0 이상이어야 합니다.")
private final Integer quantity;
...
}
이런식으로 Bean Validation이 제공하는 여러 검증 어노테이션을 붙이면
매우 편리하게 검증 로직을 적용할 수 있다.
@RestController
@RequestMapping("/products")
public class ProductController {
private final ProductService productService;
public ProductController(ProductService productService) {
this.productService = productService;
}
@PostMapping
public ResponseEntity<Integer> productAdd(@Valid @RequestBody ProductAddRequestDto product) {
int productId = productService.save(product);
return ResponseEntity.status(HttpStatus.CREATED).location(URI.create("/products/" + productId)).build();
}
}
Bean Validator도 역시 Validator이기 때문에 검증하고자 하는 객체 앞에 @Valid
나 @Validated
만 붙여주면 알아서 검증 로직이 실행된다.
Bean Validator도 기본적인 스프링 검증 오류 처리와 마찬가지로,
검증 오류가 발생하면 FieldError 또는 ObjectError를 생성해서 BindingResult에 담아준다.
다시 원래의 궁금증으로 돌아와서!
Bean Validation을 활용한 검증에서 에러 메시지를 뽑기 위해서는
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<String> handleMethodArgumentNotValidException(MethodArgumentNotValidException e) {
String errorMessage = e.getBindingResult().getAllErrors().get(0).getDefaultMessage();
log.info(errorMessage);
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorMessage);
}
e.getBindingResult().getAllErrors().get(0).getDefaultMessage();
이렇게 긴 처리를 해줘야 했다!
앞에서부터 차근차근 뜯어보자
MethodArgumentNotValidException은 BindException을 상속한다.
BindException은 BindingResult를 갖고 있다!
그러므로 우리는 먼저 e.getBindingResult()로 BindingResult를 가져온다.
BindingResult는 Errors를 상속한다.
Errors가 제공하는 메소드인 getAllErrors()를 통해 BindingResult에 담긴 모든 에러들을 가져온다.
그 중 첫번째 에러를 찾아 defaultMessage를 얻으면 된다!
만약 한 객체 내에서 여러개의 검증 오류가 발생했을 경우에는
여러 에러가 BindingResult에 담기겠지만!
이번 경우에는 여러 오류가 발생하더라도 그냥 그 중 하나만 알려줘도 될 것 같아서
e.getBindingResult().getAllErrors().get(0).getDefaultMessage();
를 사용했다.
만약 모든 검증 오류를 반환하고 싶다면
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<String> handleMethodArgumentNotValidException(MethodArgumentNotValidException e) {
BindingResult bindingResult = e.getBindingResult();
StringBuilder builder = new StringBuilder();
for (FieldError fieldError : bindingResult.getFieldErrors()) {
builder.append("[");
builder.append(fieldError.getField());
builder.append("](은)는 ");
builder.append(fieldError.getDefaultMessage());
builder.append(" 입력된 값: [");
builder.append(fieldError.getRejectedValue());
builder.append("]");
}
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(builder.toString());
}
이런식으로 작성하면 된다.