본문 바로가기

Development/Diary

[Diary] Spring 테스트 관리 트랜잭션(Test-managed transactions)과 테스트 생명주기 + 멀티 스레드에서 테스트 문제

Spring에서 테스트 관리 트랜잭션(Test-managed transactions)은 통합 테스트(integration test) 중에 트랜잭션이 자동으로 관리되고, 테스트 메서드가 종료되면 자동으로 롤백되는 트랜잭션입니다. 이러한 트랜잭션은 Spring에서 관리되는 트랜잭션(테스트를 위해 로드된 ApplicationContext 내에서 직접적으로 Spring에 의해 관리되는 트랜잭션)이나 애플리케이션에서 관리되는 트랜잭션(테스트에서 호출되는 애플리케이션 코드 내에서 프로그래밍 방식으로 관리되는 트랜잭션)과는 다릅니다.

 

메서드 수준의 생명주기 메서드(예: JUnit Jupiter의 @BeforeEach 또는 @AfterEach로 주석이 달린 메서드)는 테스트 관리 트랜잭션 내에서 실행됩니다. 반면 클래스 수준의 생명주기 메서드(예: JUnit Jupiter의 @BeforeAll 또는 @AfterAll, TestNG의 @BeforeSuite, @AfterSuite, @BeforeClass 또는 @AfterClass로 주석이 달린 메서드)는 테스트 관리 트랜잭션 내에서 실행되지 않습니다. 


이러한 제약 때문에, 트랜잭션이 적용된 테스트 메서드 실행 전후에 트랜잭션 외부에서 특정 코드를 실행해야 하는 경우가 발생합니다. 예를 들어, 트랜잭션이 적용된 테스트 코드를 실행하기 전에 초기 데이터베이스 상태를 확인하거나, 트랜잭션을 커밋한 후 예상된 동작을 확인하는 경우가 이에 해당합니다. 이러한 상황에서 유용한 어노테이션이 바로 @BeforeTransaction 및 @AfterTransaction입니다.

이번 글에서는 동시성 문제 테스트를 진행하면서 마주쳤던 문제를 해결하기 위해 공부한 테스트 트랜잭션이 무엇인지, 트랜잭션의 생명 주기는 어떠하며 멀티 스레드에서 어떻게 영향을 받는지 공부한 내용을 공유하고자 합니다. 

문제 상황: @BeforeEach에서 save 한 Entity를 새로 생성한 스레드 안에서 불러올 수 없다. 

@Test 메서드 별로, User를 새로 생성하여 테스트하기 위해 아래와 같은 코드를 작성했다.

(class 상단의 어노테이션 @Transactional을 추가한 것 기억하자.)

