시리즈 목차
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초 |
배운 점
- ThreadLocal은 스레드 로컬이다. 비동기, 코루틴, 리액티브 — 스레드가 바뀌는 모든 상황에서 컨텍스트 전파를 고려해야 한다.
- MDC도 잊지 말자. 운영 환경에서 로그 추적이 안 되면 디버깅이 불가능하다. 컨텍스트 전파 시 MDC를 함께 처리해야 한다.
- 비동기 전환은 "로직"만 바꾸는 게 아니다. 컨텍스트, 트랜잭션, 에러 핸들링 — 인프라 레이어 전체를 다시 생각해야 한다.
시리즈를 마치며: 4편에 걸쳐 Kafka 동기화에서 만난 문제들을 정리했다. 트랜잭션 중첩 → Lock Wait Timeout → Reconciliation → ThreadLocal 소실. 하나를 고치면 다른 게 터지는 연쇄의 연속이었다. 결국 가장 중요했던 건 "당연한 것을 의심하는 습관"이었다.
'백엔드 트러블슈팅' 카테고리의 다른 글
| Reactor 파이프라인 설계기: External Task 200건 동시 처리 · ConnectableFlux 삽질 (0) | 2026.03.29 |
|---|---|
| Schema-per-Tenant 멀티테넌시 구현: JPA 대신 Exposed를 선택한 이유와 실전 삽질 (0) | 2026.03.29 |
| Kafka 동기화 삽질기 3편: Reconciliation — "없는 것"을 처리하는 기술 (0) | 2026.03.29 |
| BPMN 자동화 엔진 리팩토링: 이벤트 디스패치 · 상태 업데이트 역할 분리 5단계 정리 (0) | 2026.03.28 |
| Kafka 동기화 삽질기 2편: batchInsert Lock Wait Timeout과 청크 전략 (0) | 2026.03.28 |
