본문 바로가기

스프링 부트

@Valid와 @Validated

2021-04-30글

@Valid와 @Validated

서비스 근로에서 장바구니 미션 API를 만들며 요청으로 들어온 DTO의 값을 검증하는 방법을 고민하다가, Spring Validation을 사용해보게 되었다.
이번에는 DTO의 필드에 제약을 걸어주고 컨트롤러에서 검증을 해주었는데, 새롭게 배운 내용이니 이를 정리해보려 한다.
사실 올바르게 사용한 것인지는 확신일 없으나, 이런 것도 있구나 다뤄보면서 여러 시행착오를 겪었기에 좀 더 공부하면서 정리해야지 😋

Dependency 추가 - gradle

implementation 'org.springframework.boot:spring-boot-starter-validation'

@Valid

이번에 우리가 사용한 예시를 보며 하나씩 정리하자.

ProductController

@RestController
@RequestMapping("/api/products")
public class ProductController {

    private final ProductService productService;

    public ProductController(final ProductService productService) {
        this.productService = productService;
    }

    @PostMapping
    public ResponseEntity<Void> add(@Validae @RequestBody final ProductDto productDto) {
        final Long productId = productService.addProduct(productDto);
        final URI uri = ServletUriComponentsBuilder
                .fromCurrentRequest()
                .path("/" + productId)
                .build().toUri();
        return ResponseEntity.created(uri).build();
    }

      // ...
}

컨트롤러에서 @RequestBody를 통해 DTO에 매핑을 할 때 검증을 진행할 곳에 @Valid 를 붙여준다.
해당 요청이 들어와 메서드가 실행될 시 유효성 검사를 진행한다.
만약 검증에 실패할 경우 MethodArgumentNotValidException를 던진다.

ProductDto

public class ProductDto {
    @NotNull
    private Long productId;

    @NotBlank
    private String name;

    @Min(value = 0, message = "금액은 음수일 수 없습니다.")
    private Integer price;

    @NotBlank
    private String imageUrl;

    public ProductDto() {
    }

         // ...
}

DTO에 사용된 어노테이션과 좀 더 찾아본 제약 조건 어노테이션을 정리해보면... ✍️

  • @NotNull : 모든 데이터 타입에 대해 null을 허용하지 않는다.
  • @NotEmpty : null과 ""를 허용하지 않는다. (타입 - String, Collection. Map, Array)
  • @NotBlack : null과 "", " "(빈 공백 문자열)을 허용하지 않는다.
  • @Min(숫자) / @Max(숫자) : 최소, 최대 값을 검증한다.

또한 제약 조건 어노테이션에 속성들로 예외로 던져줄 message 등의 옵션을 설정할 수 있다.

제약 조건 어노테이션

Anotation 제약 조건
@NotNull 모든 데이터 타입에 대해 null을 허용하지 않는다.
@NotEmpty null과 ""를 허용하지 않는다. (타입 - String, Collection. Map, Array)
@NotBlank null과 "", " "(빈 공백 문자열)을 허용하지 않는다.
@Null Null만 입력 가능
@Size(min=,max=) 문자열, 배열등의 크기 검증
@Pattern(regex=) 정규식 검증
@Max(숫자) 최대값 검증
@Min(숫자) 최소값 검증
@Future 현재 보다 미래인지 검증
@Past 현재 보다 과거인지 검증
@Positive 양수만 가능
@PositiveOrZero 양수와 0만 가능
@Negative 음수만 가능
@NegativeOrZero 음수와 0만 가능
@Email 이메일 형식만 가능
@Digits(integer=, fraction = ) 대상 수가 지정된 정수와 소수 자리 수 보다 작은지 검증
@DecimalMax(value=) 지정된 실수 이하인지 검증
@DecimalMin(value=) 지정된 실수 이상인지 검증
@AssertFalse false 인지 검증
@AssertTrue true 인지 검증

그런데 우리는 어떤 요청에서는 id값만 제약조건을 걸고, 어떤 요청에서는 모든 필드에 대한 제약조건을 걸고 싶었다.
당연하게도(?) 제약조건에 대해 그룹핑을 할 수 있는 방법도 있었다!


@Validated

제약조건에 대한 그룹을 만들어 적용시킬 수 있다.
특정 Validation 그룹으로 검증하기 위해서는 Group 인터페이스를 생성하고 이 안에 그룹에 대한 인터페이스를 정의한다.

Request

public interface Request {
    interface id {
    }

    interface allProperties {
    }
}

이제 요청마다 id, or 모든 필드에 대한 제약 조건을 검사하고 싶을 때를 나눠 그룹을 정의한다.

ProductDto

