ClOr

ClOr

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

Claude Code 해부학 (완결)

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

전체 시리즈 보기 →

백엔드 트러블슈팅

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

전체 시리즈 보기 →

최신 글

article thumbnail

시리즈 목차
1편: 트랜잭션 중첩 문제
2편: batchInsert & Lock Wait Timeout
3편: Reconciliation
4편: ThreadLocal 소실 (현재 글)

이전 편 요약

1~3편에서 트랜잭션 중첩, Lock Wait Timeout, reconciliation을 모두 해결했다. 동기화 로직은 완벽해 보였다. 그래서 비동기로 전환했다. 그리고 모든 게 터졌다.


증상: "Tenant context not set"

동기화를 API로 트리거하면 이런 에러가 발생했다:

ETNotFoundException: User context not available in async execution

더 심한 경우:

Tenant context not set — 잘못된 스키마에 접근하거나 NPE 발생

동기 실행에서는 정상인데, @Async를 붙이는 순간 터졌다.

원인: ThreadLocal은 스레드를 넘어가지 않는다

Schema-per-Tenant 멀티테넌시에서 현재 테넌트 정보는 TenantContext라는 ThreadLocal에 저장된다.

object TenantContext {
    private val current = ThreadLocal<String>()

    fun set(tenantId: String) = current.set(tenantId)
    fun get(): String = current.get()
        ?: throw IllegalStateException("Tenant context not set")
    fun clear() = current.remove()
}
실행 방식 스레드 TenantContext 결과
동기 (@Service) 호출자와 동일 유지됨 ✓ 정상
비동기 (@Async) 새 스레드 빈 상태 NPE / 잘못된 스키마
코루틴 (suspend) 디스패처 스레드 빈 상태 동일 문제

ThreadLocal은 스레드 로컬이다. 이름 그대로다. 스레드가 바뀌면 값이 없다. 너무 당연한 건데, 비동기 전환할 때 깜빡했다.


해결 1: @Async — TaskDecorator로 컨텍스트 전파

Spring의 TaskDecorator를 사용하면, 비동기 작업 실행 전에 원래 스레드의 컨텍스트를 새 스레드로 복사할 수 있다.

Before (컨텍스트 소실)

@Async
fun syncOrganization(tenantId: String) {
    // ← 여기서 TenantContext.get()하면 NPE!
    val schema = TenantContext.get()  // ← 빈 상태
    repository.upsertAll(data)
}

After (TaskDecorator 적용)

class TenantContextDecorator : TaskDecorator {
    override fun decorate(runnable: Runnable): Runnable {
        // 호출 스레드에서 컨텍스트 캡처
        val tenantId = TenantContext.get()
        val mdcContext = MDC.getCopyOfContextMap() ?: emptyMap()

        return Runnable {
            try {
                // 새 스레드에 컨텍스트 주입
                TenantContext.set(tenantId)
                MDC.setContextMap(mdcContext)
                runnable.run()
            } finally {
                TenantContext.clear()
                MDC.clear()
            }
        }
    }
}
@Configuration
class AsyncConfig {
    @Bean("syncExecutor")
    fun syncExecutor(): TaskExecutor {
        val executor = ThreadPoolTaskExecutor()
        executor.corePoolSize = 4
        executor.maxPoolSize = 8
        executor.setTaskDecorator(TenantContextDecorator())  // ← 핵심
        executor.initialize()
        return executor
    }
}

해결 2: Coroutine — CoroutineContext로 전파

코루틴은 TaskDecorator가 안 통한다. CoroutineContext에 커스텀 Element를 넣어야 한다.

data class TenantCoroutineContext(
    val tenantId: String
) : AbstractCoroutineContextElement(Key) {
    companion object Key : CoroutineContext.Key<TenantCoroutineContext>
}
// 코루틴 실행 시 컨텍스트 전달
suspend fun syncAsync(tenantId: String) {
    withContext(
        Dispatchers.IO + TenantCoroutineContext(tenantId)
    ) {
        // 코루틴 내부에서 컨텍스트 꺼내기
        val ctx = coroutineContext[TenantCoroutineContext]
            ?: throw IllegalStateException("Tenant context missing")

        TenantContext.set(ctx.tenantId)
        try {
            repository.upsertAll(data)
        } finally {
            TenantContext.clear()
        }
    }
}

두 방식 비교

항목 @Async + TaskDecorator Coroutine + Context
컨텍스트 전파 자동 (Decorator가 처리) 수동 (CoroutineContext 전달)
코드 침투성 낮음 (설정만 추가) 중간 (호출부에서 Context 전달)
MDC 로깅 Decorator에서 함께 전파 별도 처리 필요
스레드풀 제어 ThreadPoolTaskExecutor Dispatchers.IO (기본)
에러 핸들링 @Async 예외 핸들러 CoroutineExceptionHandler

결론: 기존 Spring 프로젝트면 TaskDecorator, 코루틴 기반이면 CoroutineContext. 둘 다 핵심은 같다 — 스레드 경계를 넘을 때 컨텍스트를 명시적으로 전달하는 것.


함정: MDC도 같이 사라진다

ThreadLocal 소실은 TenantContext만의 문제가 아니다. SLF4J의 MDC(Mapped Diagnostic Context)도 ThreadLocal 기반이다.

// 동기: 로그에 tenantId가 찍힘
[tenant=ACME] Syncing 500 users...

// 비동기: MDC가 비어있음
[tenant=] Syncing 500 users...  ← 어떤 테넌트인지 모름

그래서 TenantContextDecorator에서 MDC도 함께 복사해야 한다. 위 코드에 이미 포함되어 있다.


결과

지표 Before After
@Async 실행 시 NPE 100% 발생 0%
잘못된 스키마 접근 간헐적 발생 0건
MDC 로깅 누락 비동기 전체 전파 완료
동기화 처리 시간 순차 120초 병렬 35초

배운 점

  1. ThreadLocal은 스레드 로컬이다. 비동기, 코루틴, 리액티브 — 스레드가 바뀌는 모든 상황에서 컨텍스트 전파를 고려해야 한다.
  2. MDC도 잊지 말자. 운영 환경에서 로그 추적이 안 되면 디버깅이 불가능하다. 컨텍스트 전파 시 MDC를 함께 처리해야 한다.
  3. 비동기 전환은 "로직"만 바꾸는 게 아니다. 컨텍스트, 트랜잭션, 에러 핸들링 — 인프라 레이어 전체를 다시 생각해야 한다.

시리즈를 마치며: 4편에 걸쳐 Kafka 동기화에서 만난 문제들을 정리했다. 트랜잭션 중첩 → Lock Wait Timeout → Reconciliation → ThreadLocal 소실. 하나를 고치면 다른 게 터지는 연쇄의 연속이었다. 결국 가장 중요했던 건 "당연한 것을 의심하는 습관"이었다.

profile

ClOr

@ClOr

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

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