13장. 사라진 로그 — ThreadLocal이 스레드를 갈아탈 때

만화로 보는 요약 — 먼저 읽어보세요
InheritableThreadLocal은 풀 스레드가 언제 만들어졌느냐에 따라 되기도 하고 안 되기도 합니다. 같은 코드가요.
면접 실전 질문: ① 비동기 코드에서 MDC 로그가 사라지는 이유는? ②
InheritableThreadLocal을 쓰면 해결되나요? ③ 가상 스레드에서는ThreadLocal이 어떻게 동작하나요?
배경 — 스택 위에 얹혀 있던 것들
3부는 전부 잘 돌아가는 이야기였습니다. 요청당 스레드의 처리량 상한을 재고(9장), Future의 한계를 보고(10장), CompletableFuture가 무엇을 더했는지 열어보고(11장), 이벤트 루프가 프레임워크가 되면 어떤 모습인지 봤죠(12장).
4부는 반대입니다. 12장 마지막 질문이 시작점이에요.
“3부에서 본 셋은 전부 ‘스택에 기대던 것’을 조금씩 포기했습니다. 스택 트레이스 말고 또 무엇이 스택 위에 얹혀 있었을까요?”
4장에서 리액티브의 불평 네 가지를 나열하며 이렇게 정리했습니다 — 스택 트레이스, ThreadLocal, try-catch 범위가 전부 스택에 얹혀 있었고, 실행 흐름이 스택을 떠나자 함께 무너졌다고요.
스택 트레이스는 4장에서 실측으로 확인했습니다. 이번 장은 ThreadLocal 차례입니다.
ThreadLocal이 왜 중요하냐면 — 여러분이 매일 보는 로그가 여기 걸려 있거든요. MDC(Mapped Diagnostic Context), 그러니까 로그마다 붙는 요청 ID·사용자 ID·트레이스 ID. 그게 전부 ThreadLocal입니다.
스토리 — 로그가 스레드에 묶여 있는 이유
MDC의 발상은 단순합니다. 요청이 들어오면 요청 ID를 어딘가 담아두고, 로그를 찍을 때마다 그걸 자동으로 붙이는 거예요.
MDC.put("requestId", "req-1234");
log.info("주문 조회 시작"); // → [req-1234] 주문 조회 시작
log.info("결제 확인"); // → [req-1234] 결제 확인문제는 “어딘가”입니다. 로깅 메서드에 요청 ID를 인자로 넘길 수는 없어요 — 코드 전체가 지저분해지니까요. 그래서 스레드에 붙여둡니다.
static final ThreadLocal<String> REQUEST_ID = new ThreadLocal<>();이 설계가 성립하는 전제가 하나 있습니다. 요청 하나가 처음부터 끝까지 같은 스레드에서 처리된다는 것.
9장의 요청당 스레드 모델에서는 참이었습니다. 톰캣 워커가 요청을 받아 컨트롤러·서비스·리포지토리를 거쳐 응답까지 쭉 갑니다. 스레드가 안 바뀌니 ThreadLocal도 안 사라져요.
그런데 11장에서 우리는 그 전제를 깼습니다. thenApplyAsync는 스레드를 옮기니까요. 12장의 이벤트 루프도 마찬가지고요.
전제가 깨지면 무슨 일이 생기는지, 재봤습니다.
핵심 — 어디서 사라지고 어디서 살아남나
측정 환경: Apple M1(8코어) / 램 16GB / macOS 26.5 / JDK 25. 전체 소스는 docs/book/code/async/Ch13ContextLoss.java에 있습니다.
ThreadLocal에 요청 ID를 넣어두고, 여러 방식으로 스레드를 옮겨가며 읽어봤습니다. 실측 코드는 slf4j MDC 대신 ThreadLocal<String> 하나로 최소 재현했습니다 — Logback의 LogbackMDCAdapter도 내부는 ThreadLocal<Map<String,String>>이에요. 📄 문서 기반 (미검증)
static final ThreadLocal<String> REQUEST_ID = new ThreadLocal<>();
REQUEST_ID.set("req-1234");① 스레드를 옮기면 사라진다
A. 같은 스레드에서 읽기 → req-1234 [main]
B. thenApplyAsync 콜백에서 읽기 → 없음(null) [다른-풀]✅ 실측 (같은 환경, 2026-08)
예상대로입니다. ThreadLocal은 이름 그대로 스레드에 로컬이라, 스레드가 바뀌면 없습니다.
실무에서는 이렇게 나타나요. 컨트롤러 로그에는 요청 ID가 찍히는데, thenApplyAsync 뒤의 로그부터 갑자기 안 찍힙니다. 장애가 나서 로그를 뒤질 때, 하필 그 뒷부분이 안 보입니다.
② InheritableThreadLocal은 구해주는가 — 여기가 함정입니다
이 문제를 검색하면 InheritableThreadLocal이 나옵니다. 자식 스레드에게 값을 복사해준다는 클래스예요. 써봤습니다.
C. set() 뒤에 생긴 풀 스레드 → req-1234 [다른-풀]
C. set() 전에 이미 떠 있던 스레드 → 없음(null) [미리-뜬-풀]
C. 새로 만든 스레드 + Inheritable → req-1234 [갓-만든-스레드]✅ 실측 (같은 환경, 2026-08. 4회 반복 모두 동일 — 타이밍이 아니라 스레드 생성 순서를 재는 측정이라 결정론적입니다.)
세 번째 줄이 대조군입니다. 갓 만든 스레드에는 값이 따라가요. 즉 문제는 풀이 아니라 재사용입니다.
같은 클래스, 같은 코드, 정반대 결과입니다.
차이는 하나뿐이에요. 풀 스레드가 언제 만들어졌느냐. InheritableThreadLocal은 스레드를 생성하는 순간 부모의 값을 복사합니다. 그러니 —
- 풀 스레드가
set()뒤에 만들어졌으면 → 값이 복사돼 있습니다. 잘 됩니다. - 풀 스레드가
set()전에 이미 떠 있었으면 → 복사할 기회가 없었습니다.null입니다.
이게 왜 지독하냐면, 개발 환경에서는 거의 항상 첫 번째거든요. 서버를 막 띄우고 첫 요청을 보내면 그때 풀 스레드가 생깁니다. 요청 ID를 넣은 뒤에요. 로그가 잘 찍힙니다.
운영에서는 두 번째입니다. 워커 풀은 요청이 오기 한참 전에 이미 떠 있으니까요. 그리고 재사용되고요.
InheritableThreadLocal은 스레드 풀과 함께 쓰면 안 됩니다. 풀은 스레드를 재사용하는데, 이 클래스는 생성 시점에만 복사하거든요. 개발에서 되고 운영에서 깨지는 전형적인 조합입니다.
③ 가상 스레드에서는
17장에서 볼 가상 스레드는 어떨까요?
D. 가상 스레드에서 읽기 → 없음(null) []✅ 실측 (같은 환경, 2026-08)
똑같이 사라집니다. 가상 스레드도 스레드니까요. ThreadLocal은 그 스레드의 것이고, 새로 만든 가상 스레드에는 아무것도 안 들어 있습니다.
대괄호 안이 비어 있는 것도 눈여겨보세요. 가상 스레드는 기본적으로 이름이 없습니다. 로그에 스레드 이름을 찍는 패턴을 쓴다면 이것도 함께 대비해야 해요.
재미있는 건 InheritableThreadLocal은 가상 스레드에서 오히려 잘 따라간다는 겁니다. 태스크마다 스레드를 새로 만드니 ②의 함정이 성립하지 않거든요. 풀이 나쁜 게 아니라 재사용이 나빴던 겁니다.
다만 가상 스레드가 다른 방향으로 문제를 키울 수는 있습니다. 가상 스레드는 저마다 ThreadLocalMap을 하나씩 들고 다녀요. 요청마다 새 가상 스레드를 만들고 거기에 ThreadLocal을 열 개씩 채우면, 동시 요청 10만 건은 맵 10만 개입니다. 스레드가 싸다는 전제가 여기서 흔들려요. 📄 문서 기반 (미검증 — 17장에서 잽니다)
④ ScopedValue — 스레드가 아니라 범위에 묶는다
JDK가 내놓은 답이 **ScopedValue**입니다. ThreadLocal이 “이 스레드에 붙인다”였다면, ScopedValue는 **“이 실행 범위 안에서만 유효하다”**예요.
ScopedValue.where(SCOPED, "req-1234").run(() -> {
// 이 블록 안에서만 SCOPED.get() 이 유효하다
});재봤습니다.
E. 스코프 안, 같은 스레드 → req-1234
E. 스코프 안, fork 된 스레드 → req-1234 ← 전파된다
E. 스코프 안, 평범한 풀에 submit → 없음(unbound) ← 스코프 밖이다✅ 실측 (같은 환경, 2026-08. 4회 반복 모두 동일. ScopedValue는 JDK 25 정식 API이고, 여기서 --enable-preview가 필요한 건 아직 프리뷰인 StructuredTaskScope 때문입니다.)
두 번째 줄이 핵심입니다. 구조적 동시성(StructuredTaskScope)으로 갈라진 스레드에는 값이 따라갑니다. ThreadLocal이 못 하던 일이에요.
세 번째 줄도 중요합니다. 평범한 풀에 submit한 작업에는 안 따라갑니다. 그 작업은 스코프 밖이니까요. 이건 버그가 아니라 설계입니다 — ScopedValue는 “값이 유효한 범위”를 명시적으로 그리는 도구라서, 범위 밖으로 새어 나가지 않는 게 정상이에요.
그래서 실무에서는
정리하면 선택지가 이렇습니다.
| 방법 | 스레드를 갈아타면 | 비고 |
|---|---|---|
ThreadLocal | 사라짐 | 요청당 스레드에서만 성립 |
InheritableThreadLocal | 운에 달림 | 풀과 쓰면 안 됨 |
ScopedValue + StructuredTaskScope | 따라감 | ScopedValue는 JDK 25 정식, StructuredTaskScope는 프리뷰. 평범한 풀엔 안 따라감 |
| 수동 전파 | 따라감 | 아래 참고 |
당장 쓸 수 있는 건 수동 전파입니다. 경계를 넘을 때 손으로 옮기는 것.
// 옮기기 전에 현재 컨텍스트를 캡처하고
Map<String, String> ctx = MDC.getCopyOfContextMap(); // 비어 있으면 null 이다
CompletableFuture.supplyAsync(() -> {
if (ctx != null) MDC.setContextMap(ctx); // ← null 가드 없으면 빈 MDC 에서 터진다
try {
log.info("여기서도 요청 ID가 찍힌다");
return work();
} finally {
MDC.clear(); // ← 반드시. 풀 스레드는 재사용된다
}
}, pool);finally의 MDC.clear()가 특히 중요합니다. 풀 스레드는 재사용되니까요. 안 지우면 다음 요청이 남의 요청 ID를 달고 로그를 찍습니다. 로그가 사라지는 것보다 틀린 로그가 찍히는 게 훨씬 나쁩니다.
실무에서는 이걸 손으로 하지 않고 래퍼로 감쌉니다. Spring의 TaskDecorator, Reactor의 Context, 마이크로미터의 ContextPropagation 같은 것들이 전부 이 일을 대신해주는 물건이에요. 다만 기본값은 전부 “전파 안 함”입니다. Spring MVC라면 TaskDecorator를 ThreadPoolTaskExecutor에 물리고, Reactor라면 부팅 때 Hooks.enableAutomaticContextPropagation()을 한 번 부르세요. 📄 문서 기반 (미검증)
이 장이 진짜 말하는 것
ThreadLocal이 깨지는 건 증상이지 원인이 아닙니다. 원인은 이겁니다.
“요청 하나 = 스레드 하나”라는 전제 위에 세운 모든 것이 함께 깨집니다.
ThreadLocal만이 아니에요. 스레드 이름으로 요청을 추적하던 것, 스레드 단위로 잡던 트랜잭션, try-catch로 감싸던 범위, 스레드 덤프로 “지금 이 요청이 어디 있나”를 보던 것 — 전부 같은 전제에 얹혀 있었습니다.
비동기로 가면서 우리는 스레드를 되찾고 그 전제를 팔았습니다. 4장에서 “청구서”라고 부른 것의 첫 번째 항목이 이거예요.
정리
- MDC는 결국
ThreadLocal입니다. “요청 하나 = 스레드 하나”라는 전제 위에 서 있어요. - 스레드를 옮기면 사라집니다.
thenApplyAsync콜백에서 요청 ID가null이 되는 걸 실측했습니다. InheritableThreadLocal은 함정입니다. 풀 스레드가set()뒤에 만들어졌으면 되고, 전에 떠 있었으면 안 됩니다 — 같은 코드가요. 개발에서 되고 운영에서 깨집니다.- 가상 스레드에서도
ThreadLocal은 사라집니다. 게다가 기본적으로 이름이 없어요. 다만InheritableThreadLocal은 오히려 따라갑니다 — 재사용을 안 하니까요. 범인은 풀이 아니라 재사용이었습니다. ScopedValue가 자바의 답입니다. 구조적 동시성으로 갈라진 스레드에는 따라가고, 평범한 풀에는 안 따라갑니다(설계상 그렇습니다).ScopedValue자체는 JDK 25에서 정식(JEP 506)이고, 값을 여러 스레드로 갈라 보낼 때 쓰는StructuredTaskScope가 아직 프리뷰예요.- 당장은 경계에서 손으로 옮기세요. 그리고
finally에서 반드시 지우세요 — 풀 스레드는 재사용되고, 틀린 로그가 사라진 로그보다 나쁩니다.
생각해볼 질문
MDC.clear()를finally에서 빠뜨리면 어떤 로그가 찍힐까요? 그 버그를 테스트로 잡으려면 어떤 조건이 필요할까요?- 스레드 단위로 잡던 트랜잭션(
@Transactional)은 이 장의 이야기와 어떤 관계일까요? 비동기 경계를 넘으면 트랜잭션은 따라갈까요? CompletableFuture.supplyAsync(task, pool)에서pool을 지정하지 않으면 어디서 돌까요? 그 풀에 블로킹 작업을 던지면 무슨 일이 생길까요?
3번이 다음 장입니다. 11장에서 commonPool의 병렬도가 코어 − 1(이 기계에서 7)이라는 걸 실측했죠. JVM 전체가 공유하는 그 풀에 블로킹을 던지면 어떻게 되는지, 직접 재봅니다.