public class ProductDto {
    @NotNull(groups = Request.id.class)
    private Long productId;

    @NotBlank(groups = Request.allProperties.class)
    private String name;

    @Min(value = 0, message = "금액은 음수일 수 없습니다.", groups = Request.allProperties.class)
    private Integer price;

    @NotBlank(groups = Request.allProperties.class)
    private String imageUrl;

    public ProductDto() {
    }

    // ...
}

속성 제약조건 어노테이션의 옵션 groups에 그룹을 지정해준다.

ProductController

@RestController
@RequestMapping("/api/products")
public class ProductController {

    private final ProductService productService;

    public ProductController(final ProductService productService) {
        this.productService = productService;
    }

      // ...

    @PostMapping
    public ResponseEntity<Void> add(@Validated(Request.allProperties.class) @RequestBody final ProductDto productDto) {
        final Long productId = productService.addProduct(productDto);
        final URI uri = ServletUriComponentsBuilder
                .fromCurrentRequest()
                .path("/" + productId)
                .build().toUri();
        return ResponseEntity.created(uri).build();
    }
        // ...
}

CartItemController

@RestController
@RequestMapping("/api/customers/{customerName}/carts")
public class CartItemController {
    private final CartService cartService;

    public CartItemController(final CartService cartService) {
        this.cartService = cartService;
    }
        // ...

    @PostMapping
    public ResponseEntity<Void> addCartItem(@Validated(Request.id.class) @RequestBody final ProductDto productDto,
                                            @PathVariable final String customerName) {
        final Long newId = cartService.addCart(productDto.getProductId(), customerName);
        final URI uri = ServletUriComponentsBuilder
                .fromCurrentRequest()
                .path("/{cartId}")
                .buildAndExpand(newId)
                .toUri();
        return ResponseEntity.created(uri).build();
    }
        // ...
}

@RequestBody 옆에 @Validated를 선언하고 괄호를 열어 원하는 그룹을 넣어준다.


컬렉션 @Valid ?

OrderDetailDto

public class OrderDetailDto {

    @NotNull(groups = Request.allProperties.class)
    private Long productId;
    private Long cartId;
    private int price;
    private String name;
    private String imageUrl;

    @Min(value = 0, groups = Request.allProperties.class)
    private int quantity;

    public OrderDetailDto() {
    }

    // ...
}

우리가 구현하다 문제가 된 부분은 바로 위와 같은 OrderDetailDto 의 컬렉션인 List<OrderDetailDto> orderDetailRequestDtos로 들어오는 값을 검증하고 싶었는데,
컬렉션에 속한 객체는 속성 제약 조건이 검증되지 않고 그냥 통과되어 버리는 것이었다.

@RestController
@RequestMapping("/api/customers/{customerName}/orders")
public class OrderController {
    private final OrderService orderService;

    public OrderController(final OrderService orderService) {
        this.orderService = orderService;
    }

    @PostMapping
    public ResponseEntity<Void> addOrder(@PathVariable final String customerName,
                                         @RequestBody @Valid final List<OrderDetailDto> orderDetailRequestDtos) {
        long orderId = orderService.addOrder(orderDetailRequestDtos, customerName);
        return ResponseEntity.created(
                URI.create("/api/" + customerName + "/orders/" + orderId)).build();
    }

   // ...
}

왜❓

이유는@Valid는 JSR-303의 어노테이션이고 JSR-303은 JavaBeans에 적용되는데, List는 JavaBeans가 아니기 때문이라고 한다.
우리는 Collection DTO를 감싸는 또다른 DTO 객체를 만들어야하나... 했으나, 클래스 단에 @Validated 을 붙여 해결할 수 있었다.

예외

여기서 주의할 점이 있다. 바로 예외에 관한 부분인데,
@Valid 는 검증에 실패하면 MethodArgumentNotValidException 를 던지고,
클래스 단에 붙은 @Validated는 검증에 실패하면 ConstraintViolationException 를 던진다.

지금으로써는 각 예외를 잡아서 핸들링 해주었다.


근로하면서 Spring Validation이라는 것을 처음써봐서 여러 시도들을 해보았고, 덕분에 이런것도 있구나를 깨달을 수 있었다.
특히 컬렉션에 대한 검증에서 왜이러지를 고민하며 이유를 찾아갔었고 덕분에 정말 많이 배웠다.
사실 장바구니 API 구현에서 살짝 Spring Validation을 다룬거라 이에 대해 깊게 깨우치진 않았지만, 굉장히 좋은 학습이었다.
아마 이 글은 미션을 진행하면서 점차 살이 붙을 것 같다.
일단 지금은 이번에 경험한 내용을 정리하는데 의의를 둔다!


참고 자료