테스트에서 @Transactional을 이용한 롤백

나민혁·2024년 9월 7일
0
post-thumbnail

들어가며

테스트에서는 @Transactional을 붙이면 각 테스트에 대해서 롤백이 실행된다.

@Transactional
class ProductServiceImplTest extends IntegrationTestSupport {
}

그냥 평범하게 서비스에서 사용하는 것 처럼 @Transactional 어노테이션을 이용하면 rollback이 진행되어서 각 테스트마다 독립된 환경을 이용 할 수 있다.

class ProductServiceImplTest extends IntegrationTestSupport {

    @Autowired
    private ProductServiceImpl productService;

    @Autowired
    private ProductRepository productRepository;

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

@Transactional이 아니어도 각 테스트 실행 후 레포지토리를 비워서 매 테스트마다 rollback이 되도록 할 수 있다.

deleteAll() 대신 deleteAllInBatch() 를 사용하는 이유

deleteAll()

실제 테스트를 통해 쿼리가 날라가는걸 확인해보자

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

@DisplayName("deleteAll test")
@Test
void test() {
	//given
    Product product1 = Product.builder()
        .name("스타벅스 원두")
        .category("원두")
        .price(50000L)
        .description("에티오피아산")
        .build();

	Product product2 = Product.builder()
        .name("스타벅스 원두")
        .category("원두")
        .price(30000L)
        .description("에티오피아산")
        .build();

	Product product3 = Product.builder()
        .name("스타벅스 원두")
        .category("원두")
        .price(10000L)
        .description("에티오피아산")
        .build();

	productRepository.saveAll(List.of(product1,product2,product3));
}

지금은 어차피 하나만 확인할거긴 하지만 @AfterEach를 통해 각 테스트가 종료 될 때 repository를 @deleteAll()을 통해 비우도록 하였다.

이 때 날라간 쿼리를 살펴보자 예상하기로는 Insert 문과 테이블을 비우기 위한 delete가 한번씩만 날라가면 될 것 같다.


하지만 예상과 다르게 insert문 다음 select문이 오고 있었다. save와 delete만 하고 있는데 왜 select가 있을까 ? 그리고 왜 삭제가 하나씩 진행이 될까 ?

deleteAll()을 살펴보자

deleteAll()을 자세히 살펴보면 deleteAll()은 일단 먼저 존재하는 엔티티를 찾고 반복문을 통해서 삭제하도록 하고 있다.

과거에 했던 N+1 문제가 생각나게 하는 부분이다. 지금 테스트하는 환경에서는 당연히 delete할 데이터가 많지 않을 것은 사실이나 그래도 할 수 있다면 최적화를 하는게 좋을 것 같다. 그리고 프로덕션 코드에서는 지양해야할 것 같다.

deleteAllInBatch()

jpa 문법 중 deleteAll과 관련된 문법에는 deleteAllInBatch()가 존재한다.

deleteAllInBatch()로 한번 실행해보자

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

@DisplayName("deleteAll test")
@Test
void test() {
	//given
    Product product1 = Product.builder()
        .name("스타벅스 원두")
        .category("원두")
        .price(50000L)
        .description("에티오피아산")
        .build();

	Product product2 = Product.builder()
        .name("스타벅스 원두")
        .category("원두")
        .price(30000L)
        .description("에티오피아산")
        .build();

	Product product3 = Product.builder()
        .name("스타벅스 원두")
        .category("원두")
        .price(10000L)
        .description("에티오피아산")
        .build();

	productRepository.saveAll(List.of(product1,product2,product3));
}

각 insert는 아까와 동일하게 진행된다. (deleteAll() 때도 마찬가지로 insert가 3번 나갔다. 이건 saveAll()을 이용하면 한번씩 나가게 되어있다.)

deleteAllInBatch()도 내부를 살펴보자

쿼리를 만들도록 되어있다.

그리고 getDeleteAllQueryString()이 뭐냐면

이렇게 구현이 되어있다.

실제 테이블의 모든 데이터를 삭제할 때 처럼 쿼리가 작성되어있는 것을 알 수 있다.

그러면 deleteAll()이 더 안좋은건데 왜 있을까?

deleteAll() 의 패키지는 package org.springframework.data.repository; 이다
반면 deleteAllInBatch() 의 패키지는 package org.springframework.data.jpa.repository.support; 이다.
그렇다는 말은 deleteAllInBatch()는 Spring Data JPA에 종속적이라는 뜻이다. MyBatis, JdbcTemplate과 같은 곳에서는 사용 할 수 없기 때문에 deleteAll()을 사용해야 한다.

쿼리의 갯수 차이를 확인 한 것 처럼 deleteAllInBatch()가 효율적이다 deleteAllInBatch()를 사용 할 수 있는 환경이라면 deleteAll()보단 deleteAllInBatch()를 사용하자

@Transactional 이나 @AfterEach를 통해 클렌징하면 데이터가 없어야되는거 아닌가?

문제상황

나는 여기서 문제상황이 발생했다. 분명히 내 생각엔 @Transactional 이나 deleteAllInBatch()를 하면 다음 테스트에서는 완전 DB 가 빈 깡통일줄 알았다.

그래서 아래와 같이 코드를 짰다.

@Transactional
class ProductServiceImplTest extends IntegrationTestSupport {

