본문 바로가기

스프링 부트

@Transactional 메서드에서 다른 클래스의 @Async 메서드를 호출하면? (feat. 트랜잭션과 스레드)

서로 다른 스레드 간 트랜잭션 공유를 초점으로, 스프링이 트랜잭션을 어떻게 관리하는지 아주 일부분 관찰해본 글입니다.

 

(참고) 아주*1000 간단한 테스트 환경

// main에 @EnableAsync

@Transactional
@Service
class TxService(
    private val testEntityRepository: TestEntityRepository,
    private val innerService: AsyncService
) {

    fun saveOneAndCallException() {
        logger.info("[TxService][saveOneAndCallException] 호출. Thread id: ${Thread.currentThread().id}")
        // 테스트 엔티티 1 save
        testEntityRepository.save(createTestEntity())
        // exception 날리는 메서드 호출
        innerService.doException()
    }

    fun saveOneAndCallAsyncException() {
        logger.info("[TxService][saveOneAndCallAsyncException] 호출. Thread id: ${Thread.currentThread().id}")
        // 테스트 엔티티 1 save
        testEntityRepository.save(createTestEntity())
        // @async로 exception 날리는 메서드 호출
        innerService.doAsyncException()
    }

    private fun createTestEntity() = TestEntity(name = "test")
}


// ...

@Service
class InnerService {

    fun doException() {
        logger.info("[AsyncService][doException] 호출. Thread id: ${Thread.currentThread().id}")
        throw IllegalArgumentException("[AsyncService] exception")
    }

    @Async
    fun doAsyncException() {
        logger.info("[AsyncService][doAsyncException] 호출. Thread id: ${Thread.currentThread().id}")
        throw IllegalArgumentException("[AsyncService] async exception")
    }
}
  • TxService : 트랜잭션이 걸려있는 서비스
  • InnerService : TxService에서 호출할 서비스로, exception을 던질 서비스 (@Async 메서드 존재)

테스트 

@IntegrationTest
class TxServiceTest() {

    @Autowired
    private lateinit var txService: TxService

    @Autowired
    private lateinit var testEntityRepository: TestEntityRepository

    @Test
    fun `Transaction 메서드에서 호출한 일반 메서드 예외 발생 시, 호출한 메서드는 롤백 된다`() {
        try {
 			// 롤백되어 저장된 count 0
            txService.saveOneAndCallException()
        } catch (e: Exception) {
            logger.info("[TxServiceTest] exception")
        }
        assertThat(testEntityRepository.count()).isEqualTo(0)
    }

    @Test
    fun `Transaction 메서드에서 호출한 async 메서드 예외 발생 시, 호출한 메서드는 커밋 된다`() {
        try {
			// 커밋되어 저장된 count 1
            txService.saveOneAndCallAsyncException()
        } catch (e: Exception) {
            logger.info("[TxServiceTest] async exception")
        }
        assertThat(testEntityRepository.count()).isEqualTo(1)
    }
}

테스트는 성공한다. 

@Transactional이 달린 메서드에서 호출한 일반 메서드 예외 발생 시, 호출한 메서드는 롤백 된다

  • TxService, InnerService 동일한 스레드 (Thread id: 13)

@Transactional이 달린 메서드에서 호출한 async 메서드 예외 발생 시, 호출한 메서드는 커밋 된다

  • TxService, InnerService 각각 다른 스레드 (Thread id: 13, Thread id: 25)

즉 @Transactional 메서드에서 @Async 메서드를 호출하면 서로 다른 스레드에서 동작하기 때문에
@Async 메서드에서 unchecked exception이 나더라도 롤백되지 않는다.

 

음... 근데 서로 다른 스레드와 트랜잭션은 무슨 관계이길래? 좀 더 자세히...

스프링에서 트랜잭션을 관리하는 부분을 조금 더 살펴보기 위해, PlatformTransactionManager 를 주입받고,
트랜잭션을 가져오는 부분을 디버깅을 걸어봅시다!

step into를 해봅시다.

AbstractPlatformTransactionManager#getTransaction

PlatformTransactionManager 인터페이스를 구현한 추상 클래스 AbstractPlatformTransactionManager 의 메서드이다.
트랜잭션을 가져오는 메서드 부분을 보니 doGetTransaction()을 호출하고 있다. 다시 step into,

JpaTransactionManager#doGetTransaction

JPA 사용으로, PlatformTransactionManager의 실구현체인 JpaTransactionManager 로 왔다. 자세히 보면,

ConnectionHolder conHolder = (ConnectionHolder) TransactionSynchronizationManager.getResource(getDataSource());

해당 부분이 실제 커넥션을 관리하는 ConnectionHolder를 가져오는 부분으로 유추해본다.

ConnectionHolder가 뭐죠?

