![[Spring] 테스트 데이터 클렌징 시에 deleteAll(), deleteAllInBatch(), @Transactional 중에 무엇을 사용해야할까?](https://img1.daumcdn.net/thumb/R750x0/?scode=mtistory2&fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcmWdqT%2FbtsJ3QS8plN%2FV6LY0kkfLPTOWkqYA0KECk%2Fimg.png)
여러 개의 테스트를 동시에 실행하기 위해서는 각 테스트가 독립적으로 수행되어야 한다. 즉, 이전 테스트에서 수행한 내용이 다음 테스트에 영향을 미쳐서는 안 된다.
스프링을 사용한다면 @AfterEach 또는 @BeforeEach를 사용하여 각 테스트가 종료될 때마다 클렌징 처리가 가능하다. 이때, deleteAll(), deleteAllInBatch(), @Transactional 중에 하나를 사용하여 클렌징 처리가 가능하다. 셋 다 테스트에서 사용한 데이터를 지운다는 것은 동일하지만, 성능 상에 차이점이 존재한다.
각 방식의 차이점과 무엇을 사용해야 할지 알아보자.
1. 테스트 환경
- 하나의 주문(Order)에는 여러 개의 상품이 포함될 수 있다.
- 하나의 상품(Product)은 여러 개의 주문에 포함될 수 있다.
1-1. Order.class
@Table(name = "orders")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@OneToMany(cascade = CascadeType.ALL, orphanRemoval = true)
private List<OrderProduct> orderProducts;
@Builder
private Order(List<Product> products) {
orderProducts = products.stream()
.map(p -> new OrderProduct(this, p))
.toList();
}
public static Order create(List<Product> products) {
return Order.builder()
.products(products)
.build();
}
}
1-2. Product.class
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
public class Product {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(unique = true)
private String name;
@Builder
private Product(String name) {
this.name = name;
}
public static Product create(String productName) {
return Product.builder()
.name(productName)
.build();
}
}
1-3. OrderProduct.class
Order와 Product의 다대다 관계를 풀어내기 위해 중간 테이블을 사용한다.
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
public class OrderProduct {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
private Order order;
@ManyToOne(fetch = FetchType.LAZY)
private Product product;
public OrderProduct(Order order, Product product) {
this.order = order;
this.product = product;
}
}
2. 테스트 간에 독립성이 보장되어야 한다.
두 개의 테스트 메서드인 createOrder()와 createOrder2()를 작성했다. Product 엔티티의 name 필드는 unique 제약이 걸려 있어, 동일한 이름을 중복해서 저장할 수 없다.
이 두 테스트를 동시에 실행하면 실패하게 된다. 이는 첫 번째 테스트가 완료된 후, 데이터베이스 정리가 이루어지지 않은 상태에서 다음 테스트가 실행되기 때문이다. 예를 들어, createOrder()가 먼저 실행되어 "productA"가 데이터베이스에 저장되면, 이어서 createOrder2()를 실행할 때 "productA"를 다시 저장하려고 하면 unique 제약 조건에 의해 충돌이 발생하여 테스트가 실패하게 된다.
@SpringBootTest
class OrderServiceTest {
@Autowired
private OrderService orderService;
@Autowired
private ProductRepository productRepository;
@Test
@DisplayName("주문에는 하나의 상품을 담을 수 있다.")
void createOrder() {
// given
Product product = Product.create("productA");
Product savedProduct = productRepository.save(product);
// when
OrderResponse response = orderService.createOrder(List.of(savedProduct));
// then
assertThat(response.getProducts()).extracting("id", "productName")
.contains(tuple(product.getId(), product.getName()));
}
@Test
@DisplayName("주문에는 두 개 이상의 상품을 담을 수 있다..")
void createOrder2() {
// given
Product product1 = Product.create("productA");
Product product2 = Product.create("productB");
Product product3 = Product.create("productC");
List<Product> products = productRepository.saveAll(List.of(product1, product2, product3));
// when
OrderResponse response = orderService.createOrder(products);
// then
assertThat(response.getProducts()).extracting("id", "productName")
.containsExactlyInAnyOrder(
tuple(product1.getId(), product1.getName()),
tuple(product2.getId(), product2.getName()),
tuple(product3.getId(), product3.getName())
);
}
}
3. 각 테스트의 독립성을 보장하는 방법
테스트 간의 독립성을 보장하려면, 각 테스트가 종료될 때마다 데이터베이스를 클렌징해야 한다. 즉, 테스트 코드에서 데이터베이스에 저장된 내용을 롤백하거나 삭제해야 한다.
- deleteAll()
- deleteAllInBatch()
- @Transactional
각 방법의 차이점에 대해서 알아보자.
4. deleteAll()는 비효율적인 쿼리를 발생시킨다.
@AfterEach 애너테이션을 사용하면 각 테스트가 종료될 때마다 추가 작업을 수행할 수 있다. 예를 들어, createOrder() 테스트가 종료되면 tearDown() 메서드가 호출되고, createOrder2()가 종료될 때도 동일하게 tearDown()이 호출된다.
이전에 두 테스트를 동시에 실행했을 때, 첫 번째 테스트의 결과가 두 번째 테스트에 영향을 미쳤기 때문에 문제가 발생했다. 이를 방지하려면 각 테스트가 끝난 후, 테스트 도중 발생한 변경 사항을 정리해 주는 작업이 필요하다. deleteAll() 메서드는 테이블에 저장된 모든 레코드를 삭제한다. 따라서 두 개의 테스트를 동시에 실행하더라도 아래 사진처럼 정상적으로 통과하는 것을 확인할 수 있다.
그러나 deleteAll()의 치명적인 단점은 데이터베이스에 저장된 레코드 개수만큼 삭제 작업이 발생한다는 것이다. 레코드 개수가 많을수록 성능 저하로 이어지는데 자세한 내용을 알아보자.
1. orderRepository.deleteAll()이 호출되면서 Order 테이블에 저장된 레코드를 모두 조회한다. Order 테이블에 저장한 레코드를 조회한다.
2. Order 테이블을 삭제하기 전에, 외래키를 가지고 있는 OrderProduct 테이블부터 클렌징한다. Order에 3개의 Product가 저장되어 있으므로 세 번의 delete 쿼리가 발생한다.
3. Order 테이블에 저장된 데이터는 1개이므로, 한 번의 delete 쿼리가 발생한다.
4. productRepository.deleteAll()이 호출되면서 Product 테이블에 저장된 레코드를 조회한다.
5. Product 테이블에 저장된 데이터는 3개이므로, 세 번의 delete 쿼리가 발생한다.
deleteAll()을 사용하면 테스트에서 사용된 데이터를 먼저 SELECT로 조회한 후, 조회된 레코드 개수만큼 개별적인 DELETE 쿼리가 발생한다. 그 이유는 SimpleJpaRepository의 deleteAll() 메서드를 보면 알 수 있는데, 메서드 내부를 보면 this.findAll()에서 테이블에 저장된 데이터를 모두 조회하고, 반복문을 돌면서 this.delete()를 호출하기 때문이다.
package org.springframework.data.jpa.repository.support;
public class SimpleJpaRepository<T, ID> implements JpaRepositoryImplementation<T, ID> {
@Transactional
public void deleteAll() {
Iterator var2 = this.findAll().iterator();
while(var2.hasNext()) {
T element = (Object)var2.next();
this.delete(element);
}
}
}
테스트에서 많은 데이터를 생성하거나, 매번 테스트마다 이러한 쿼리가 계속해서 발생한다면 성능 저하로 이어진다. 따라서 deleteAll()을 사용하지 않고 다른 선택지를 사용하는 게 좋다.
5. deleteAllInBatch()
deleteAllInBatch() 메서드는 테이블에 저장된 레코드를 한꺼번에 삭제한다. 얼핏 보면 deleteAll()과 차이가 없어 보인다.
deleteAll()의 문제점은 SELECT 쿼리와 레코드 개수만큼 DELETE 쿼리가 발생하는 것이었다. 반면 deleteAllInBatch()는 이러한 문제를 해결하였다. 사진에서 알 수 있듯이, deleteAll()과 달리 SELECT 쿼리가 발생하지 않고, 레코드 개수만큼 DELETE 쿼리 또한 발생하지 않는다.
그러나 deleteAll()과 달리 주의할 점이 있다면, 다대다 매핑을 풀어낸 중간 테이블에 저장된 데이터를 직접 삭제해야 한다는 것이다. deleteAll()에서는 중간 테이블인 OrderProduct 테이블에 저장된 데이터를 자동으로 삭제하였으나, deleteAllInBatch()에서는 이를 지원하지 않으므로 직접 삭제해야 한다.
따라서 orderProductRepository.deleteAllInBatch()를 호출하였다. 하나 더 주의할 점이 있다면, 중간 테이블은 Order와 Product의 외래키를 가지고 있으므로 반드시 OrderProduct 테이블에 저장된 데이터 먼저 삭제해야 외래키 삭제 문제가 발생하지 않는다.
@AfterEach
void tearDown() {
orderProductRepository.deleteAllInBatch();
orderRepository.deleteAllInBatch();
productRepository.deleteAllInBatch();
}
deleteAllInBatch()는 JPA에서 제공하는 삭제 기능으로, 개별적으로 데이터를 조회하지 않고 한 번에 DELETE 작업을 실행한다(벌크 연산). 따라서 대량의 데이터를 처리할 때도 성능상의 이점이 크다.
6. @Transactional
@Transactional 애너테이션을 테스트 코드에 사용하는 경우, 테스트 메서드에서 발생한 모든 데이터베이스 작업이 하나의 트랜잭션으로 묶인다. 테스트 코드에서 @Transactional을 사용하면 테스트가 종료될 때마다 커밋을 하지 않고 롤백처리하는데, 롤백하기 때문에 테스트 중에 생성된 데이터는 자동으로 데이터베이스에서 삭제된다.
아래 사진처럼, 테스트에서 생성한 데이터에 대해 INSERT 쿼리만 발생하고 DELETE 쿼리는 발생하지 않는다.
또한 deleteAll()이나 deleteAllInBatch()처럼 @AfterEach를 통해 직접 데이터베이스 클렌징 작업을 할 필요가 없으므로, 사용하지 않는 필드에 대해서 선언할 필요가 없다. 따라서 사용하지 않는 OrderRepository, OrderProductRepository를 주입받을 필요가 없다.
@SpringBootTest
@Transactional
class OrderServiceTest {
@Autowired
private OrderService orderService;
@Autowired
private ProductRepository productRepository;
.. 생략
}
@Transactional을 사용하면 테스트가 종료될 때 자동으로 트랜잭션이 롤백되기 때문에 다음 테스트에 영향을 미치지 않는다.
그러나 JPA 환경에서 @Transactional을 사용할 경우 더티 체킹이 발생하지 않아, 테스트 코드에서 발생하지 않던 문제가 프로덕션 환경에서 발생할 수 있다. 따라서 @Transactional을 자세히 이해한 상태에서 사용해야 한다 -> 관련글📌\
7. 결론
deleteAllInBatch()와 @Transactional 두 개를 적절히 사용하여 테스트 환경을 구성하면 될 것 같다.
- deleteAll() : 중간 테이블 삭제를 알아서 해주지만, 대량의 데이터가 존재하면 쿼리가 많이 발생하면서 성능 저하 발생
- deleteAllInBatch() : 중간 테이블 삭제를 직접 해야 하지만, 대량의 데이터가 존재하더라도 벌크 작업으로 삭제하므로 성능상 이점 존재
- @Transactional : 테스트가 종료되면서 트랜잭션 내에서 사용된 데이터를 알아서 정리해 주므로 삭제 코드를 직접 작성할 필요가 없다. 그러나 JPA 환경에서 @Transactional을 사용하면 더티 체킹 문제가 발생할 수 있으므로 주의가 필요