Sprign I/O 2024 강연에서도 해당 내용이 나왔는데, 간단하고 오피셜하게 해결할 수 있는 방법을 알아보자.
Problem
Spring JPA와 코루틴을 사용할 때 여러가지 문제가 발생한다.
하나씩 알아보자.
1. AOP 미동작
예시 코드에서 Repository는 JpaRepository를 사용한다.
interface OrderRepository : JpaRepository<Order, Long>
아래는 Service 클래스이다.
@Service
class OrderService(
private val orderRepository: OrderRepository,
) {
@Transactional
suspend fun submit(
id: Long,
throwException: Boolean = false,
) {
val order = orderRepository.findById(id).get()
order.submit()
orderRepository.save(order)
if (throwException) {
throw IllegalStateException("테스트 위한 에러")
}
}
@Transactional
fun submitNotSuspend(
id: Long,
throwException: Boolean = false,
) {
val order = orderRepository.findById(id).get()
order.submit()
orderRepository.save(order)
if (throwException) {
throw IllegalStateException("테스트 위한 에러")
}
}
}
throwException 파라미터에 true가 들어오면 예외를 발생할 것이고, 지난 변경사항을 롤백할 것이다. 2개 메서드는 동일하고 1개는 suspend 함수이고 나머지 1개는 일반 메서드이다.
아래는 데이터 Write가 정상적으로 수행되는 지와 예외가 발생했을 때 롤백이 되는 지에 대한 테스트 코드이다.
@SpringBootTest
class OrderServiceTest {
@Autowired
private lateinit var orderService: OrderService
@Autowired
private lateinit var orderRepository: OrderRepository
@Test
fun `정상 저장 테스트`() {
// given
val order =
orderRepository.save(
Order(
id = 1L,
status = OrderStatus.READY,
),
)
// when
runBlocking(Dispatchers.IO) {
orderService.submit(order.id)
}
// then
val result = orderRepository.findById(order.id).get()
assertThat(result.status).isEqualTo(OrderStatus.SUBMITTED)
}
@Test
fun `롤백 동작 테스트`() {
// given
val order =
orderRepository.save(
Order(
id = 1L,
status = OrderStatus.READY,
),
)
// when
runCatching {
runBlocking(Dispatchers.IO) {
orderService.submit(order.id, true)
}
}
// then
val result = orderRepository.findById(order.id).get()
assertThat(result.status).isEqualTo(OrderStatus.READY)
}
}
테스트를 실행해보면 롤백 동작 테스트가 깨진다. Exception이 발생해도 롤백이 되지 않는다.
suspend 메서드가 아닌 일반 메서드를 호출하면 테스트가 정상적으로 성공한다.
원인은 JPA는 코루틴에서의 Transaction을 지원하지 않는다. 코루틴의 Transaction은 MongoDB, R2DBC 등 Reactive 모듈에서만 제공한다.
해결 방법은 간단하다. CoroutineCrudRepository
와 R2DBC
를 사용하면 된다.
@Repository
interface OrderRepository : CoroutineCrudRepository<Order, Long> {
override suspend fun findById(id: Long): Order
}
그 결과 모든 테스트를 통과한다.
CoroutineCrudRepository와 R2DBC를 사용하면 아래 효과가 있다.
- 데이터 접근 시 suspend 함수를 사용해서 Blocking I/O를 방지할 수 있다.
- suspend 함수에서도 AOP를 활용한 트랜잭션을 지원한다.
2. 원하지 않는 범위의 롤백
JDBC의 Transaction에서는 ThreadLocal에 트랜잭션의 커넥션 정보를 저장한다. 문제는 코루틴 쓰레드는 1개의 요청만을 전담해서 처리하지 않는다.
코루틴의 쓰레드는 여러 요청의 Job을 수행하기 때문에 롤백될 때 원하지 않는 것들까지 롤백될 수 있다.
즉, 아래와 같이 1개 쓰레드에서 여러 개의 Job을 동시다발적으로 처리할 때도 정상적으로 동작해야 한다.
@Test
fun `동시성 테스트`() = runTest {
// given
repeat(2000) {
orderRepository.save(Order(status = OrderStatus.READY))
}
// when
val jobs = ArrayList<Job>()
for (i in 1L..2000L) {
val job = launch(Dispatchers.IO) {
orderService.submit(
id = i,
throwException = i % 10 == 0L,
)
}
jobs.add(job)
}
jobs.joinAll()
// then
val submittedOrders = orderRepository.findAll().toList().filter { it.status == OrderStatus.SUBMITTED }
assertThat(submittedOrders).hasSize(1800)
}
동시성 테스트도 무사히 통과했다.
테스트가 통과한 이유는 R2dbc
에서는 PlatformTransactionManager
가 아닌 ReactiveTransactionManager
를 사용하기 때문이다.
ReactiveTransactionManager
는 ThreadLocal
이 아닌 Reactor
의 Context
에 커넥션 정보를 보관한다. Reactor
의 Context
는 코루틴의 Context
와 호환된다.
결과적으로 Thread 1개가 여러 Job을 수행하더라도 각 Job 안에 커넥션 정보를 보관하기에 각 Job은 트랜잭션을 안전하게 보관할 수 있게 된다.
소감
잘 모르고 @Transactional
이 미동작한다는 이유로 TransactionTemplate, TransactionalOperator 등을 사용하고 있었다. (공식문서에서도 언급하지만, 나쁜 선택지는 아니다.)
잘 알고 있었더라면 CoroutineCrudRepository
를 활용해서 suspend
함수의 이점을 충분히 활용할 수 있었고, 기존 AOP를 활용한 코드 스타일을 유지할 수 있었다.
기회가 되면 더 자세히 학습해봐야겠다.
참고
'Server > Spring JPA' 카테고리의 다른 글
Connection Pool Deadlock 해결하기! (feat. REQUIRES_NEW) (12) | 2024.03.30 |
---|---|
Hibernate ORM 공식문서 읽어보기! (4) | 2023.12.19 |
QueryDsl에서 Index Hint 사용하기! (0) | 2023.12.08 |
JPA - OSIV 제대로 이해하기! (0) | 2023.11.19 |
Hibernate @Where 애노테이션이 동작하지 않는 이유! (0) | 2023.11.05 |