🍃Spring

[Spring] 테스트 클렌징시 deleteAll()을 사용할 때의 주의점

waveofmymind 2023. 5. 13. 12:16

테스트 코드를 작성하면서, 테스트 간 독립성을 보장하기 위해 트랜잭션 롤백 클렌징을 사용하지 않고

 

@AfterEach를 통해 리포지토리 레벨에서 delete 메서드를 통해 given 절에서 생성했던 픽스쳐를 제거한다고 해보자.

 

우선 나의 경우 Order 엔티티 픽스쳐를 지우기 위해 아무 의심 없이 deleteAll() 메서드를 사용했다.

 

그래서 @AfterEach의 메서드를 아래와 같이 작성했다.

@AfterEach
void tearDown() {
    orderRepository.deleteAll();
    productRepository.deleteAll();

}

 

AS-IS

내가 테스트를 할 메서드는 아래와 같다.

 @DisplayName("주문번호 리스트를 받아 주문을 생성한다.")
    @Test
    void createOrder() {
        //given
        Product product1 = createProduct(HANDMADE, "001", 1000);
        Product product2 = createProduct(HANDMADE, "002", 3000);
        Product product3 = createProduct(HANDMADE, "003", 5000);

        productRepository.saveAll(List.of(product1, product2, product3));
        OrderCreateRequest request = OrderCreateRequest.builder()
                .productNumbers(List.of("001", "002"))
                .build();

        //when
        LocalDateTime registeredDateTime = LocalDateTime.now();
        OrderResponse orderResponse = orderService.createOrder(request.toServiceRequest(), registeredDateTime);

        //then
        assertThat(orderResponse.getId()).isNotNull();
        assertThat(orderResponse)
                .extracting("registeredDateTime", "totalPrice")
                .contains(registeredDateTime, 4000);
        assertThat(orderResponse.getProducts()).hasSize(2)
                .extracting("productNumber", "price")
                .containsExactlyInAnyOrder(
                        tuple("001", 1000),
                        tuple("002", 3000)
                );

    }

위 테스트가 끝나고 @AfterEach 메서드가 실행됨에 따라 Order, Product가 순서대로 제거되어야 한다.

그리고 그에 대한 쿼리를 살펴보니,

Hibernate: 
    select
        o1_0.id,
        o1_0.created_date_time,
        o1_0.modified_date_time,
        o1_0.order_status,
        o1_0.registered_date_time,
        o1_0.total_price 
    from
        orders o1_0
Hibernate: 
    select
        o1_0.order_id,
        o1_0.id,
        o1_0.created_date_time,
        o1_0.modified_date_time,
        o1_0.product_id 
    from
        order_product o1_0 
    where
        o1_0.order_id=?
Hibernate: 
    delete 
    from
        order_product 
    where
        id=?
Hibernate: 
    delete 
    from
        order_product 
    where
        id=?
Hibernate: 
    delete 
    from
        orders 
    where
        id=?
Hibernate: 
    select
        p1_0.id,
        p1_0.name,
        p1_0.price,
        p1_0.product_number,
        p1_0.selling_status,
        p1_0.type 
    from
        product p1_0
Hibernate: 
    delete 
    from
        product 
    where
        id=?
Hibernate: 
    delete 
    from
        product 
    where
        id=?
Hibernate: 
    delete 
    from
        product 
    where
        id=?

위와 같이 Order 1:N OrderProduct N:1 Product 관계를 조회 - 한 개씩 제거의 방식으로 쿼리가 발생했다.

지금은 간단한 테스트로써 몇 개 정도의 엔티티를 제거하기 때문에 성능상 큰 이슈가 없지만, 모든 테스트를 한꺼번에 실행할 때나,

나중에 더 복잡한 테스트 케이스를 작성할 때 given 절의 데이터를 하나씩 지운다고 생각하면 쓸데없이 쿼리를 많이 발생하는 것이라는 생각이 들었다.

문제 파악

deleteAll() 메서드를 살펴보면 다음과 같이 작성되어 있다.

@Override
@Transactional
public void deleteAll() {

	for (T element : findAll()) {
    	delete(element);
    }
}

for-each를 통해 findAll()로 찾은 모든 엔티티를 하나하나 제거하도록 되어있는 것을 볼 수 있었다.

