Transactional 한계 극복해보기
🥑 들어가며
이전 프로젝트를 진행하며 기술 블로그를 참고하여 트랜잭션 로직을 직접 구현한 경험이 있었다. 당시에는 AOP에 대한 이해가 부족해, 왜 그렇게 동작하는지 정확히 알지 못한 채 코드를 작성했었다. 하지만 오늘 카카오페이 테크 블로그의 글과 그때 작성했던 코드를 다시 살펴보며, 그 이유를 명확히 이해할 수 있게 되었다. 이를 잊지 않기 위해 정리하게 되었다.
🚨 @Transactional 한계
이전에 작성했던 글을 보면 @Transactional의 한계를 비교적 명확하게 확인할 수 있다. 간단히 말해, AOP는 프록시 패턴을 기반으로 동작하며 @Transactional은 AOP의 대표적인 활용 사례라는 점이다. 이로 인해 트랜잭션은 객체 외부에서 최초로 진입하는 메서드를 기준으로 적용되며, 동일 객체 내부의 메서드 호출(self-invocation)에는 트랜잭션이 적용되지 않는다.
그렇다면 이러한 불편함은 어떻게 해결할 수 있을까?
해답은 아이러니하게도, 당시 원리를 제대로 이해하지 못한 채 작성했던 이전 프로젝트 코드에 있었다. 그동안은 트랜잭션 적용을 위해 TransactionalService와 같은 별도의 클래스를 만들어 사용해왔는데, 이 방식을 활용하면 트랜잭션을 위해 굳이 클래스를 분리하지 않아도 동일한 문제를 해결할 수 있다.
💡 @Transactional 극복해보기
구현
먼저, 나는 코드의 가독성을 위해 따로 @ReadOnlyTransactional을 만들었다. 나는 의도적으로 REQUIRED를 사용했다. 즉, 읽기에도 항상 트랜잭션을 시작하게 한다.
1
2
@Transactional(readOnly = true, propagation = Propagation.REQUIRED)
annotation class ReadOnlyTransactional()
트랜잭션을 걸어주기 위한 클래스 하나를 생성한다. @Transactional을 붙여 해당 로직에 AOP를 적용시킨다. 이 클래스가 트랜잭션 경계를 보장할 진입점 클래스가 되는 것이다.
1
2
3
4
5
6
7
8
9
@Component
class TransactionRunner {
@Transactional
fun <T> run(block: () -> T): T = block()
@ReadOnlyTransactional
fun <T> runReadOnly(block: () -> T): T = block()
}
하지만, 이 코드만 있다면 트랜잭션이 필요한 메서드마다 bean을 주입해야하는 불편함이 있다. 그래서 어디서나 간단히 쓸 수 있게 전역 퍼사드 Tx를 둔다. 초기화 가드를 넣어(1회 초기화 보장) 실수로 인한 오류를 줄인다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
object Tx {
@Suppress("ktlint:standard:backing-property-naming")
private lateinit var _txRunner: TransactionRunner
fun initialize(txRunner: TransactionRunner) {
_txRunner = txRunner
}
fun <T> writable(function: () -> T): T = _txRunner.run(function)
fun <T> readable(function: () -> T): T = _txRunner.runReadOnly(function)
}
또한 부팅 시 1회 초기화시키도록 Configuration을 작성한다.
1
2
3
4
5
6
7
@Configuration
class TransactionConfig {
@Bean("txInitBean")
fun txInitialize(txRunner: TransactionRunner): InitializingBean =
InitializingBean { Tx.initialize(txRunner) }
}
이제 내부 메서드를 호출하더라도, Tx -> TransactionRunner(프록시) -> 실제 메서드 순으로 호출되어 AOP가 적용된다.
그렇다면 언제 이 패턴이 유리할까? self-invocation 이슈가 잦은 팀/코드베이스나 코드 블록 단위로 경계를 명시하고 싶은 팀
전파와 합류 규칙 요약(REQUIRED 기준)
Tx.readable은REQUIRED이므로 바깥 트랜잭션이 없어도 읽기 전용 트랜잭션을 시작한다.writable내부에서readable을 호출하면,readable은 기존 트랜잭션에 합류하고readOnly=false(=writable)의 성질을 유지한다. 즉 읽기 호출이더라도 바깥이 쓰기라면 쓰기 트랜잭션으로 동작한다. 읽기를 있을 때만 합류, 없으면 비트랜잭션으로 두고 싶다면Propagation.SUPPORTS로 바꾼다. 이 글의 테스트는 REQUIRED 기준이다.
테스트
트랜잭션 활성/읽기전용 여부, 합류, 커밋/롤백을 검증한다.
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
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
@ExtendWith(SpringExtension::class)
@ContextConfiguration(classes = [TxIntegrationTest.TxTestConfig::class])
class TxIntegrationTest {
@Test
fun writable_starts_transaction_and_not_readOnly() {
var active = false
var readOnly = true
val result = Tx.writable {
active = TransactionSynchronizationManager.isActualTransactionActive()
readOnly = TransactionSynchronizationManager.isCurrentTransactionReadOnly()
42
}
assertEquals(42, result)
assertTrue(active, "writable should start a transaction")
assertFalse(readOnly, "writable transaction should not be read-only")
}
@Test
fun readable_with_REQUIRED_starts_transaction_and_is_readOnly() {
var active = false
var readOnly = false
Tx.readable {
active = TransactionSynchronizationManager.isActualTransactionActive()
readOnly = TransactionSynchronizationManager.isCurrentTransactionReadOnly()
}
assertTrue(active, "readable with REQUIRED should start a transaction when none exists")
assertTrue(readOnly, "readable transaction should be read-only")
}
@Test
fun readable_inside_writable_joins_existing_tx_and_is_not_readOnly() {
var innerActive = false
var innerReadOnly = true
Tx.writable {
Tx.readable {
innerActive = TransactionSynchronizationManager.isActualTransactionActive()
innerReadOnly = TransactionSynchronizationManager.isCurrentTransactionReadOnly()
}
}
assertTrue(innerActive, "readable should join existing transaction (SUPPORTS)")
assertFalse(innerReadOnly, "joined transaction should keep writable semantics (not read-only)")
}
@Test
fun writable_commits_on_success_and_rolls_back_on_runtime_exception() {
var committedStatus: Int? = null
var rolledBackStatus: Int? = null
// commit path
Tx.writable {
TransactionSynchronizationManager.registerSynchronization(object : TransactionSynchronization {
override fun afterCompletion(status: Int) {
committedStatus = status
}
})
}
assertEquals(TransactionSynchronization.STATUS_COMMITTED, committedStatus)
// rollback path
assertThrows(RuntimeException::class.java) {
Tx.writable {
TransactionSynchronizationManager.registerSynchronization(object : TransactionSynchronization {
override fun afterCompletion(status: Int) {
rolledBackStatus = status
}
})
throw RuntimeException("boom")
}
}
assertEquals(TransactionSynchronization.STATUS_ROLLED_BACK, rolledBackStatus)
}
@TestConfiguration
@EnableTransactionManagement
class TxTestConfig {
@Bean
fun dataSource(): DataSource = DriverManagerDataSource().apply {
setDriverClassName("org.h2.Driver")
url = "jdbc:h2:mem:widdle-tx;DB_CLOSE_DELAY=-1;MODE=PostgreSQL"
username = "sa"
password = ""
}
@Bean
fun transactionManager(ds: DataSource): PlatformTransactionManager = DataSourceTransactionManager(ds)
@Bean
fun transactionRunner(): TransactionRunner = TransactionRunner()
// Initialize Tx with the TransactionRunner bean
@Bean
fun txInitializer(runner: TransactionRunner): InitializingBean = InitializingBean {
Tx.initialize(runner)
}
}
}
