목차
- 시리즈 안내
- 결론부터
- 환경
- 증상: 고쳤는데 왜 재발하나
- 원인 1: AOP가 Spring을 우회했다
- 원인 2: 스키마 전환이 불안정했다
- 원인 3: 트랜잭션 소유권의 레이어 역전
- 해결: 아키텍처 재설계
- 1~2편 다시 보기
- 요약
- 관련 글
시리즈 안내
사내 업무 자동화 프로젝트에서 Kafka 기반 전사 조직 동기화를 구현하면서 겪은 시리즈의 마지막 편이다.
- 트랜잭션 중첩이 만든 유령 롤백
- batchInsert Lock Wait Timeout과 청크 전략
- Reconciliation — "없는 것"을 처리하는 기술
- 멀티테넌시 + @Async에서 ThreadLocal이 사라지는 문제
- Spring @Transactional과 Exposed ORM이 안 맞을 때 ← 이번 편
결론부터
1편의 유령 롤백과 2편의 Lock Wait Timeout은 증상이었다. 근본 원인은 Spring의 @Transactional 전파 메커니즘과 Exposed ORM의 트랜잭션 시스템이 아키텍처 수준에서 호환되지 않는 것이었다. 커스텀 AOP가 Spring의 TransactionInterceptor를 우회하면서 REQUIRES_NEW가 무시됐고, Exposed의 exec()을 통한 스키마 전환이 커넥션 풀 재사용 시 불안정했다.
환경
- Kotlin 2.2 + Spring Boot 3.3
- Exposed ORM (1.0.0-rc-4)
- Schema-per-Tenant 멀티테넌시 (MariaDB/MSSQL)
- Apache Kafka (조직/사용자 동기화)
증상: 고쳤는데 왜 재발하나
1편에서 Shared/Tenant 스키마 혼재를 잡았다. 2편에서 Lock Wait Timeout에 배치 청크 분할을 적용했다. 장애 빈도는 줄었는데, 같은 코드와 같은 데이터에서 어떤 날은 되고 어떤 날은 안 됐다.
트랜잭션 매니저 자체를 의심하기 시작했다. 그리고 발견한 건 1~2편에서 고친 것들이 증상이었고, 병은 트랜잭션 인프라 자체에 있었다는 것이다.
[표면] Lock Wait Timeout (2편에서 다룸)
↓
[중간] Shared/Tenant 스키마 컨텍스트 오염 (1편에서 다룸)
↓
[근본] Spring @Transactional ↔ Exposed ORM 아키텍처 불일치
원인 1: AOP가 Spring을 우회했다
멀티테넌시를 위해 @TenantTransactional이라는 커스텀 어노테이션을 만들었다. 문제는 이걸 처리하는 방식이었다.
// 초기 구현: 커스텀 AOP Aspect가 직접 트랜잭션을 관리
@Aspect @Component
class TenantTransactionalAspect(
private val transactionManager: MultiTenantTransactionManager
) {
@Around("@annotation(TenantTransactional)")
fun around(joinPoint: ProceedingJoinPoint): Any? {
return transactionManager.transaction {
joinPoint.proceed()
}
}
}
이 구조에서 Spring의 TransactionInterceptor는 완전히 우회된다. AOP Aspect가 직접 MultiTenantTransactionManager.transaction{}을 호출하므로, Spring이 전파를 제어할 기회가 없다.
// 이렇게 선언해도 REQUIRES_NEW가 무시된다
@TenantTransactional(propagation = Propagation.REQUIRES_NEW)
fun syncAllUsers() { ... }
// → 새 트랜잭션이 안 만들어지고 기존 트랜잭션에 합류
1편에서 "self-injection으로 독립 트랜잭션을 보장했다"고 썼는데, REQUIRES_NEW가 무시되고 있었으니 실제로는 독립 트랜잭션이 아니었을 가능성이 높다.
원인 2: 스키마 전환이 불안정했다
Schema-per-Tenant에서 테넌트를 바꾸려면 DB 커넥션에 USE schema_name을 실행해야 한다. 초기에는 Exposed의 exec()로 처리했다.
fun <T> transaction(block: () -> T): T {
return transaction(db = tenantDatabase) {
exec("USE `$schema`") // Exposed의 exec()
block()
}
}
두 가지 문제가 있다.
첫째, exec()는 Exposed 트랜잭션 컨텍스트 안에서만 동작한다. Spring의 doBegin()에서 스키마를 전환하고 싶어도, 아직 Exposed 트랜잭션이 시작되지 않은 시점이라 exec()를 쓸 수 없었다.
둘째, HikariCP가 커넥션을 재사용할 때 이전 트랜잭션에서 전환한 스키마가 남아있을 수 있다. 새 트랜잭션이 시작될 때 스키마를 명시적으로 리셋하지 않으면 다른 테넌트의 데이터를 읽을 수 있다.
원인 3: 트랜잭션 소유권의 레이어 역전
Spring의 AbstractPlatformTransactionManager는 JDBC 트랜잭션을 직접 제어하도록 설계되어 있다. doBegin()에서 커넥션을 열고, doCommit()에서 커밋한다.
그런데 초기 구현에서는 Spring이 Exposed의 transaction{} 블록을 감싸는 구조였다. 실제 JDBC 트랜잭션은 Exposed가 관리하고, Spring은 그 위에 올라탄 형태다.
Spring이 기대하는 구조: Spring → JDBC Connection → DB
실제 구조: Spring → Exposed transaction{} → JDBC → DB
↑ 여기가 실제 트랜잭션 주인이 역전 때문에 Spring이 설정한 isolation 레벨이 전달되지 않았고, timeout도 적용되지 않았으며, REQUIRES_NEW로 새 트랜잭션을 만들어야 할 때 Exposed가 이미 열어놓은 트랜잭션을 닫을 수 없었다.
해결: 아키텍처 재설계
패치가 아니라 재설계가 필요했다. 핵심 변경은 세 가지다.
1) 메타 어노테이션으로 전환
커스텀 AOP Aspect를 제거하고, @TenantTransactional을 Spring @Transactional의 메타 어노테이션으로 바꿨다.
// Spring @Transactional의 메타 어노테이션
@Transactional(transactionManager = "tenantTransactionManager")
annotation class TenantTransactional(
@get:AliasFor(annotation = Transactional::class, attribute = "propagation")
val propagation: Propagation = Propagation.REQUIRED,
// isolation, timeout, readOnly, rollbackFor... 전부 지원
)
이제 Spring의 TransactionInterceptor가 직접 트랜잭션을 관리한다.
2) Exposed 내부 API로 직접 제어
Spring의 doBegin()에서 Exposed의 JdbcTransaction을 직접 생성하고 ThreadLocal 스택에 넣는다.
@OptIn(InternalApi::class)
override fun doBegin(transaction: Any, definition: TransactionDefinition) {
val schema = TenantContext.getCurrentTenant()
// Exposed 트랜잭션 직접 생성
val newTransaction = manager.newTransaction(isolationLevel, readOnly, outerTransaction)
ThreadLocalTransactionsStack.pushTransaction(newTransaction)
// raw JDBC Connection에서 스키마 전환 — exec() 아님
val jdbcConnection = newTransaction.connection.connection as Connection
jdbcConnection.createStatement().use { it.execute("USE `$schema`") }
}
exec() 대신 raw JDBC Connection에서 직접 스키마를 전환한다.
3) REQUIRES_NEW를 위한 suspend/resume
기존 트랜잭션을 잠시 멈추고 새 트랜잭션을 여는 doSuspend/doResume을 구현했다.
override fun doSuspend(transaction: Any): Any {
ThreadLocalTransactionsStack.popTransaction()
return SuspendedResources(transaction, currentSchema)
}
override fun doResume(transaction: Any?, suspendedResources: Any) {
val resources = suspendedResources as SuspendedResources
ThreadLocalTransactionsStack.pushTransaction(resources.transaction)
switchToSchema(resources.transaction, resources.schema)
}
이 작업 이후에도 MSSQL 환경에서 스키마 전환이 조용히 실패하는 문제가 남았다. 결국 전환 후 검증 로직을 추가했다. 해당 커밋 메시지는
"스키마 선택 오류 해결 (제발)"이었다.
1~2편 다시 보기
재설계 관점에서 이전 글들을 보정한다.
1편 (유령 롤백)에서 "트랜잭션 중첩(NESTED 전파)이 발생했다"고 썼는데, 정확히는 REQUIRES_NEW로 분리해야 할 트랜잭션이 AOP 우회로 인해 분리되지 않았던 것이다. 하위 서비스의 실패가 상위로 전파된 건 같은 트랜잭션이었기 때문이다.
2편 (Lock Wait Timeout)에서 "하나의 트랜잭션에서 수천 건의 gap lock이 유지되었다"는 맞는 진단이지만, 왜 하나의 큰 트랜잭션이었는지를 짚지 못했다. REQUIRES_NEW가 작동했다면 각 단계가 별도 트랜잭션이었을 것이고, lock 보유 시간이 자연스럽게 줄었을 것이다.
요약
- Spring
@Transactional과 Exposed ORMtransaction{}은 둘 다 "내가 트랜잭션 주인"이라고 가정한다. 한쪽이 완전히 제어하고 다른 쪽은 따라가는 구조가 아니면 깨진다. - 커스텀 AOP로 우회하면 겉은 같아도
propagation이 무시된다. 테스트에서도 잡기 어렵다. - 증상(Lock Timeout, 유령 롤백)이 줄었다고 원인을 찾은 게 아니다. "왜 하나의 큰 트랜잭션이었는지"를 물었어야 했다.
- 내부 API(
@OptIn(InternalApi::class))를 쓰는 건 부담이지만, 공식 API만으로 Spring 통합이 불가능할 때는 선택지가 된다. 대신 왜 썼는지 문서로 남겨야 한다.
관련글:
- Schema-per-Tenant 멀티테넌시 구현 — 이 글에서 다루는 멀티테넌시 아키텍처의 전체 설계
- Kafka 동기화 트랜잭션에서 실제로 자주 터지는 장애 5가지 — Kafka 트랜잭션 운영 장애 패턴 정리
- WebClient .block() → Kotlin Coroutine 전환기 — 같은 프로젝트에서 진행한 비동기 전환
관련 글
'백엔드 트러블슈팅' 카테고리의 다른 글
| Tauri + Python 프로세스 간 통신 삽질기 — stdout 파이프가 영원히 안 끝날 때 (0) | 2026.03.29 |
|---|---|
| WebClient .block() → Kotlin Coroutine 전환기: suspend · @Transactional 충돌 해결 (0) | 2026.03.29 |
| Windows 환경 Blue-Green 무중단 배포 구축기: 50커밋 삽질 완전 정리 (0) | 2026.03.29 |
| Reactor 파이프라인 설계기: External Task 200건 동시 처리 · ConnectableFlux 삽질 (0) | 2026.03.29 |
| Schema-per-Tenant 멀티테넌시 구현: JPA 대신 Exposed를 선택한 이유와 실전 삽질 (0) | 2026.03.29 |