@ActiveProfiles("test")
@SpringBootTest
@AutoConfigureMockMvc
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
@Transactional
public class SurveyServiceConcurrencyTest extends BaseControllerTest {

// ... 중략

@BeforeEach
void setupBeforeEach() throws Exception {
    UserRegisterRequestDto userRegisterRequestDto = UserRegisterRequestDto.builder()
            .name("test1")
            .email("test1@gmail.com")
            .password("Password40@")
            .phoneNumber("01012345678")
            .build();
    mockMvc.perform(post("/auth/register")
            .contentType(MediaType.APPLICATION_JSON)
            .content(objectMapper.writeValueAsString(userRegisterRequestDto)));

    UserLoginRequestDto userLoginRequestDto = UserLoginRequestDto.builder()
            .email("test1@gmail.com")
            .password("Password40@")
            .build();

    mockMvc.perform(post("/auth/login")
            .contentType(MediaType.APPLICATION_JSON)
            .content(objectMapper.writeValueAsString(userLoginRequestDto)));

}

이 메서드는 간단히 회원가입과 로그인을 수행한다. (Spring Data JPA의 repository에 User 엔티티를 save 하고, 로그인하는 동작이다.) 테스트 마다, 다시 새로운 User를 만들어서 테스트하기 위해 @BeforeEach를 적용했다.

 

이후 동시성 문제를 테스트 하기 위해, ExecutorService, CountDownLatch, Runnable을 활용하여 스레드를 만들고 실행시키기 위한 테스트 환경을 만들었다.

@Test
    public void testConcurrentSurveyCreation() throws Exception {
        int threadCount = 1;
        ExecutorService executorService = Executors.newFixedThreadPool(threadCount);
        CountDownLatch latch = new CountDownLatch(threadCount);

        List<User> test1 = userRepository.findAll();
        System.out.println( "Task 밖 결과 : " + test1.size());

        Runnable task = () -> {
            try {
                List<User> test2 = userRepository.findAll();
                System.out.println("Task 안 결과 : " + test2.size());
            } catch (Exception e) {
                e.printStackTrace();
            } finally {
                latch.countDown();
            }
        };

        for (int i = 0; i < threadCount; i++) {
            executorService.submit(task);
        }

        latch.await();
    }

위 코드는 1개의 스레드를 생성하고, userRepository.findAll()를 Runnable task 안과 밖에서 한 번씩 호출하고 값을 비교한다.

 

테스트 결과는 아래와 같다.

스레드 밖에서 호출한 userRepository.findAll()은 @BeforeEach에서 생성한 User 데이터를 불러오지만, 새로 생성한 스레드 안에서는 userRepository.findAll() 값이 0이 출력된다.

 

그럼 이번엔 @BeforeEach를 @BeforeAll로 수정해보고 다시 실행해 보았다.

 

이번엔 스레드 안에서도 정상적으로 User 데이터를 fetch 한다.

이러한 현상이 발생하는 이유를 알아보자.

메서드 수준 테스트 트랜잭션과 클래스 수준의 테스트 트랜잭션

메서드 수준에서 @Transactional 어노테이션이 적용되면 해당 테스트는 기본적으로 테스트 완료 후 자동으로 롤백되는 테스트 관리 트랜잭션 (test-managed Transaction) 내에서 실행된다.

그리고 @BeforeEach 또는 @AfterEach로 주석이 달린 메서드는 테스트 관리 트랜잭션 내에서 실행된다. 

 

예시를 살펴보자

public class UserServiceTest {

	@BeforeEach
    void setUp() {
      
    }
    
   @Test
   @Transactional
    public void method1(){
        try{
            method2();
        }catch(Exception e){

        }
    }
    
    public void method2(){

    }
}

위 코드에서 @BeforeEach가 적용된 setUp 메서드는 테스트 관리 트랜잭션에서 수행된다.

method1이 시작하면 @Transactional 어노테이션이 적용되어 있어 마찬가지로 테스트 관리 트랜잭션에서 함수가 수행되고, method2는 Transaction을 전파받는다. (참고로 여기서 self-invocation이 언급될 수 있는데, Transaction의 동작 원리인 AOP과 관련되어 있다. 여기선 자세히 설명하지 않겠다.) 결과적으로 setUp과 method1, method2는 같은 테스트 관리 트랜잭션에서 실행된다. 

 

반면, 클래스에서 @Transactional이 어노테이션 된 경우, 해당 클래스 계층 내의 각 테스트 메서드는 테스트 관리 트랜잭션 내에서 실행된다. 

@Transactional
public class UserServiceTest {

	@BeforeEach
    void setUp() {
      
    }
    
   @Test
    public void method1(){
        try{
            method2();
        }catch(Exception e){

        }
    }
    
