ClOr

ClOr

백엔드 실무 트러블슈팅과 AI 에이전트 구조 분석을 기록합니다.

Claude Code 해부학 (완결)

51만 줄 소스코드를 19편에 걸쳐 분석한 완결 시리즈

전체 시리즈 보기 →

백엔드 트러블슈팅

실무에서 겪은 장애와 해결 과정 기록

전체 시리즈 보기 →

최신 글

article thumbnail

목차

  • 시리즈 안내
  • 결론부터
  • 환경
  • 증상: 고쳤는데 왜 재발하나
  • 원인 1: AOP가 Spring을 우회했다
  • 원인 2: 스키마 전환이 불안정했다
  • 원인 3: 트랜잭션 소유권의 레이어 역전
  • 해결: 아키텍처 재설계
  • 1~2편 다시 보기
  • 요약
    • 관련 글

시리즈 안내

사내 업무 자동화 프로젝트에서 Kafka 기반 전사 조직 동기화를 구현하면서 겪은 시리즈의 마지막 편이다.

  1. 트랜잭션 중첩이 만든 유령 롤백
  2. batchInsert Lock Wait Timeout과 청크 전략
  3. Reconciliation — "없는 것"을 처리하는 기술
  4. 멀티테넌시 + @Async에서 ThreadLocal이 사라지는 문제
  5. 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 ORM transaction{}은 둘 다 "내가 트랜잭션 주인"이라고 가정한다. 한쪽이 완전히 제어하고 다른 쪽은 따라가는 구조가 아니면 깨진다.
  • 커스텀 AOP로 우회하면 겉은 같아도 propagation이 무시된다. 테스트에서도 잡기 어렵다.
  • 증상(Lock Timeout, 유령 롤백)이 줄었다고 원인을 찾은 게 아니다. "왜 하나의 큰 트랜잭션이었는지"를 물었어야 했다.
  • 내부 API(@OptIn(InternalApi::class))를 쓰는 건 부담이지만, 공식 API만으로 Spring 통합이 불가능할 때는 선택지가 된다. 대신 왜 썼는지 문서로 남겨야 한다.

관련글:


관련 글

profile

ClOr

@ClOr

포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!

ClOr · 백엔드 트러블슈팅과 AI 에이전트 구조 분석을 기록합니다.