Resource holder wrapping a JDBC Connection. DataSourceTransactionManager binds instances of this class to the thread, for a specific javax.sql.DataSource.
Inherits rollback-only support for nested JDBC transactions and reference count functionality from the base class.

JDBC 연결을 랩핑하는 리소스 홀더로, 상속하고 있는 ResourceHolderSupport가 중첩 JDBC를 위한 롤백을 지원한다.

 다시 step into를 해봅시다.

TransactionSynchronizationManager#getResource()

TransactionSynchronizationManager는 뭐죠?

Central delegate that manages resources and transaction synchronizations per thread. To be used by resource management code but not by typical application code.
Supports one resource per key without overwriting, that is, a resource needs to be removed before a new one can be set for the same key. Supports a list of transaction synchronizations if synchronization is active.
Resource management code should check for thread-bound resources, e.g. JDBC Connections or Hibernate Sessions, via getResource. Such code is normally not supposed to bind resources to threads, as this is the responsibility of transaction managers. A further option is to lazily bind on first use if transaction synchronization is active, for performing transactions that span an arbitrary number of resources.

스레드당 리소스 및 트랜잭션 동기화를 관리하는 중앙 대리자. 리소스 관리 코드에서 사용되지만 일반적인 응용 프로그램 코드에서 사용되지는 않습니다. 키당 하나의 리소스를 덮어쓰지 않고 지원합니다.
즉, 동일한 키에 새 리소스를 설정하려면 먼저 리소스를 제거해야 합니다. 동기화가 활성 상태인 경우 트랜잭션 동기화 목록을 지원합니다. 리소스 관리 코드는 getResource를 통해 스레드 바인딩된 리소스(예: JDBC 연결 또는 최대 절전 모드 세션)를 검사해야 합니다.
이러한 코드는 일반적으로 리소스를 스레드에 바인딩하지 않아야 하는데, 이는 트랜잭션 관리자의 책임이기 때문이다. 또 다른 옵션은 트랜잭션 동기화가 활성화되어 있는 경우 처음 사용할 때 임의 개수의 리소스에 걸쳐 트랜잭션을 수행할 때 천천히 바인딩하는 것입니다.

이해한대로 한줄 요약하면, 스레드의 리소스, 트랜잭션의 synchronizations을 보장하도록 관리하는 얘.

여기서 얻어오는 resources 필드를 봐보면...

트랜잭션에 관한 정보들을 ThreadLocal로 관리하고 있음을 확인할 수 있다. 

조금 정리를 해보면...

TransactionSynchronizationManager는 트랜잭션에 관한 정보들을 ThreadLocal로 관리한다. 
즉 트랜잭션은 하나의 스레드에서만 관리(생성, 종료 등)될 수 있다. 
때문에 트랜잭션이 걸린 메서드에서 Async를 사용해 비동기(다른 스레드로) 메서드 호출을 하면,
호출한 메서드와 호출된 메서드는 서로 다른 스레드에서 동작하기 때문에 트랜잭션을 공유할 수 없다. 

 

번외로 확인해 본 것

그러면 Async 메서드에 @Transactional 을 걸고 트랜잭션 정보를 확인해보고 싶었다. (당연한 결과가 나오겠지만)

트랜잭션 로깅하기

application.properties

logging.level.org.springframework.orm.jpa=DEBUG
logging.level.org.springframework.transaction=DEBUG

@Async + @Transactional

//...
    @Async
    @Transactional
    fun doAsyncException() {
        logger.info("[InnerService][doAsyncException] 호출. Thread id: ${Thread.currentThread().id}")
        throw IllegalArgumentException("IllegalArgumentException 발생!!!!!!!!")
    }

TxService.saveOneAndCallAsyncException 와 InnerService.doAsyncException 두 메서드에서 서로 다른 트랜잭션이 create된 걸 확인할 수 있다. (transaction의 전파 속성 기본: Propagation.REQUIRED)

REQUIRED 속성은 부모 트랜잭션 내에서 실행하며, 부모 트랜잭션이 없을 경우 새로운 트랜잭션을 생성하는 전파 속성이다. 
트랜잭션이 새로 열린 것을 보면 부모 트랜잭션이 없다고 판단한 것으로 볼 수 있다.

찐막으로... MANDATORY 속성이면 예외가 날까?

  • 부모 트랜잭션 내에서 실행되며, 부모 트랜잭션이 없을 경우 Exception이 발생

당연하게도, 서로 다른 스레드에서 동작하고 있으니 TreadLocal로 관리되고 있는 부모의 트랜잭션 정보를 얻지 못한다!!!

ThreadLocal?

정말 간단하게 말하면, 여러 스레드에서 한 개의 ThreadLocal를 참조하더라도 서로 독립적인 값을 가지며 다른 스레드의 값을 볼 수 없다.
ThreadLocal은 추후에 다시 다루어 보려고 한다. 다음 글의 주제가 되지 않을까 하는...

일단 참고하면 좋은 글 공유