    public void method2(){

    }
}

위 코드는 class 수준에서 @Transactional 어노테이션이 적용되어 있어, method1은 @Transactional이 없어도 테스트 트랙잭션 안에서 함수가 수행된다. (method2도 마찬가지)

Transaction은 Thread-Bound 하다.

Transaction은 Thread-bound 하다. 따라서 각 Thread 마다 고유한 트랜잭션을 가지고 있다.

실제로 디버그모드로 Java의 Thread 내부 구조를 살펴보면,

Thread -> ThreadLocal -> ThreadLocalMap에 inheritableThreadLocals에 TestContext 정보가 저장되어 있다.

(ThreadLocal이 무엇인지는 이 링크를 참조 [Spring] ThreadLocal에 대해 알아보자 + SecurityContextHolder, RequestContextHolder (tistory.com))

inheritableThreadLocals 안에 testContext가 있다. transaction 정보가 포함된다.
testContext 내부엔 persistenceContext와 어떤 Entity가 존재하는지를 포함한다.

따라서 Runnable Task 안에서 User Entitiy를 불러올 수 없는 이유는 새로운 스레드가 생성되어 Task 밖과 다른 트랜잭션을 가지고 있었고, Test DB에는 아직 User 데이터가 commit 되지 않았기 때문에 불러올 수 없었던 것이다.

 

참고로 class 위에 @Transactional 어노테이션을 지우면, 메서드마다 독립된 Transaction을 가지기 때문에 아래와 같이 메서드 안에 Thread는 Transaction 정보를 가지고 있지 않다.

테스트 관리 트랜잭션에서 실행되지 않는 경우

@BeforeAll 또는 @AfterAll로 주석이 달린 메서드 및 TestNG의 @BeforeSuite, @AfterSuite, @BeforeClass, @AfterClass로 주석이 달린 메서드는 테스트 관리 트랜잭션 내에서 실행되지 않는다. 

그래서 @BeforeAll을 사용했을 때 새로운 스레드에서 User 엔티티 접근이 가능했다. 이는 Test DB에서 데이터를 가져왔기 때문이다.

그림으로 표현하자면 위와 같이 표현할 수 있다.

 

또한 @BeforeTransaction 또는 @AfterTransaction이 적용된 메서드는 테스트 메서드의 트랜잭션 전후에 실행되는 메서드로, 트랜잭션이 시작되기 전이나 트랜잭션이 완료된 후에 특정 작업을 수행한다.

@BeforeTransaction 메서드는 테스트 트랜잭션 외부에서 실행되므로, 이 메서드에서 저장된 데이터는 트랜잭션 컨텍스트와 무관하게 Commit 된다.


이를 활용하면 테스트 메서드가 실행되기 전, 트랜잭션이 시작되기 전에 데이터베이스 상태를 확인하거나 초기화할 수 있다.

@BeforeTransaction
void verifyInitialDatabaseState(@Autowired DataSource dataSource) {
	// Use the DataSource to verify the initial state before a transaction is started
}


이 예제 코드는 @BeforeTransaction 어노테이션을 사용하여 트랜잭션이 시작되기 전에 데이터베이스의 초기 상태를 확인하는 메서드이다. 여기서는 @Autowired 어노테이션을 사용하여 DataSource Bean을 주입받아 데이터베이스의 상태를 검증할 수 있다.

 

아래 코드는 테스트 트랜잭션 생명 주기 예제이다.

@SpringJUnitConfig
@Transactional(transactionManager = "txMgr")
@Commit
class FictitiousTransactionalTest {

	@BeforeTransaction
	void verifyInitialDatabaseState() {
		// logic to verify the initial state before a transaction is started
	}

	@BeforeEach
	void setUpTestDataWithinTransaction() {
		// set up test data within the transaction
	}

	@Test
	// overrides the class-level @Commit setting
	@Rollback
	void modifyDatabaseWithinTransaction() {
		// logic which uses the test data and modifies database state
	}

	@AfterEach
	void tearDownWithinTransaction() {
		// run "tear down" logic within the transaction
	}

	@AfterTransaction
	void verifyFinalDatabaseState() {
		// logic to verify the final state after transaction has rolled back
	}

}

정리하자면,

  • @BeforeTransaction은 초기 데이터베이스 상태를 초기화하는 데 사용할 수 있다.
  • @BeforeEach는 DB 안에 데이터를 수정하거나 가져오는 데 사용할 수 있다.
  • @AfterEach는 Transaction을 정리할 때 사용할 수 있다.
  • @AfterTransaction은 Transaction이 commit 또는 rollback 되었을 때 데이터베이스 상태를 검증하기 위해 사용할 수 있다.

문제 원인 파악하기

문제 상황을 그림으로 표현하면 이렇다.

class 위에 @Transactional을 추가했기 때문에 테스트 클래스 Transaction에서 @BeforeEach, @Test와 같은 메서드 수준의 테스트는 같은 트랜잭션을 공유한다.

여기서 트랜잭션이 성공적으로 끝나면 실제 DB에 Commit을 하거나, 실패하면 Rollback을 하는 작업을 수행하는데, 클래스 수준에서 트랜잭션을 선언하다 보니 영속성 컨텍스트(Persistence Context)에는 엔티티가 있지만 DB에는 Commit을 하지 않는 현상이 발생하게 된 것이다. 

Transaction은 Thread-bound 하기 때문에 새로운 스레드에서 실행되는 Transaction은 Main 스레드와 별개이므로, User의 Entity를 가져올 수 없다. 

문제 해결하기 1: @BeforeTransaction

@BeforeTransaction을 활용하여 문제 상황을 해결할 수 있다.

이 어노테이션이 적용되면 외부 Transaction에서 수행되어 DB에 반영이 된다. 따라서 새로운 Thread는 DB에서 데이터를 가져온다.

@BeforeTransaction을 적용한 메서드에 회원가입을 수행
task 스레드 밖, 안 모두 회원가입한 User 데이터를 성공적으로 불러온다.

 

문제 해결하기 2: 클래스에 @Transactional 제거

class 상단에 @Transactional 어노테이션을 제거하면, @BeforeEach를 사용해도 테스트 메서드가 트랜잭션을 사용하지 않기 때문에, 바로 DB에 commit 하게 된다. 

@ActiveProfiles("test")
@SpringBootTest
@AutoConfigureMockMvc
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
// @Transactional
public class SurveyServiceConcurrencyTest extends BaseControllerTest {

