@Valid 또는 @Validated를 활용한 검증에서 에러 메시지 뽑기 (feat. BindingResult에 대해 자세히 알아보아요)

오잉·2023년 5월 29일
1

Intro

장바구니 미션에서 처음으로 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

BindingResult란 스프링이 제공하는 검증 오류를 보관하는 객체이다. 검증 오류가 발생하면 여기에 보관된다.

BindingResult의 핵심 구성요소

1) FieldError : 필드에 에러 있을 경우

FieldError는 두가지 생성자를 제공한다.

  • objectName : 에러가 발생한 객체 이름
  • field : 오류가 발생한 필드 이름
  • defaultMessage : 오류 기본 메시지
  • rejectedValue : 오류 발생 시 사용자 입력 값 (사용자가 입력한 값, 거절된 값)
  • bindingFailure : 타입 오류 같은 바인딩 실패인지(T), 검증 실패인지(F) 구분 값
  • codes, arguments : 오류 메시지 관련한 파라미터(인데 복잡해서 일단 넘기겠다)

2) ObjectError : 특정 필드를 넘어서는 오류가 있을 경우

ObjectError도 두가지 생성자를 제공한다.

  • objectName : 에러가 발생한 객체 이름
  • defaultMessage : 오류 기본 메시지
  • codes, arguments : 오류 메시지 관련한 파라미터(인데 복잡해서 일단 넘기겠다)

BindingResult에 검증 오류를 적용하는 3가지 방법

public class Item {
    private final String name;
    private final Integer price;
    private final Integer quantity;
    ...
}

1. 개발자가 직접 BindingResult에 검증 오류 넣기

@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();
    }
}

2. Validator 만들어서 사용

스프링은 검증을 체계적으로 제공하기 위해 Validator라는 인터페이스를 제공한다.

  • supports() {} : 해당 검증기를 지원하는 여부 확인
  • validate(Object target, Errors errors) : target을 검증하고 Errors에 검증 오류 보관
    (참고: BindingResult 는 인터페이스이고, Errors 인터페이스를 상속받고 있다.
    BindingResult 대신에 Errors 를 사용해도 된다. Errors 인터페이스는 단순한 오류 저장과 조회 기능을 제공한다. BindingResult 는 여기에 더해서 추가적인 기능들을 제공한다.)
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

  • WebDataBinder 는 스프링의 파라미터 바인딩의 역할을 해주고 검증 기능도 내부에 포함한다.
  • 이렇게 WebDataBinder 에 검증기를 추가하면 해당 컨트롤러에서는 검증기를 자동으로 적용할 수 있다.
  • @InitBinder는 해당 컨트롤러에만 영향을 준다. 글로벌 설정은 별도로 해야한다.
@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

  • WebMvcConfigurer를 사용하면 글로벌 설정을 할 수 있다.
  • 글로벌 설정을 하면 BeanValidator가 자동 등록되지 않는다.
  • 참고로 글로벌 설정을 직접 사용하는 경우는 드물다.

하지만 우리에게는 Bean Validation이라는 멋진 기능이 있기 때문에, 사실 직접 Validator를 만들 일이 별로 없다.

3. Bean Validation 사용

앞에서 여러 방법을 알아봤지만,
사실 검증 기능을 하나하나 코드로 작성하는 것은 상당히 번거롭다.
특히 특정 필드에 대한 검증 로직은 대부분 빈 값인지 아닌지, 특정 크기를 넘는지 아닌지와 같이 매우 일반적인 로직이다.

그래서 스프링은 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에 담아준다.


Outro

다시 원래의 궁금증으로 돌아와서!
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();
이렇게 긴 처리를 해줘야 했다!
앞에서부터 차근차근 뜯어보자

1. MethodArgumentNotValidException


MethodArgumentNotValidException은 BindException을 상속한다.


BindException은 BindingResult를 갖고 있다!
그러므로 우리는 먼저 e.getBindingResult()로 BindingResult를 가져온다.

2. 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());
    }

이런식으로 작성하면 된다.


참고
Validation
Bean Validation
모든 검증 오류 반환

profile
오잉이라네 오잉이라네 오잉이라네 ~

0개의 댓글