위 쿼리에서 select를 통해 order에 대한 모든 데이터를 where절 없이 가져왔던 것이 findAll()에 대한 쿼리이고, 그다음으로

하나하나 제거하기 때문에 쿼리가 엔티티 개수만큼 발생한 것이다.

따라서 given 절에 데이터가 많아질수록 연관 관계를 하나하나 찾아서 일일이 제거하는 번거로움이 발생한다.

deleteAllInBatch() 사용하기

하나씩 제거하는 메서드보다, delete * from 테이블의 형태로 쿼리를 발생시키는 메서드가 없을까 하다가, deleteAllInBatch()를 사용했다.

 

deleteAllInBatch()를 살펴보면 다음과 같다.

@Override
@Transactional
public void deleteAllInBatch(Iterable<T> entities) {

	Assert.notNull(entities, "Entities must not be null!");

	if (!entities.iterator().hasNext()) {
		return;
	}

	applyAndBind(getQueryString(DELETE_ALL_QUERY_STRING, entityInformation.getEntityName()), entities, em)
			.executeUpdate();
}

그리고 매개변수 DELETE_ALL_QUERY_STRING을 살펴보면,

public abstract class QueryUtils {

	public static final String COUNT_QUERY_STRING = "select count(%s) from %s x";
	public static final String DELETE_ALL_QUERY_STRING = "delete from %s x";
	public static final String DELETE_ALL_QUERY_BY_ID_STRING = "delete from %s x where %s in :ids";
}

원하던 대로 delete from 테이블 명으로 쿼리를 발생시킴을 알 수 있었다.

 

그러나 한 가지 주의점이 있다.

order를 지우기 위해 orderRepository.deleteAll()이 발생시킨 쿼리를 보면, order와 일대다 관계인 order_product 테이블도 같이 조회해서 지움을 알 수 있다.

즉, 지우는 순서를 고려해야 한다는 점인데, 현재 product의 경우 order_product와 다대일 관계여서 order를 먼저 지워서 연관된 order_product를 먼저 지우기 때문에 productRepository.deleteAll()로 product를 지우는 쿼리가 발생하는 것을 확인했다.

그러나 현재 OrderProduct와 Product는 단방향 연관관계로 Product는 OrderProduct를 참조하고 있지 않다.

그래서 Product를 먼저 deleteAll()로 지우려고 시도할 경우, OrderProduct가 남아있기 때문에 OrderProduct 입장에서 FK로 Product의 PK를 참조하고 있어서 Product를 지울 수 없다는 DataIntegrityViolationException 가 발생한다.

 

그리고 deleteAllInBatch()는 연관된 테이블은 지워주지 않아 OrderProduct에 대한 deleteAllInBatch() 메서드를 제일 먼저 실행시켜야 한다.

 

정리하면, deleteAll() deleteAllInBatch() 둘 다 연관관계를 고려하여 순서를 신경 써서 지워야 하지만, deleteAll()보다 deleteAllInBatch()가 쿼리 수를 줄일 수 있다는 장점이 있지만, 연관된 테이블은 지워주지 않기 때문에 FK를 고려해서 모든 테이블에 대한 deleteAllInBatch()를 사용해야 한다는 단점이 있다.

 

TO-BE

@AfterEach 메서드를 아래와 같이 변경시켰다.

@AfterEach
void tearDown() {
    orderProductRepository.deleteAllInBatch();
    productRepository.deleteAllInBatch();
    orderRepository.deleteAllInBatch();
}

발생하는 쿼리를 확인해 보면

Hibernate: 
    delete 
    from
        order_product
Hibernate: 
    delete 
    from
        product
Hibernate: 
    delete 
    from
        orders

단 3개로 확연하게 줄었음을 알 수 있었다.

 

그러나 deleteAllInBatch()는 지우는 순서가 중요하기 때문에 도메인에 대한 이해도가 필요하다.

 

그래서 개인적으로는 @Transactional 롤백을 사용하는 것이 편리하지 않을까에 대한 고민을 했다.

비즈니스 로직을 구현할 때, 사이드 이펙트에 대해서 잘 고려하고 구현을 해놓았다면 @Transactional 롤백을 사용하는 것이 편리하지만, 예를 들어 한 로직을 수행할 때 여러 트랜잭션이 참여하게 될 경우에 위처럼 수동으로 지우는 것이 확실하다고 생각한다