    @Autowired
    private ProductServiceImpl productService;

    @Autowired
    private ProductRepository productRepository;

    @DisplayName("신규 상품을 등록한다.")
    @Test
    void createProduct() {
        //given
        ProductCreateServiceRequest request = ProductCreateServiceRequest.builder()
            .name("스타벅스 원두")
            .category("원두")
            .price(50000L)
            .description("에티오피아산")
            .build();

        //when
        ProductResponse productResponse = productService.createProduct(request);

        List<Product> products = productRepository.findAll();

        //then
        assertThat(products).hasSize(1)
            .extracting("id", "name", "category", "price", "description")
            .containsExactlyInAnyOrder(
                tuple(1L, "스타벅스 원두", "원두", 50000L, "에티오피아산")
            );

        assertThat(productResponse)
            .extracting("id", "name", "category", "price", "description")
            .containsExactlyInAnyOrder(1L, "스타벅스 원두", "원두", 50000L, "에티오피아산");

    }

    @DisplayName("상품 ID를 통해 상품에 대한 상세정보를 조회한다.")
    @Test
    void getProductByProductId() {
        //given
        Long productId = 1L;

        Product product = Product.builder()
            .name("스타벅스 원두")
            .category("원두")
            .price(50000L)
            .description("에티오피아산")
            .build();

        productRepository.save(product);

        //when
        ProductResponse productResponse = productService.getProduct(productId);

        //then
        assertThat(productResponse)
            .extracting("id", "name", "category", "price", "description")
            .containsExactlyInAnyOrder(productId, "스타벅스 원두", "원두", 50000L, "에티오피아산");

    }

    @DisplayName("상품 ID를 통해 상품에 대한 상세정보를 조회 할 때 해당 ID의 상품이 존재하지 않을 때 상품을 조회 할 수 없다.")
    @Test
    void getProductByProductIdWhenProductIsNull() {
        //given
        Long productId = 1L;

        //when
        //then
        assertThatThrownBy(() -> productService.getProduct(productId))
            .isInstanceOf(NoSuchElementException.class)
            .hasMessage("해당 id : " + productId + "를 가진 상품을 찾을 수 없습니다.");

    }

    @DisplayName("상품 ID를 통해 해당 상품을 삭제 할 수 있다.")
    @Test
    void deleteProductByProductId() {
        //given
        Long productId = 1L;

        Product product = Product.builder()
            .name("스타벅스 원두")
            .category("원두")
            .price(50000L)
            .description("에티오피아산")
            .build();

        productRepository.save(product);

        //when
        Long deletedProductId = productService.deleteProduct(productId);
        List<Product> products = productRepository.findAll();

        //then
        assertThat(products).hasSize(0);
        assertThat(deletedProductId).isEqualTo(productId);

    }

