Notice
Recent Posts
Recent Comments
Link
나의 GitHub Contribution 그래프
Loading data ...
«   2024/04   »
1 2 3 4 5 6
7 8 9 10 11 12 13
14 15 16 17 18 19 20
21 22 23 24 25 26 27
28 29 30
Tags
more
Archives
Today
Total
관리 메뉴

Code in Life

코루틴에서 트랜잭션 처리하기 본문

Springboot

코루틴에서 트랜잭션 처리하기

퓨끼 2022. 6. 14. 00:58

개요

얼마전 코루틴과 @Transactional을 함께 사용했을 때 트랜잭션이 제대로 적용되지 않아서 당황했던 적이 있었는데요. 둘의 원리만 알고 있으면 쉽게 해결할 수 있었던 문제였지만 코루틴을 입문한 분들이라면 한번쯤 마주할뻔한 문제여서 이를 포스팅에서 다뤄보고자 합니다.

트랜잭션이 적용되지 않는 문제

마주했던 에러 문구입니다. 트랜잭션이 있어야할 곳에 트랜잭션이 없기 때문에 발생하고 있었습니다.

Executing an update/delete query; nested exception is javax.persistence.TransactionRequiredException: Executing an update/delete query

Executing an update/delete query Exception

해당 Exception이 발생한 지점을 찾아보니 update문을 실행하는 곳이였습니다.

update문을 처리하기 위해 메소드에 @Transactional을 분명히 지정해줬는데 왜 트랜잭션이 제대로 적용되지 않은 것일까요?
결론부터 말씀드리자면 코루틴(경량 스레드)을 생성하였기에 Proxy가 해당 스레드에는 관여할 수 없는 것입니다. 이를 좀 더 자세히 알아보기 위해 @Transactional의 동작 원리와 코루틴에서의 스레드를 살펴보겠습니다.

A. @Transactional의 동작 원리

AOP 기반으로 동작하여 Target이 상속하고 있는 인터페이스 또는 Target을 상속한 Proxy 객체가 생성되며, Proxy 객체의 메소드를 호출하면 전후로 트랜잭션을 처리합니다.

val status: TransactionStatus = transactionManager.getTransaction(DefaultTransactionDefinition())
try {
	tx.begin()
	modelRepository.updateTradePrice(request) // target
	tx.commit()
} catch (Exception e) {
	tx.rollback(status)
}

B. 코루틴에서의 스레드

launch, async를 통해 새로운 코루틴을 생성할 수 있습니다. 현재 스레드의 이름을 출력하는 예제 코드를 작성해서 코루틴에서의 스레드가 메인 스레드와 다른지를 확인해보겠습니다.

 

A, B로 비추어본 상황을 그림으로 표현하면 update문은 Proxy 객체로 감싸지지 않은 상태였기 때문에 트랜잭션이 적용되지 않은 것입니다.

@Service
class ModelService(
	private val modelRepository: ModelRepository
) {
	@Transactional 
	fun updateTradePrice(request: UpdateTradePriceRequest) {
		modelRepository.updateTradePrice(request) // target
	}
}

그렇다면 update문을 Proxy 객체(@Transactional)로 감싸만 준다면 해결될 것이고, 실제로도 업데이트가 잘 되는 것을 확인할 수 있습니다.
그러나, 이 코드에는 매우 심각한 문제가 있습니다. 그것은 바로 트랜잭션이 실패하여 롤백이 발생했을 때, 실패하지 않은 건에 대해서도 함께 롤백되는 치명적인 문제입니다.

비정상적인 롤백의 원인

이 문제가 발생하는 원인은 코루틴의 스레드가 다수의 Job을 가짐으로써, 각 Job에서 처리해야하는 update() 작업들이 동일한 스레드에서 하나의 트랜잭션으로 합쳐졌기 때문입니다.

tx.begin()
update() // job1
update() // job2 ~> Wrong!! 
update() // job3
tx.commit()

// --> job1 ~ job3 모두 ROLLBACK

JPA의 EntityManager는 내부적으로 DB 커넥션 풀을 사용해서 DB에 접근하는데, 각 스레드는 필요한 커넥션을 받아서 사용하게 됩니다. 이때 스레드는 여러 개의 job을 처리할 수 있고, 사용중인 커넥션은 동일합니다. 따라서 다수의 트랜잭션들이 하나의 커넥션 내에 묶여 처리된 것입니다.

[JPA EntityManager] 동일한 커넥션에 묶인 트랜잭션들

해결책

따라서 각 job별로 트랜잭션을 끊어서 실행시킬 수 있도록 해줘야합니다.
JPA에서 update/delete는 쓰기 지연을 사용하므로 Query가 생성되어 DB에 실행되는 시점은 flush 또는 commit되는 시점입니다. 이를 다음과 같은 방법으로 직접 명시해주면 됩니다.

1. flush 강제 호출

// *---- update()
// *---- flush()

2. 트랜잭션 전파 레벨을 REQUIRES_NEW로 설정
새로운 트랜잭션을 만듦으로써 트랜잭션을 하나로 묶이지 않게 합니다.

@Service
class ModelService(
		private val modelRepository: ModelRepository
) {
		@Transactional(Transactional.TxType.REQUIRES_NEW)
		fun updateTradePrice(request: UpdateTradePriceRequest) {
				modelRepository.updateTradePrice(request) // target
		}
}

3.TransactionTemplate
방법 2와 마찬가지로 새로운 트랜잭션을 만드는 것은 동일하며, 트랜잭션을 개발자가 직접 정의해서 사용하는 방식입니다.

@Bean
@Primary
fun coreTransactionManager(coreEntityManager: EntityManagerFactory): PlatformTransactionManager {
    return JpaTransactionManager(coreEntityManager)
}


private val transactionManager: PlatformTransactionManager
//...(중략)
val tx = transactionManager.getTransaction(DefaultTransactionDefinition())
// *----- update() 
transactionManager.commit(tx)
  • DefaultTransactionDefinition() : 트랜잭션 구현 객체
  • getTransaction() : 트랜잭션의 설정정보를 가져오는 메소드
Comments