ThreadLocalMap map = t.threadLocals; // 해당 스레드의 Map 가져오기
if (map !=null) {
Entry e = map.getEntry(this); // this(ThreadLocal 객체)를 key로 조회
if (e !=null) {
return (T) e.value;
}
}
returnsetInitialValue();
}
🤔 왜 ThreadLocal 객체에 값을 저장하지 않고, Thread 객체에 저장할까?
ThreadLocal에 직접 Map<Thread, Value>를 두면 될 것 같지만, 이 경우 스레드가 종료되어도 Map에서 Thread를 key로 참조하고 있어 GC가 안 됩니다. 현재 구조에서는 Thread가 종료되면 Thread 내부의 ThreadLocalMap도 함께 GC 대상이 됩니다.
UserContext user = UserContextHolder.get(); // 어디서든 접근
log.info("Creating order for user: {}", user.getId());
// ...
}
}
이 패턴은 Spring Security의 SecurityContextHolder, Hibernate의 Session 관리 등에서 광범위하게 사용됩니다.
ThreadLocal의 한계
먼저 알아야 할 것: Spring MVC에서 스레드는 언제 바뀌나?
ThreadLocal의 한계를 이해하려면 먼저 스레드가 언제 바뀌는지를 알아야 합니다.
Spring MVC의 기본 동작 (Thread-per-Request)
일반적인 Spring MVC 애플리케이션에서는 한 요청이 하나의 스레드를 끝까지 점유합니다:
flowchart LR
subgraph Thread["Thread: http-nio-8080-exec-1"]
A[요청 시작] --> B[Controller]
B --> C[Service]
C --> D[Repository]
D --> E[응답 완료]
end
style Thread fill:#e8f5e9,stroke:#4caf50
DB 호출이나 외부 API 호출에서 blocking이 발생해도 스레드는 그대로 기다립니다. 이것이 “Thread-per-Request” 모델입니다. 이 경우 ThreadLocal은 아무 문제 없이 동작합니다.
스레드가 바뀌는 경우
하지만 개발자가 명시적으로 비동기 처리를 사용하면 스레드가 바뀝니다:
상황
스레드 전환
일반적인 Spring MVC 코드
❌ 스레드 유지
@Async 메서드 호출
✅ 다른 스레드풀로 전환
CompletableFuture.supplyAsync()
✅ ForkJoinPool 또는 지정 Executor
DeferredResult, Callable 리턴
✅ Servlet 스레드 반납 → 작업 스레드
// 이 경우 스레드가 바뀜!
@Async
public CompletableFuture<Result>asyncMethod() {
// 여기는 다른 스레드!
log.info("traceId: {}", MDC.get("traceId")); // null일 수 있음!
}
sequenceDiagram
participant S as Servlet Thread<br/>(http-nio-8080-exec-1)
participant A as Async Thread<br/>(async-pool-1)
Note over S: ThreadLocal에 traceId 있음
S->>S: Controller 처리
S->>A: @Async 메서드 호출
Note over S: 즉시 반환
Note over A: ThreadLocal 비어있음!<br/>traceId = null
A->>A: 비동기 작업 수행
🤔 WebFlux는 다른가요?
완전히 다릅니다. WebFlux(Event Loop 모델)에서는 한 요청이 여러 스레드를 오가며 처리됩니다. I/O 대기 시 스레드를 반납하고, 응답이 오면 아무 스레드나 가져다 씁니다. 그래서 ThreadLocal이 근본적으로 작동하지 않습니다. 이 문제는 3편에서 다룹니다.
스레드 풀 환경의 문제
ThreadLocal은 “스레드가 요청마다 새로 생성되고 종료된다”는 가정 하에 잘 동작합니다. 하지만 현실의 서버 애플리케이션은 스레드 풀을 사용합니다.
3개의 스레드가 5개의 작업을 처리하면서, Task 4와 5는 이전 작업의 값을 그대로 보게 됩니다. 스레드가 재사용되면서 이전 ThreadLocal 값이 남아있기 때문입니다.
이 문제가 Tracing에서 발생하면? 전혀 다른 요청의 traceId가 로그에 찍히는 심각한 상황이 됩니다.
해결책: 반드시 remove() 호출
executor.submit(() -> {
try {
context.set("task-"+ taskId);
// 작업 수행
} finally {
context.remove(); // 반드시!
}
});
하지만 사람의 실수는 피할 수 없습니다. 좀 더 근본적인 해결책이 필요합니다.
메모리 누수 가능성
또 다른 문제는 메모리 누수입니다. ThreadLocalMap의 Entry는 ThreadLocal을 WeakReference로 참조하지만, 값(value)은 강한 참조입니다.
Entry(WeakReference<ThreadLocal>, value)
ThreadLocal 객체가 GC되면 key는 null이 되지만, value는 여전히 Entry에 남아있습니다. 스레드가 살아있는 한 이 value는 GC되지 않습니다. 스레드 풀 환경에서 스레드는 애플리케이션이 종료될 때까지 살아있으므로, 누적된 value들이 메모리를 점유하게 됩니다.
맞습니다! 둘 다 “캡처 → 전달 → 복원”의 동일한 원리입니다. TTL은 TtlRunnable.get()과 TtlExecutors.wrap()으로 편리한 래퍼를 제공하고, Micrometer는 ContextSnapshot.setThreadLocals()와 ContextExecutorService.wrap()을 제공합니다. 핵심 차이는 Micrometer가 Reactor Context와의 연동을 지원해서 WebFlux 환경까지 커버한다는 점입니다.
ThreadLocalAccessor의 역할
한 가지 의문이 생깁니다. ThreadLocal은 MDC, SecurityContextHolder, ObservationRegistry 등 여러 곳에 분산되어 있는데, ContextSnapshot이 이걸 어떻게 다 알고 캡처할까요?
정답은 ThreadLocalAccessor입니다. 각 ThreadLocal에 “어떻게 접근하는지”를 정의한 어댑터입니다.
이 Accessor들이 ContextRegistry에 등록되고, captureAll() 호출 시 등록된 모든 Accessor의 getValue()를 호출해서 값을 수집합니다. setThreadLocals() 호출 시에는 각 Accessor의 setValue()를 호출합니다.
flowchart TB
subgraph Registry["ContextRegistry"]
A1["ObservationThreadLocalAccessor"]
A2["MdcAccessor"]
A3["SecurityContextAccessor"]
end
subgraph Capture["captureAll() 호출"]
direction LR
C1["accessor.getValue()"] --> C2["snapshot.put(key, value)"]
end
subgraph Snapshot["ContextSnapshot (결과)"]
S1["'micrometer.observation': Observation 객체"]
S2["'mdc': {traceId: 'abc', userId: 'user-1'}"]
S3["'security': SecurityContext 객체"]
end
Registry --> Capture
Capture --> Snapshot
다른 스레드에서 setThreadLocals() 호출 시:
flowchart TB
subgraph Snapshot["ContextSnapshot"]
S1["'mdc': {traceId: 'abc'}"]
S2["'observation': Observation 객체"]
end
subgraph Restore["setThreadLocals() 호출"]
direction LR
R1["snapshot.get(key)"] --> R2["accessor.setValue(value)"]
end
subgraph NewThread["새 스레드의 ThreadLocal"]
T1["MDC.setContextMap(...)"]
T2["ObservationRegistry에 복원"]
end
Snapshot --> Restore
Restore --> NewThread
Spring Boot 3에서는 ObservationThreadLocalAccessor 등이 SPI(Service Provider Interface)로 자동 등록되므로, 개발자가 직접 Accessor를 다룰 일은 거의 없습니다.
Spring MVC에서 @Async에 적용하기
⚠️ 중요: 자동 적용이 아닙니다!
Spring Boot는 @Async에 대한 Context Propagation을 기본으로 적용하지 않습니다. 직접 설정해야 합니다.
Spring MVC에서는 @Async 호출 시점에 “누가 누구를 호출하는지” 명확합니다. 그래서 그 순간 ContextSnapshot을 만들어 전달하면 됩니다.
반면 WebFlux는 이벤트 기반이라 스레드 A가 “누가 이어받을지” 모릅니다. I/O 대기 후 아무 스레드나 가져다 쓰기 때문에, 중간에 Reactor Context라는 “구독(Subscription)에 붙어있는 저장소”가 필요합니다.
Micrometer Context Propagation은 이 둘을 연결하는 브릿지 역할을 합니다. ThreadLocal ↔ Reactor Context 간 변환을 지원해서 MVC와 WebFlux가 섞인 환경에서도 Trace가 끊기지 않습니다. 자세한 내용은 3편에서 다룹니다.
MDC란?
Mapped Diagnostic Context 소개
MDC(Mapped Diagnostic Context)는 로깅 프레임워크(SLF4J, Logback, Log4j)가 제공하는 로그 컨텍스트 저장소입니다. 내부적으로 ThreadLocal 기반의 Map으로 구현되어 있습니다.
하지만 remote-fields에 x-user-id처럼 임의의 헤더 이름을 설정하면, 그 헤더도 baggage처럼 취급됩니다:
X-User-ID:user-123 (이 헤더도 baggage로 인식!)
이건 W3C 표준이 아니라 Spring Boot/Micrometer의 확장 기능입니다. 레거시 시스템이나 기존 인프라와 통합할 때 유용합니다. 공식 문서에서도 “setting this property to baggage1 results in an HTTP header baggage1: value1“라고 설명하고 있습니다.
WebFlux는 스레드 모델 자체가 다릅니다. 하나의 요청이 여러 스레드를 오가며 처리되기 때문에 ThreadLocal/MDC가 근본적으로 작동하지 않습니다.
MVC: 스레드 A → 스레드 B 직접 전환 (누가 누구를 호출하는지 앎)
WebFlux: 스레드 A → ??? (I/O 대기 후 아무 스레드나 배정)
그래서 WebFlux에서는 ThreadLocal 대신 Reactor Context라는 “구독(Subscription)에 붙어있는 Map”을 저장소로 사용합니다. Micrometer Context Propagation이 이 둘을 연결하는 브릿지 역할을 하고요. 3편에서 자세히 다룹니다.
flowchart TB
subgraph MVC["Spring MVC (동기식)"]
direction LR
MA[스레드 A] -->|"@Async 호출<br/>Snapshot 전달"| MB[스레드 B]
MA -.->|"누가 받는지 앎"| MB
end
subgraph WebFlux["Spring WebFlux (비동기식)"]
direction TB
WA[스레드 A] -->|"I/O 요청"| RC((Reactor<br/>Context))
RC -->|"I/O 완료"| WB[스레드 ?]
WA -.->|"누가 받을지 모름"| WB
end
style MVC fill:#e3f2fd,stroke:#1976d2
style WebFlux fill:#fff3e0,stroke:#f57c00
style RC fill:#ffecb3,stroke:#ffa000
핵심 요약: 동기식 Spring MVC에서는 ThreadLocal이 잘 동작하지만, @Async 같은 비동기 호출 시에는 ContextExecutorService.wrap() 으로 Context를 전파해야 합니다.
다음 편에서는 리액티브 환경(WebFlux)에서의 Context 전파를 다룹니다. ThreadLocal이 근본적으로 작동하지 않는 환경에서 Reactor Context와 Hooks.enableAutomaticContextPropagation()이 어떻게 문제를 해결하는지 알아보겠습니다.