    @DisplayName("상품 ID를 통해 해당 상품을 삭제 할 때 해당 상품이 존재하지 않으면 상품을 삭제 할 수 없다.")
    @Test
    void deleteProductByProductIdWhenProductIsNull() {
        //given
        Long productId = 1L;

        //when
        //then
        assertThatThrownBy(()->productService.deleteProduct(productId))
            .isInstanceOf(NoSuchElementException.class)
            .hasMessage("해당 id : " + productId + "를 가진 상품을 찾을 수 없습니다.");
    }

    @DisplayName("상품 ID를 통해 해당 상품의 정보를 수정 할 수 있다.")
    @Test
    void updateProductByProductId() {
        //given
        Long productId = 1L;

        Product product = Product.builder()
            .name("스타벅스 원두")
            .category("원두")
            .price(50000L)
            .description("에티오피아산")
            .build();

        productRepository.save(product);

        ProductUpdateServiceRequest request = ProductUpdateServiceRequest.builder()
            .name("이디야 커피")
            .category("커피")
            .price(40000L)
            .description("국산")
            .build();

        //when
        ProductResponse response = productService.updateProduct(productId, request);

        //then
        assertThat(response)
            .extracting("id", "name", "category", "price", "description")
            .containsExactlyInAnyOrder(productId, "이디야 커피", "커피", 40000L, "국산");
    }

