ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [김영한 스프링] 08. 데이터 접근 기술(테스트) - 데이터 롤백 & @Transactional
    Spring/스프링 DB 2편 - 데이터 접근 활용 기술 2024. 1. 9. 13:01

    테스트 - 데이터 롤백

     

     

    트랜잭션과 롤백 전략

    이때 도움이 되는 것이 바로 트랜잭션이다.

    테스트가 끝나고 나서 트랜잭션을 강제로 롤백해 버리면 데이터가 깔끔하게 제거된다.

    테스트를 하면서 데이터를 이미 저장했는데, 중간에 테스트가 실패해서 롤백을 호출하지 못해도 괜찮다. 트랜잭션을 커밋하지 않았기 때문에 데이터베이스에 해당 데이터가 반영되지 않는다.

    이렇게 트랜잭션을 활용하면 테스트가 끝나고 나서 데이터를 깔끔하게 원래 상태로 되돌릴 수 있다.

     

    예를 들어서 다음 순서와 같이 각각의 테스트 실행 직전에 트랜잭션을 시작하고, 각각의 테스트 실행 직후에 트랜잭션을 롤백해야 한다. 그래야 다음 테스트에 데이터로 인한 영향을 주지 않는다.

    1. 트랜잭션 시작
    2. 테스트 A 실행
    3. 트랜잭션 롤백

    4. 트랜잭션 시작
    5. 테스트 B 실행
    6. 트랜잭션 롤백

    테스트는 각각의 테스트 실행 전 후로 동작하는 @BeforeEach, @AfterEach라는 편리한 기능을 제공한다.

    테스트에 트랜잭션과 롤백을 적용하기 위해 다음 코드를 추가하자.

     

     

    테스트에 직접 트랜잭션 추가 - ItemRepositoryTest

    모든 ITEM 데이터 삭제

     

     

    package hello.itemservice.domain;
    
    import hello.itemservice.repository.ItemRepository;
    import hello.itemservice.repository.ItemSearchCond;
    import hello.itemservice.repository.ItemUpdateDto;
    import hello.itemservice.repository.memory.MemoryItemRepository;
    import org.junit.jupiter.api.AfterEach;
    import org.junit.jupiter.api.BeforeEach;
    import org.junit.jupiter.api.Test;
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.boot.test.context.SpringBootTest;
    import org.springframework.transaction.PlatformTransactionManager;
    import org.springframework.transaction.TransactionStatus;
    import org.springframework.transaction.support.DefaultTransactionDefinition;
    
    import java.util.List;
    
    import static org.assertj.core.api.Assertions.assertThat;
    
    @SpringBootTest
    class ItemRepositoryTest {
    
        @Autowired
        ItemRepository itemRepository;
    
        @Autowired
        PlatformTransactionManager transactionManager;
        TransactionStatus status;
    
        @BeforeEach
        void beforeEach() {
            // 트랜잭션 시작
            status = transactionManager.getTransaction(new DefaultTransactionDefinition());
        }
    
        @AfterEach
        void afterEach() {
            //MemoryItemRepository 의 경우 제한적으로 사용
            if (itemRepository instanceof MemoryItemRepository) {
                ((MemoryItemRepository) itemRepository).clearStore();
            }
    
            // 트랜잭션 롤백
            transactionManager.rollback(status);
        }
    
        @Test
        void save() {
            //given
            Item item = new Item("itemA", 10000, 10);
    
            //when
            Item savedItem = itemRepository.save(item);
    
            //then
            Item findItem = itemRepository.findById(item.getId()).get();
            assertThat(findItem).isEqualTo(savedItem);
        }
    
        @Test
        void updateItem() {
            //given
            Item item = new Item("item1", 10000, 10);
            Item savedItem = itemRepository.save(item);
            Long itemId = savedItem.getId();
    
            //when
            ItemUpdateDto updateParam = new ItemUpdateDto("item2", 20000, 30);
            itemRepository.update(itemId, updateParam);
    
            //then
            Item findItem = itemRepository.findById(itemId).get();
            assertThat(findItem.getItemName()).isEqualTo(updateParam.getItemName());
            assertThat(findItem.getPrice()).isEqualTo(updateParam.getPrice());
            assertThat(findItem.getQuantity()).isEqualTo(updateParam.getQuantity());
        }
    
        @Test
        void findItems() {
            //given
            Item item1 = new Item("itemA-1", 10000, 10);
            Item item2 = new Item("itemA-2", 20000, 20);
            Item item3 = new Item("itemB-1", 30000, 30);
    
            itemRepository.save(item1);
            itemRepository.save(item2);
            itemRepository.save(item3);
    
            //둘 다 없음 검증
            test(null, null, item1, item2, item3);
            test("", null, item1, item2, item3);
    
            //itemName 검증
            test("itemA", null, item1, item2);
            test("temA", null, item1, item2);
            test("itemB", null, item3);
    
            //maxPrice 검증
            test(null, 10000, item1);
    
            //둘 다 있음 검증
            test("itemA", 10000, item1);
        }
    
        void test(String itemName, Integer maxPrice, Item... items) {
            List<Item> result = itemRepository.findAll(new ItemSearchCond(itemName, maxPrice));
            assertThat(result).containsExactly(items);
        }
    }

    • 트랜잭션 관리자는 PlatformTransactionManager를 주입받아서 사용하면 된다. 참고로 스프링 부트는 자동으로 적절한 트랜잭션 매니저를 스프링 빈으로 등록해 준다. (앞서 학습한 스프링 부트의 자동 리소스 등록 장을 떠올려보자.)
    • @BeforeEach : 각각의 테스트 케이스를 실행하기 직전에 호출된다. 따라서 여기서 트랜잭션을 시작하면 된다. 그러면 각각의 테스트를 트랜잭션 범위 안에서 실행할 수 있다.
      • transactionManager.getTransaction(new DefaultTransactionDefinition())로 트랜잭션을 시작한다.
    • @AfterEach : 각각의 테스트 케이스가 완료된 직후에 호출된다. 따라서 여기서 트랜잭션을 롤백하면 된다. 그러면 데이터를 트랜잭션 실행 전 상태로 복구할 수 있다.
      • transactionManager.rollback(status)로 트랜잭션을 롤백한다.

     

     

    실행

     

     

    결과

     

     

    테스트 - @Transactional

     

     

    스프링은 테스트 데이터 초기화를 위해 트랜잭션을 적용하고 롤백하는 방식을 @Transactional 애노테이션 하나로 깔끔하게 해결해 준다.

     

     

    ItemRepositoryTest

    package hello.itemservice.domain;
    
    import hello.itemservice.repository.ItemRepository;
    import hello.itemservice.repository.ItemSearchCond;
    import hello.itemservice.repository.ItemUpdateDto;
    import hello.itemservice.repository.memory.MemoryItemRepository;
    import org.junit.jupiter.api.AfterEach;
    import org.junit.jupiter.api.BeforeEach;
    import org.junit.jupiter.api.Test;
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.boot.test.context.SpringBootTest;
    import org.springframework.transaction.PlatformTransactionManager;
    import org.springframework.transaction.TransactionStatus;
    import org.springframework.transaction.annotation.Transactional;
    import org.springframework.transaction.support.DefaultTransactionDefinition;
    
    import java.util.List;
    
    import static org.assertj.core.api.Assertions.assertThat;
    
    @Transactional
    @SpringBootTest
    class ItemRepositoryTest {
    
        @Autowired
        ItemRepository itemRepository;
    
    //    @Autowired
    //    PlatformTransactionManager transactionManager;
    //    TransactionStatus status;
    //
    //    @BeforeEach
    //    void beforeEach() {
    //        // 트랜잭션 시작
    //        status = transactionManager.getTransaction(new DefaultTransactionDefinition());
    //    }
    
        @AfterEach
        void afterEach() {
            //MemoryItemRepository 의 경우 제한적으로 사용
            if (itemRepository instanceof MemoryItemRepository) {
                ((MemoryItemRepository) itemRepository).clearStore();
            }
    
            // 트랜잭션 롤백
    //        transactionManager.rollback(status);
        }
    
        @Test
        void save() {
            //given
            Item item = new Item("itemA", 10000, 10);
    
            //when
            Item savedItem = itemRepository.save(item);
    
            //then
            Item findItem = itemRepository.findById(item.getId()).get();
            assertThat(findItem).isEqualTo(savedItem);
        }
    
        @Test
        void updateItem() {
            //given
            Item item = new Item("item1", 10000, 10);
            Item savedItem = itemRepository.save(item);
            Long itemId = savedItem.getId();
    
            //when
            ItemUpdateDto updateParam = new ItemUpdateDto("item2", 20000, 30);
            itemRepository.update(itemId, updateParam);
    
            //then
            Item findItem = itemRepository.findById(itemId).get();
            assertThat(findItem.getItemName()).isEqualTo(updateParam.getItemName());
            assertThat(findItem.getPrice()).isEqualTo(updateParam.getPrice());
            assertThat(findItem.getQuantity()).isEqualTo(updateParam.getQuantity());
        }
    
        @Test
        void findItems() {
            //given
            Item item1 = new Item("itemA-1", 10000, 10);
            Item item2 = new Item("itemA-2", 20000, 20);
            Item item3 = new Item("itemB-1", 30000, 30);
    
            itemRepository.save(item1);
            itemRepository.save(item2);
            itemRepository.save(item3);
    
            //둘 다 없음 검증
            test(null, null, item1, item2, item3);
            test("", null, item1, item2, item3);
    
            //itemName 검증
            test("itemA", null, item1, item2);
            test("temA", null, item1, item2);
            test("itemB", null, item3);
    
            //maxPrice 검증
            test(null, 10000, item1);
    
            //둘 다 있음 검증
            test("itemA", 10000, item1);
        }
    
        void test(String itemName, Integer maxPrice, Item... items) {
            List<Item> result = itemRepository.findAll(new ItemSearchCond(itemName, maxPrice));
            assertThat(result).containsExactly(items);
        }
    }

    • 이전에 테스트에 트랜잭션과 롤백을 위해 추가했던 코드들을 주석 처리하자.
    • 스프링이 제공하는 @Transactional를 추가하자.

     

     

    실행

     

     

    결과

     

     

    @Transactional 원리

    스프링이 제공하는 @Transactional 애노테이션은 로직이 성공적으로 수행되면 커밋하도록 동작한다.

    그런데 @Transactional 애노테이션을 테스트에서 사용하면 아주 특별하게 동작한다.

    @Transactional이 테스트에 있으면 스프링은 테스트를 트랜잭션 안에서 실행하고, 테스트가 끝나면 트랜잭션을 자동으로 롤백시켜 버린다!

     

     

    @Transactional이 적용된 테스트 동작 방식

    • 1. 테스트에 @Transactional 애노테이션이 테스트 메서드나 클래스에 있으면 먼저 트랜잭션을 시작한다.
    • 2. 테스트 로직을 실행한다. 테스트가 끝날 때까지 모든 로직은 트랜잭션 안에서 수행된다.
      • 트랜잭션은 기본적으로 전파되기 때문에, 리포지토리에서 사용하는 JdbcTemplate도 같은 트랜잭션을 사용한다.
    • 3. 테스트 실행 중에 INSERT SQL을 사용해서 item1, item2, item3를 데이터베이스에 저장한다.
      • 물론 테스트가 리포지토리를 호출하고, 리포지토리는 JdbcTemplate을 사용해서 데이터를 저장한다.
    • 4. 검증을 위해서 SELECT SQL로 데이터를 조회한다. 여기서는 앞서 저장한 item1, item2, item3이 조회되었다.
      • SELECT SQL도 같은 트랜잭션을 사용하기 때문에 저장한 데이터를 조회할 수 있다. 다른 트랜잭션에서는 해당 데이터를 확인할 수 없다.
      • 여기서 assertThat()으로 검증이 모두 끝난다.
    • 5. @Transactional이 테스트에 있으면 테스트가 끝날때 트랜잭션을 강제로 롤백한다.
    • 6. 롤백에 의해 앞서 데이터베이스에 저장한 item1, item2, item3의 데이터가 제거된다.

     

    참고
    테스트 케이스의 메서드나 클래스에 @Transactional을 직접 붙여서 사용할 때 만 이렇게 동작한다.
    그리고 트랜잭션을 테스트에서 시작하기 때문에 서비스, 리포지토리에 있는 @Transactional도 테스트에서 시작한 트랜잭션에 참여한다. (이 부분은 뒤에 트랜잭션 전파에서 더 자세히 설명하겠다. 지금은 테스트에서 트랜잭션을 실행하면 테스트 실행이 종료될 때까지 테스트가 실행하는 모든 코드가 같은 트랜잭션 범위에 들어간다고 이해하면 된다. 같은 범위라는 뜻은 쉽게 이야기해서 같은 트랜잭션을 사용한다는 뜻이다. 그리고 같은 트랜잭션을 사용한다는 것은 같은 커넥션을 사용한다는 뜻이기도 하다.)

     

     

    정리

    • 테스트가 끝난 후 개발자가 직접 데이터를 삭제하지 않아도 되는 편리함을 제공한다.
    • 테스트 실행 중에 데이터를 등록하고 중간에 테스트가 강제로 종료되어도 걱정이 없다. 이 경우 트랜잭션을 커밋하지 않기 때문에, 데이터는 자동으로 롤백된다. (보통 데이터베이스 커넥션이 끊어지면 자동으로 롤백되어 버린다.)
    • 트랜잭션 범위 안에서 테스트를 진행하기 때문에 동시에 다른 테스트가 진행되어도 서로 영향을 주지 않는 장점이 있다.
    • @Transactional 덕분에 아주 편리하게 다음 원칙을 지킬 수 있게 되었다.
      • 테스트는 다른 테스트와 격리해야 한다.
      • 테스트는 반복해서 실행할 수 있어야 한다.

     

     

    강제로 커밋하기 - @Commit

    @Transactional을 테스트에서 사용하면 테스트가 끝나면 바로 롤백되기 때문에 테스트 과정에서 저장한 모든 데이터가 사라진다. 당연히 이렇게 되어야 하지만, 정말 가끔은 데이터베이스에 데이터가 잘 보관되었는지 최종 결과를 눈으로 확인하고 싶을 때도 있다. 이럴 때는 다음과 같이 @Commit을 클래스 또는 메서드에 붙이면 테스트 종료 후 롤백 대신 커밋이 호출된다. 참고로 @Rollback(value = false)를 사용해도 된다.

    import org.springframework.test.annotation.Commit;
    
    @Commit
    @Transactional
    @SpringBootTest
    class ItemRepositoryTest {}

     

     

    출처 :  https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81-db-2/dashboard

     

    스프링 DB 2편 - 데이터 접근 활용 기술 강의 - 인프런

    백엔드 개발에 필요한 DB 데이터 접근 기술을 활용하고, 완성할 수 있습니다. 스프링 DB 접근 기술의 원리와 구조를 이해하고, 더 깊이있는 백엔드 개발자로 성장할 수 있습니다., 백엔드 개발자

    www.inflearn.com

Designed by Tistory.