 //...

    @BeforeEach
    void setupBeforeEach() throws Exception {

        UserRegisterRequestDto userRegisterRequestDto = UserRegisterRequestDto.builder()
                .name("test1")
                .email("test1@gmail.com")
                .password("Password40@")
                .phoneNumber("01012345678")
                .build();
       mockMvc.perform(post("/auth/register")
                .contentType(MediaType.APPLICATION_JSON)
                .content(objectMapper.writeValueAsString(userRegisterRequestDto)));

        UserLoginRequestDto userLoginRequestDto = UserLoginRequestDto.builder()
                .email("test1@gmail.com")
                .password("Password40@")
                .build();

        mockMvc.perform(post("/auth/login")
                .contentType(MediaType.APPLICATION_JSON)
                .content(objectMapper.writeValueAsString(userLoginRequestDto)));

			// ...
    }

    @Test
    public void testConcurrentSurveyCreation() throws Exception {
        int threadCount = 1;
        ExecutorService executorService = Executors.newFixedThreadPool(threadCount);
        CountDownLatch latch = new CountDownLatch(threadCount);
        List<User> test1 = userRepository.findAll();
        System.out.println( "Task 밖 결과 : " + test1.size());
        Thread thread = Thread.currentThread();

        Runnable task = () -> {
            try {
                List<User> test2 = userRepository.findAll();
                System.out.println("Task 안 결과 : " + test2.size());
            } catch (Exception e) {
                e.printStackTrace();
            } finally {
                latch.countDown();
            }
        };

        for (int i = 0; i < threadCount; i++) {
            executorService.submit(task);
        }

        latch.await();

테스트에서는 클래스 수준의 @Transactional 지양하기

위 상황과는 상관없지만 실제 운영하는 트랜잭션, 영속성 컨텍스트 환경과 테스트 환경이 다르면 LazyInitializationException과 관련된 문제가 발생할 수 있다.

가령 테스트 코드에서 테스트 메서드에 @Transactional을 적용하면, 테스트 메서드 전체가 하나의 트랜잭션 내에서 실행된다. 이 경우, 테스트 메서드가 끝날 때까지 영속성 컨텍스트가 열려 있기 때문에, 지연 로딩된 엔티티에 접근할 때 LazyInitializationException이 발생하지 않는다.

 

그런데 실제 서버 환경에서 보통 애플리케이션에서는 서비스 계층에서 트랜잭션이 관리되는데, 만약 서비스 계층에서 @Transaction을 사용하지 않는다면, 트랜잭션이 종료된 후에 지연 로딩된 엔티티에 접근하려고 할 때 영속성 컨텍스트가 이미 닫혀 있기 때문에 LazyInitializationException이 발생할 수 있다. 

 

즉, 테스트 메서드 자체에 @Transactional이 붙어있으면 위와 같은 실제 서버 환경을 재현하지 못하는 문제가 생긴다. 따라서 @Transactional을 신중하게 사용해야 한다.

마치며

  • 사실 이전까진 DB 작업이 필요하면 무의식적으로 @Transactional을 추가했는데 동작 원리를 알게 되니 신중하게 사용해야겠다고 느꼈다.
  • @Transactional은 AOP로 동작한다는 사실을 알게 되었다. 나중에 차차 더 공부해 봐야겠다.

참고