    @DisplayName("상품 ID를 통해 해당 상품의 정보를 수정 할 때 해당 상품이 존재하지 않으면 상품을 삭제 할 수 없다.")
    @Test
    void updateProductByProductIdWhenProductIsNull() {
        //given
        Long productId = 1L;

        ProductUpdateServiceRequest request = ProductUpdateServiceRequest.builder()
            .name("이디야 커피")
            .category("커피")
            .price(40000L)
            .description("국산")
            .build();

        //when
        //then
        assertThatThrownBy(()->productService.updateProduct(productId,request))
            .isInstanceOf(NoSuchElementException.class)
            .hasMessage("해당 id : " + productId + "를 가진 상품을 찾을 수 없습니다.");
    }

}

테스트가 조금 길긴한데 이걸 각각 개별 테스트를 돌렸을 때는 모두 통과했다 그러나 전체를 돌리니까 테스트가 깨졌다.

엥? 테스트가 왜 깨졌을까? 각 테스트를 돌렸을 때는 통과하고 @Transactional을 걸어두었으니까 롤백이 되어서 각 테스트가 독립적인 환경을 보장받아야되는거 아닐까 ? 라고 생각했다. 정말 똑같은 코드로 각 테스트를 돌리면 성공한다.

흠.... 전체 테스트의 오류를 보자

엥? 분명히 클렌징이 되면 1개만 들어가야되니까 1L이 나와야되는거아닌가? 라고 생각했다. 왜냐하면 각 테스트에서 save를 하더라도 클렌징작업을 통해서 데이터베이스를 비워내니까 당연히 비워져있으리라고 생각했다.

실패한 해결 방안

1. @Transactional 대신 @AfterEach로 클렌징하기

앞에서 작성한것 처럼 Transactional 대신 deleteAllInBatch()를 해보았다.

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

놀랍게도 그대로였다. 그리고 심지어 클렌징작업도 제대로 되고있는 것도 볼 수 있었다. 콘솔에 찍히는 것을 그대로 가져와보았다.

Hibernate: 
    select
        p1_0.product_id,
        p1_0.category,
        p1_0.created_at,
        p1_0.description,
        p1_0.product_name,
        p1_0.price,
        p1_0.updated_at 
    from
        products p1_0 
    where
        p1_0.product_id=?
Hibernate: 
    delete 
    from
        products p1_0
Hibernate: 
    select
        p1_0.product_id,
        p1_0.category,
        p1_0.created_at,
        p1_0.description,
        p1_0.product_name,
        p1_0.price,
        p1_0.updated_at 
    from
        products p1_0 
    where
        p1_0.product_id=?
Hibernate: 
    delete 
    from
        products p1_0
Hibernate: 
    select
        next value for products_seq
Hibernate: 
    insert 
    into
        products
        (category, created_at, description, product_name, price, updated_at, product_id) 
    values
        (?, ?, ?, ?, ?, ?, ?)
Hibernate: 
    select
        p1_0.product_id,
        p1_0.category,
        p1_0.created_at,
        p1_0.description,
        p1_0.product_name,
        p1_0.price,
        p1_0.updated_at 
    from
        products p1_0 
    where
        p1_0.product_id=?
Hibernate: 
    delete 
    from
        products 
    where
        product_id=?

음... 클렌징도 다 잘 작동하는거같은데 뭐가문제지 ? 일단 이렇게 해도 테스트가 똑같은 부분에서 깨졌다.

2. IntegerationTestSupport 추상 클래스에 @Transactional 이용하기

implements하기 전에 @Transactional이 있어야하나? 생각해서 한번 붙여보았다.

@ActiveProfiles("test")
@SpringBootTest
@Transactional
public abstract class IntegrationTestSupport {

}

결과 ? 당연히 똑같이 안된다

해결방안

음.. 솔직하게 말하면 해결하긴 했는데 내가 테스트를 잘 이해하지 못해서 그런가? 이렇게 하는게 맞는지 모르겠다. 이거는 너무 당연히 assertThat을 하면 맞는 값이 나오는거아닌가? 라고 생각이든다.

1L과 같은 매직넘버 대신 getter 사용하기

// 기존 깨지던 테스트
@DisplayName("신규 상품을 등록한다.")
@Test
void createProduct() {
	//given
    ProductCreateServiceRequest request = ProductCreateServiceRequest.builder()
    	.name("스타벅스 원두")
        .category("원두")
        .price(50000L)
        .description("에티오피아산")
        .build();

	//when
    ProductResponse productResponse = productService.createProduct(request);

	List<Product> products = productRepository.findAll();

	//then
	assertThat(products).hasSize(1)
    	.extracting("id", "name", "category", "price", "description")
        .containsExactlyInAnyOrder(
        	tuple(1L, "스타벅스 원두", "원두", 50000L, "에티오피아산")
        );

	assertThat(productResponse)
    	.extracting("id", "name", "category", "price", "description")
        .containsExactlyInAnyOrder(1L, "스타벅스 원두", "원두", 50000L, "에티오피아산");

}

// 해결한 방법
@DisplayName("신규 상품을 등록한다.")
@Test
void createProduct() {
    //given
    ProductCreateServiceRequest request = ProductCreateServiceRequest.builder()
        .name("스타벅스 원두")
        .category("원두")
        .price(50000L)
        .description("에티오피아산")
        .build();

    //when
    ProductResponse productResponse = productService.createProduct(request);

    List<Product> products = productRepository.findAll();

    //then
    assertThat(products).hasSize(1)
        .extracting("id", "name", "category", "price", "description")
        .containsExactlyInAnyOrder(
            tuple(products.get(0).getId(), "스타벅스 원두", "원두", 50000L, "에티오피아산")
        );

    assertThat(productResponse)
        .extracting("id", "name", "category", "price", "description")
        .containsExactlyInAnyOrder(products.get(0).getId(), "스타벅스 원두", "원두", 50000L, "에티오피아산");

}

기존에는 당연히 클렌징 작업을 진행하기 때문에 완전히 비어있는 곳에 넣으니까 1L이 반환될거라고 생각했는데 그게 아닌거같다. 전체 테스트를 보면 save가 총 3번 그리고 지금 테스트에 포함되어있는 메서드가 가지는 save 1번 총 4번이 실행된다. 그래서 4L이 반환이 되었던걸까?

그래서 그냥 이곳에서는 전부 다 조회해서 1개가 들었는지 검증하고 List의 0번째 id를 가져와 검증하도록 바꾸었다.

진짜 미치겠는건 findAll()로 찾은 List의 hasSize(1)은 통과하는데 도대체 왜 id가 4인것일까?
이렇게 보면 @Transactional은 제대로 동작하는게 맞다.

나머지 테스트들도 다 위와 같은 방식으로 수정하였다.

@Transactional
class ProductServiceImplTest extends IntegrationTestSupport {

    @Autowired
    private ProductServiceImpl productService;

    @Autowired
    private ProductRepository productRepository;

    @DisplayName("신규 상품을 등록한다.")
    @Test
    void createProduct() {
        //given
        ProductCreateServiceRequest request = ProductCreateServiceRequest.builder()
            .name("스타벅스 원두")
            .category("원두")
            .price(50000L)
            .description("에티오피아산")
            .build();

        //when
        ProductResponse productResponse = productService.createProduct(request);

        List<Product> products = productRepository.findAll();

        //then
        assertThat(products).hasSize(1)
            .extracting("id", "name", "category", "price", "description")
            .containsExactlyInAnyOrder(
                tuple(products.get(0).getId(), "스타벅스 원두", "원두", 50000L, "에티오피아산")
            );

        assertThat(productResponse)
            .extracting("id", "name", "category", "price", "description")
            .containsExactlyInAnyOrder(products.get(0).getId(), "스타벅스 원두", "원두", 50000L, "에티오피아산");

    }

    @DisplayName("상품 ID를 통해 상품에 대한 상세정보를 조회한다.")
    @Test
    void getProductByProductId() {
        //given
        Product product = Product.builder()
            .name("스타벅스 원두")
            .category("원두")
            .price(50000L)
            .description("에티오피아산")
            .build();

        Product savedProduct = productRepository.save(product);

        //when
        ProductResponse productResponse = productService.getProduct(savedProduct.getId());

        //then
        assertThat(productResponse)
            .extracting("id", "name", "category", "price", "description")
            .containsExactlyInAnyOrder(savedProduct.getId(), "스타벅스 원두", "원두", 50000L, "에티오피아산");

    }

    @DisplayName("상품 ID를 통해 상품에 대한 상세정보를 조회 할 때 해당 ID의 상품이 존재하지 않을 때 상품을 조회 할 수 없다.")
    @Test
    void getProductByProductIdWhenProductIsNull() {
        //given
        Long productId = 1L;

        //when
        //then
        assertThatThrownBy(() -> productService.getProduct(productId))
            .isInstanceOf(NoSuchElementException.class)
            .hasMessage("해당 id : " + productId + "를 가진 상품을 찾을 수 없습니다.");

    }

    @DisplayName("상품 ID를 통해 해당 상품을 삭제 할 수 있다.")
    @Test
    void deleteProductByProductId() {
        //given

        Product product = Product.builder()
            .name("스타벅스 원두")
            .category("원두")
            .price(50000L)
            .description("에티오피아산")
            .build();

        Product savedProduct = productRepository.save(product);

        //when
        Long deletedProductId = productService.deleteProduct(savedProduct.getId());
        List<Product> products = productRepository.findAll();

        //then
        assertThat(products).hasSize(0);
        assertThat(deletedProductId).isEqualTo(savedProduct.getId());

    }

    @DisplayName("상품 ID를 통해 해당 상품을 삭제 할 때 해당 상품이 존재하지 않으면 상품을 삭제 할 수 없다.")
    @Test
    void deleteProductByProductIdWhenProductIsNull() {
        //given
        Long productId = 1L;

        //when
        //then
        assertThatThrownBy(()->productService.deleteProduct(productId))
            .isInstanceOf(NoSuchElementException.class)
            .hasMessage("해당 id : " + productId + "를 가진 상품을 찾을 수 없습니다.");
    }

    @DisplayName("상품 ID를 통해 해당 상품의 정보를 수정 할 수 있다.")
    @Test
    void updateProductByProductId() {
        //given
        Product product = Product.builder()
            .name("스타벅스 원두")
            .category("원두")
            .price(50000L)
            .description("에티오피아산")
            .build();

        Product savedProduct = productRepository.save(product);

        ProductUpdateServiceRequest request = ProductUpdateServiceRequest.builder()
            .name("이디야 커피")
            .category("커피")
            .price(40000L)
            .description("국산")
            .build();

        //when
        ProductResponse response = productService.updateProduct(savedProduct.getId(), request);

        //then
        assertThat(response)
            .extracting("id", "name", "category", "price", "description")
            .containsExactlyInAnyOrder(savedProduct.getId(), "이디야 커피", "커피", 40000L, "국산");
    }

    @DisplayName("상품 ID를 통해 해당 상품의 정보를 수정 할 때 해당 상품이 존재하지 않으면 상품을 삭제 할 수 없다.")
    @Test
    void updateProductByProductIdWhenProductIsNull() {
        //given
        Long productId = 1L;

        ProductUpdateServiceRequest request = ProductUpdateServiceRequest.builder()
            .name("이디야 커피")
            .category("커피")
            .price(40000L)
            .description("국산")
            .build();

        //when
        //then
        assertThatThrownBy(()->productService.updateProduct(productId,request))
            .isInstanceOf(NoSuchElementException.class)
            .hasMessage("해당 id : " + productId + "를 가진 상품을 찾을 수 없습니다.");
    }

}

전체 수정한 테스트코드이다.

솔직하게 말하면 모르겠다.... 그리고 테스트를 이렇게 짜는것도 맞는지 조금 의심은 든다.

궁금하니까 찍어보기

일단 show-sql은 거슬리니까 좀 끄고 System.out.println을 써서 확인해보자

음 왼쪽에 나오는 순서대로 테스트 메서드가 실행되는거 같다. 근데 내가 작성한 제일 위에서부터 실행되는건 아닌가보다 내가 작성한 순서대로라면

createProduct,getProductByProductId,getProductByProductIdWhenProductIsNull 등등으로 나아가야되는데 이게 아니다.

뭐 대충 순서를 보면 앞에 두개는 Exception을 발생시키는것이고 여기서부터 save가 3번, 그리고 createProduct테스트에서 호출하는 메서드에서 save 한번 발생한다.
뭔가 테스트도 순서가 있는걸까? 근데 제일 마지막에 또 예외를 확인하는 테스트가 일어난다.

솔직히 아직까진 모르겠다. 테스트 순서가 임의로 정해지는건지도 알아봐야될거같고 (근데 10번돌렸는데 다 10번 다 이거만 나옴) id 값이 계속 오르는거는 AutoIncrement니까 클렌징을 하더라도 테이블을 새로 만드는게 아니라 비우는거니까 그런거같고...? 그러면 이렇게 테스트하는게 맞는거같기도 하고 ? 확신이 생기지는 않는다.

결론

솔직하게 알 것 같으면서도 모르겠다. 확신이 들진 않는다.
정확한건 아니지만 아마도 테이블을 drop 하는게 아닌 테이블의 컬럼을 delete 하는거니까 id값은 재사용하지 않도록 하여서 증가하는게 아닐까? 싶다.

그래도 몇개 알아간것들은 있다.

  1. Spring Data JPA를 사용 할 땐 deleteAll() 보단 deleteAllInBatch()를 사용하자
  2. 위엔 안적었지만 @AfterEach를 사용할 땐 참조 무결성 제약 조건을 신경써야한다.
  3. 테스트에 @Transactional 쓸거면 프로덕션 코드에도 @Transactional 붙여라 (까먹었었다)

!!주의 확신 없음 !!
앞으로 autoincrement id 값에 대한 테스트는 꺼내서 확인하는 걸로 해보자
이유1. 테스트순서를 가늠할 수 없음
이유2. 클렌징은 drop table이 아닌 delete 이다.

정확한 이유를 알고 있는사람이 이 글을 본다면 알려주셨으면 좋겠다 !!

추가
그냥 공부하다가 지나가다가 발견했다 !

테스트 전체 실행시 id 값이 4부터 시작하는 현상

내가 생각한 부분이 맞나보다. get을 이용해야되는게 맞는 것 같다.

0개의 댓글