본문 바로가기

ThreadLocal 수명 주기

ThreadLocal의 저장 구조와 정리 시점을 익히고 스레드 재사용·비동기 전달의 누수를 예방합니다.

ThreadLocal은 하나의 변수 키에 대해 스레드마다 다른 값을 보관합니다.

워커 A와 B가 싱글톤 트레이서의 같은 인스턴스를 공유해도 서로 다른 저장 공간을 읽습니다.

그러나 서블릿 컨테이너와 실행자는 스레드를 재사용하므로 요청 종료 때 remove()하지 않으면 다음 사용자가 이전 컨텍스트를 물려받습니다.

격리와 수명 관리는 하나의 계약입니다.

ThreadLocal 수명 주기

root 설정·자식 깊이·부모 복원·finally remove·재사용 worker null의 동일 저장 칸 변화를 보여 준다.

THREADLOCAL · SET → RESTORE → REMOVE

중첩 종료는 복원, 루트 종료는 제거한다

ThreadLocal은 키 하나에 스레드별 값을 제공합니다. 풀 워커가 재사용되므로 설정한 경계가 성공·실패 모두에서 값의 수명을 닫아야 합니다.

중첩 종료의 복원과 루트 종료의 제거루트trace-A깊이0에서 같은스레드 자식깊이1로 갔다가 자식종료에부모0을복원한다. 루트finally에서remove한뒤 같은worker 다음작업이별도set하지않고 일반ThreadLocalget을하면null이다. 범위는같은스레드에서LIFO로한번씩닫는조건이며 originalTraceScope가오류순서를자동차단하지않는다.
루트 설정trace-Adepth 0
연결 관계루트 설정 · trace-A · depth 0 → 중첩 범위 · trace-A · depth 1
중첩 범위trace-Adepth 1
연결 관계중첩 범위 · trace-A · depth 1 → 부모 복원 · trace-A · depth 0
부모 복원trace-Adepth 0
연결 관계부모 복원 · trace-A · depth 0 → 루트 finally · remove
루트 finallyremove

동일 워커 · 역순으로 한 번씩 close · 다음 값 null

중첩 종료의 복원과 루트 종료의 제거루트trace-A깊이0에서 같은스레드 자식깊이1로 갔다가 자식종료에부모0을복원한다. 루트finally에서remove한뒤 같은worker 다음작업이별도set하지않고 일반ThreadLocalget을하면null이다. 범위는같은스레드에서LIFO로한번씩닫는조건이며 originalTraceScope가오류순서를자동차단하지않는다.
루트 설정trace-A · depth 0
연결 관계루트 설정 · trace-A · depth 0 → 중첩 범위 · trace-A · depth 1
중첩 범위trace-A · depth 1
연결 관계중첩 범위 · trace-A · depth 1 → 부모 복원 · trace-A · depth 0
부모 복원trace-A · depth 0
연결 관계부모 복원 · trace-A · depth 0 → 루트 finally · remove
루트 finallyremove

같은 워커의 다음 작업추가 set 없으면 null

루트 설정, 자식 실행, 부모 복원, 루트 제거요청 루트가 trace-A depth0을 설정한다. 중첩 범위가 depth1을 사용한 뒤 이전 depth0을 복원한다. 루트 finally에서 remove하고 다음 요청은 빈 상태여야 한다.루트 설정trace-A · depth 0중첩 범위trace-A · depth 1부모 복원trace-A · depth 0루트 제거finally · remove같은 워커의 다음 요청: 이전 값 없이 시작
  1. 루트 설정 → 중첩 범위

    루트가 trace-A depth0을 넣고 자식 범위는 이전 값을 보관한 뒤 depth1을 넣습니다.

  2. 부모 복원 → 루트 remove

    자식 종료는 depth0을 복원하고 루트 finally에서 제거합니다. 예외·취소 뒤 재사용 워커의 다음 작업도 빈 상태여야 합니다.

executor 경계 · 명시적 snapshot

  1. 제출할 때 캡처

    필요한 불변 컨텍스트를 작업에 붙입니다.

  2. worker 실행 때 설정

    worker의 이전 값을 보관하고 snapshot을 설치합니다.

  3. worker 종료 때 복원

    finally에서 이전 상태로 복원합니다. 스레드 생성 시 상속에 의존하지 않습니다.

다른 thread-bound 상태도 확인

MDC·로케일·보안 컨텍스트는 각자의 보관 전략에 맞게 정리합니다. 명령형 트랜잭션의 연결과 롤백은 새 스레드에 자동 전달되지 않습니다. 인증 판단 값은 명시적 명령 입력으로 전달하는 편이 분명합니다.

ThreadLocal 키가 수거돼도 값의 즉시 제거는 보장되지 않습니다. 컨텍스트 격리와 restore/remove는 함께 검증합니다.

이 예의 조건

  • 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
  • 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
  • 루트 trace-A의 깊이 0에서 같은 스레드의 자식 깊이 1로 갔다가 자식 종료에 부모의 깊이 0을 복원한다. 루트 finally에서 remove한 뒤 같은 worker의 다음 작업이 별도 set을 하지 않고 일반 ThreadLocal get을 하면 null이다. 범위는 같은 스레드에서 LIFO로 한 번씩 닫는 조건이며 originalTraceScope가 오류 순서를 자동 차단하지 않는다.
  • 요청 루트가 trace-A depth 0을 설정한다. 중첩 범위가 depth 1을 사용한 뒤 이전 depth 0을 복원한다. 루트 finally에서 remove하고 다음 요청은 빈 상태여야 한다.
관계도의 보충 설명
원래 전체 그림의 표시 문구·조건·주석
순번원문 표시
1ThreadLocal 수명 주기
6루트 trace-A의 깊이 0에서 같은 스레드의 자식 깊이 1로 갔다가 자식 종료에 부모의 깊이 0을 복원한다. 루트 finally에서 remove한 뒤 같은 worker의 다음 작업이 별도 set을 하지 않고 일반 ThreadLocal get을 하면 null이다. 범위는 같은 스레드에서 LIFO로 한 번씩 닫는 조건이며 originalTraceScope가 오류 순서를 자동 차단하지 않는다.
21요청 루트가 trace-A depth 0을 설정한다. 중첩 범위가 depth 1을 사용한 뒤 이전 depth 0을 복원한다. 루트 finally에서 remove하고 다음 요청은 빈 상태여야 한다.
26루트가 trace-A depth 0을 넣고 자식 범위는 이전 값을 보관한 뒤 depth 1을 넣습니다.
28자식 종료는 depth 0을 복원하고 루트 finally에서 제거합니다. 예외·취소 뒤 재사용 워커의 다음 작업도 빈 상태여야 합니다.
단계별 상태·값과 전환 이유
장면관찰 값전환 이유
루트 범위 열기

같은 스레드의 ThreadLocal에 루트 trace-A를 설정합니다.

trace: trace-A · 깊이: 0 · 범위: 루트이 worker의 지역 값이 루트 상태가 됩니다.
자식 범위 열기

부모 상태를 보관하고 같은 스레드의 자식 범위를 활성화합니다.

trace: trace-A · 깊이: 1 · 부모: 깊이 0 보관활성 깊이가 0에서 1로 바뀝니다.
자식 종료와 부모 복원

LIFO로 자식을 한 번 닫아 보관한 부모 값을 다시 설정합니다.

활성 깊이: 0 · 자식: 종료 · 부모: 복원됨자식 상태가 빠지고 부모가 다시 활성화됩니다.
루트 정리와 worker 재사용

루트 finally의 remove 뒤 별도 set 없는 다음 작업이 일반 ThreadLocal.get을 호출합니다.

루트: 종료 · ThreadLocal.get: null · worker: 같은 스레드 재사용지역 값이 제거되어 다음 작업에 trace-A가 남지 않습니다.

ThreadLocal 수명 주기

root 설정·자식 깊이·부모 복원·finally remove·재사용 worker null의 동일 저장 칸 변화를 보여 준다.

THREADLOCAL · SET → RESTORE → REMOVE

중첩 종료는 복원, 루트 종료는 제거한다

ThreadLocal은 키 하나에 스레드별 값을 제공합니다. 풀 워커가 재사용되므로 설정한 경계가 성공·실패 모두에서 값의 수명을 닫아야 합니다.

중첩 종료의 복원과 루트 종료의 제거루트trace-A깊이0에서 같은스레드 자식깊이1로 갔다가 자식종료에부모0을복원한다. 루트finally에서remove한뒤 같은worker 다음작업이별도set하지않고 일반ThreadLocalget을하면null이다. 범위는같은스레드에서LIFO로한번씩닫는조건이며 originalTraceScope가오류순서를자동차단하지않는다.
루트 설정trace-Adepth 0
연결 관계루트 설정 · trace-A · depth 0 → 중첩 범위 · trace-A · depth 1
중첩 범위trace-Adepth 1
연결 관계중첩 범위 · trace-A · depth 1 → 부모 복원 · trace-A · depth 0
부모 복원trace-Adepth 0
연결 관계부모 복원 · trace-A · depth 0 → 루트 finally · remove
루트 finallyremove

동일 워커 · 역순으로 한 번씩 close · 다음 값 null

중첩 종료의 복원과 루트 종료의 제거루트trace-A깊이0에서 같은스레드 자식깊이1로 갔다가 자식종료에부모0을복원한다. 루트finally에서remove한뒤 같은worker 다음작업이별도set하지않고 일반ThreadLocalget을하면null이다. 범위는같은스레드에서LIFO로한번씩닫는조건이며 originalTraceScope가오류순서를자동차단하지않는다.
루트 설정trace-A · depth 0
연결 관계루트 설정 · trace-A · depth 0 → 중첩 범위 · trace-A · depth 1
중첩 범위trace-A · depth 1
연결 관계중첩 범위 · trace-A · depth 1 → 부모 복원 · trace-A · depth 0
부모 복원trace-A · depth 0
연결 관계부모 복원 · trace-A · depth 0 → 루트 finally · remove
루트 finallyremove

같은 워커의 다음 작업추가 set 없으면 null

루트 설정, 자식 실행, 부모 복원, 루트 제거요청 루트가 trace-A depth0을 설정한다. 중첩 범위가 depth1을 사용한 뒤 이전 depth0을 복원한다. 루트 finally에서 remove하고 다음 요청은 빈 상태여야 한다.루트 설정trace-A · depth 0중첩 범위trace-A · depth 1부모 복원trace-A · depth 0루트 제거finally · remove같은 워커의 다음 요청: 이전 값 없이 시작
  1. 루트 설정 → 중첩 범위

    루트가 trace-A depth0을 넣고 자식 범위는 이전 값을 보관한 뒤 depth1을 넣습니다.

  2. 부모 복원 → 루트 remove

    자식 종료는 depth0을 복원하고 루트 finally에서 제거합니다. 예외·취소 뒤 재사용 워커의 다음 작업도 빈 상태여야 합니다.

executor 경계 · 명시적 snapshot

  1. 제출할 때 캡처

    필요한 불변 컨텍스트를 작업에 붙입니다.

  2. worker 실행 때 설정

    worker의 이전 값을 보관하고 snapshot을 설치합니다.

  3. worker 종료 때 복원

    finally에서 이전 상태로 복원합니다. 스레드 생성 시 상속에 의존하지 않습니다.

다른 thread-bound 상태도 확인

MDC·로케일·보안 컨텍스트는 각자의 보관 전략에 맞게 정리합니다. 명령형 트랜잭션의 연결과 롤백은 새 스레드에 자동 전달되지 않습니다. 인증 판단 값은 명시적 명령 입력으로 전달하는 편이 분명합니다.

ThreadLocal 키가 수거돼도 값의 즉시 제거는 보장되지 않습니다. 컨텍스트 격리와 restore/remove는 함께 검증합니다.

이 예의 조건

  • 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
  • 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
  • 루트 trace-A의 깊이 0에서 같은 스레드의 자식 깊이 1로 갔다가 자식 종료에 부모의 깊이 0을 복원한다. 루트 finally에서 remove한 뒤 같은 worker의 다음 작업이 별도 set을 하지 않고 일반 ThreadLocal get을 하면 null이다. 범위는 같은 스레드에서 LIFO로 한 번씩 닫는 조건이며 originalTraceScope가 오류 순서를 자동 차단하지 않는다.
  • 요청 루트가 trace-A depth 0을 설정한다. 중첩 범위가 depth 1을 사용한 뒤 이전 depth 0을 복원한다. 루트 finally에서 remove하고 다음 요청은 빈 상태여야 한다.

관계도의 보충 설명

원래 전체 그림의 표시 문구·조건·주석
순번원문 표시
1ThreadLocal 수명 주기
6루트 trace-A의 깊이 0에서 같은 스레드의 자식 깊이 1로 갔다가 자식 종료에 부모의 깊이 0을 복원한다. 루트 finally에서 remove한 뒤 같은 worker의 다음 작업이 별도 set을 하지 않고 일반 ThreadLocal get을 하면 null이다. 범위는 같은 스레드에서 LIFO로 한 번씩 닫는 조건이며 originalTraceScope가 오류 순서를 자동 차단하지 않는다.
21요청 루트가 trace-A depth 0을 설정한다. 중첩 범위가 depth 1을 사용한 뒤 이전 depth 0을 복원한다. 루트 finally에서 remove하고 다음 요청은 빈 상태여야 한다.
26루트가 trace-A depth 0을 넣고 자식 범위는 이전 값을 보관한 뒤 depth 1을 넣습니다.
28자식 종료는 depth 0을 복원하고 루트 finally에서 제거합니다. 예외·취소 뒤 재사용 워커의 다음 작업도 빈 상태여야 합니다.
단계별 상태·값과 전환 이유
장면관찰 값전환 이유
루트 범위 열기

같은 스레드의 ThreadLocal에 루트 trace-A를 설정합니다.

trace: trace-A · 깊이: 0 · 범위: 루트이 worker의 지역 값이 루트 상태가 됩니다.
자식 범위 열기

부모 상태를 보관하고 같은 스레드의 자식 범위를 활성화합니다.

trace: trace-A · 깊이: 1 · 부모: 깊이 0 보관활성 깊이가 0에서 1로 바뀝니다.
자식 종료와 부모 복원

LIFO로 자식을 한 번 닫아 보관한 부모 값을 다시 설정합니다.

활성 깊이: 0 · 자식: 종료 · 부모: 복원됨자식 상태가 빠지고 부모가 다시 활성화됩니다.
루트 정리와 worker 재사용

루트 finally의 remove 뒤 별도 set 없는 다음 작업이 일반 ThreadLocal.get을 호출합니다.

루트: 종료 · ThreadLocal.get: null · worker: 같은 스레드 재사용지역 값이 제거되어 다음 작업에 trace-A가 남지 않습니다.

ThreadLocal 저장소

ThreadLocal 객체는 키 역할을 하고 값은 각 Thread 내부 맵에 저장됩니다.

필드를 static final로 둘 수 있지만 값 자체가 변경 가능하고 같은 객체를 여러 스레드로 전달하면 다시 공유 상태 문제가 생깁니다.

불변 트레이스 프레임을 저장하고 교체하는 편이 이해하기 쉽습니다.

src/main/java/board/context/ThreadLocalIsolationDemo.java
package board.context;

import java.util.concurrent.Executors;

public final class ThreadLocalIsolationDemo {
    private static final ThreadLocal<String> TRACE = new ThreadLocal<>();

    static String run(String traceId) {
        TRACE.set(traceId);
        try {
            return Thread.currentThread().getName() + "=" + TRACE.get();
        } finally {
            TRACE.remove();
        }
    }

    public static void main(String[] args) throws Exception {
        try (var workers = Executors.newFixedThreadPool(2)) {
            var first = workers.submit(() -> run("trace-A"));
            var second = workers.submit(() -> run("trace-B"));
            System.out.println(first.get());
            System.out.println(second.get());
        }
        System.out.println("main=" + TRACE.get());
    }
}
ThreadLocalIsolationDemo 관찰
pool-1-thread-1=trace-A
pool-1-thread-2=trace-B
main=null

스레드 이름의 순서는 달라질 수 있지만 각 트레이스 값은 섞이지 않아야 합니다.

메인 스레드에는 값을 넣은 적이 없어 null이고 작업자도 finally에서 제거됩니다.

여기서 main=null은 메인 스레드의 저장 칸이 비어 있다는 관찰입니다. 워커의 정리까지 검증하려면 같은 워커에 다음 작업을 제출해 값을 읽어야 합니다. ThreadLocal.remove의 대상도 현재 스레드의 값이므로 main에서 remove해 워커의 값을 대신 지울 수는 없습니다. 이 예제는 initialValue를 재정의하지 않은 일반 ThreadLocal이어서 remove 뒤 get이 null로 초기화됩니다.


스레드 풀 누수

ThreadLocal 키가 가비지 컬렉션되더라도 스레드 맵의 값이 즉시 사라진다는 보장은 없습니다.

긴 수명의 풀 스레드가 큰 요청 객체를 붙잡아 메모리 누수를 만들 수 있습니다.

더 위험한 경우에는 이전 회원 ID가 다음 요청의 인가에 사용됩니다.

src/main/java/board/context/ThreadReuseLeakDemo.java
package board.context;

import java.util.concurrent.Executors;

public final class ThreadReuseLeakDemo {
    private static final ThreadLocal<Long> MEMBER = new ThreadLocal<>();

    public static void main(String[] args) throws Exception {
        try (var single = Executors.newSingleThreadExecutor()) {
            long first = single.submit(() -> {
                MEMBER.set(41L);
                return MEMBER.get();
            }).get();
            Long leaked = single.submit(MEMBER::get).get();
            single.submit(MEMBER::remove).get();
            System.out.printf("first=%d nextRequest=%s%n", first, leaked);
        }
    }
}

출력의 nextRequest=41은 격리 API를 썼어도 생명 주기가 틀리면 데이터가 새는 증거입니다.

프레임워크 스레드가 요청마다 새로 생긴다고 가정하지 않습니다.

remove 누락 실패 신호
first request member = 41
second request supplied member = none
second request observed member = 41
result = THREAD_CONTEXT_LEAK

필터의 정리 책임

컨텍스트를 처음 만드는 컴포넌트가 제거까지 책임지면 설정과 제거의 짝을 찾기 쉽습니다.

서블릿 애플리케이션에서는 필터가 요청 ID와 인증 주체 스냅샷을 설정하고 체인을 try/finally로 감쌉니다.

컨트롤러와 리포지토리는 읽기 전용 접근자로 값을 봅니다.

비동기 디스패치와 오류 디스패치에서는 같은 필터가 여러 번 호출될 수 있습니다.

OncePerRequestFilter의 디스패치 정책을 명시하고, 비동기 작업에 컨텍스트를 언제 복사할지 결정합니다.

원 요청 스레드에서 remove()해도 백그라운드 워커의 저장 칸에는 영향을 주지 않습니다. 값을 전파하지 않았고 깨끗한 일반 ThreadLocal 워커라면 null이지만, 재사용 워커에 정리하지 않은 값이 남아 있으면 이전 요청 값을 읽을 수도 있습니다.

범위를 중첩해야 하면 이전 값을 저장했다가 finally에서 복원합니다.

라이브러리 코드가 호출자 컨텍스트를 무조건 제거하면 바깥 트레이스가 사라집니다.

루트 책임 주체만 전체 정리를 수행하고 내부 범위는 넣기·꺼내기 규칙을 사용합니다.


실행자 경계 전달

InheritableThreadLocal은 자식 스레드 생성 시 값을 복사하지만 풀은 작업보다 먼저 스레드가 만들어집니다.

값 갱신과 정리도 직관적이지 않아 요청 컨텍스트 전파 해결책으로 부적합합니다.

작업 데코레이터가 제출 시 스냅샷을 잡고 실행 전에 설정하며 종료 후 복원하도록 만듭니다.

CompletableFuture의 공통 풀에 민감한 컨텍스트를 암묵적으로 기대하지 않습니다.

실행자를 주입하고 대기열 대기 시간이 길면 트레이스 기한과 인가 최신성을 재검토합니다.

보안 결정에 필요한 인증 주체는 명령의 명시적 필드로 전달하는 편이 안전합니다.

Java 25의 가상 스레드는 작업마다 스레드를 만들 수 있어 풀 재사용 누수 위험을 줄이지만 제거 책임이 사라지는 것은 아닙니다.

스레드가 살아 있는 동안 큰 값을 붙잡을 수 있습니다. 여러 라이브러리가 같은 ThreadLocal 객체를 공유하면 값 덮어쓰기와 중첩 호출 뒤 복원·정리 책임이 충돌할 수 있습니다.

어휘적 스코프가 맞는 새 코드에는 ScopedValue 같은 구조도 검토하되 프레임워크 트랜잭션 컨텍스트와 혼용 규칙을 확인합니다.


스레드 결합 리소스

명령형 트랜잭션에서는 TransactionSynchronizationManager가 연결 보관 객체를 현재 스레드에 묶습니다.

트랜잭션 메서드에서 새 스레드를 시작하면 같은 연결과 롤백 결과가 자동으로 전달되지 않습니다.

엔티티를 백그라운드 스레드로 넘기지 말고 트랜잭션 안에서 불변 명령을 만든 뒤 별도 사용 사례를 호출합니다.

MDC도 흔히 ThreadLocal 기반입니다.

트레이스 컨텍스트를 제거해도 MDC를 남기면 로그가 섞입니다.

필터 정리 점검표에 사용자 정의 컨텍스트, MDC, 로케일, 보안 컨텍스트를 포함하고 프레임워크가 제공하는 보관 객체 전략을 우선합니다.


워커 재사용 누수 테스트

각 작업을 새 스레드에서 돌리면 정리 누락을 발견하지 못합니다.

단일 스레드 실행자에서 첫 작업이 값을 쓰고 두 번째 작업이 빈 컨텍스트를 관찰하는지 검사합니다.

예외와 취소 경로에서도 제거가 실행되어야 합니다.

운영 환경 메트릭에는 현재 값 자체를 태그로 넣지 않습니다.

정리 실패 개수, 컨텍스트 누락 개수, 전파 래퍼 적용률을 낮은 카디널리티로 관찰합니다.


연습 문제

AutoCloseable 범위를 만들어 try-with-resources 구문으로 ThreadLocal 트레이스를 넣고 이전 값을 복원하세요.

중첩 범위와 예외 종료, 같은 단일 워커의 다음 작업이 null인지 검증하세요.

해설 보기

종료에서 무조건 제거하지 않고 이전 프레임 유무에 따라 복원해야 중첩 호출을 안전하게 다룹니다.

package board.context;

public final class TraceScope implements AutoCloseable {
    private static final ThreadLocal<String> CURRENT = new ThreadLocal<>();
    private final String previous;

    public TraceScope(String traceId) {
        previous = CURRENT.get();
        CURRENT.set(traceId);
    }

    public static String current() {
        return CURRENT.get();
    }

    @Override
    public void close() {
        if (previous == null) {
            CURRENT.remove();
        } else {
            CURRENT.set(previous);
        }
    }
}

스코프 인스턴스를 다른 스레드에서 종료하지 않게 책임 주체 스레드 ID를 저장해 검증할 수도 있습니다.

원 TraceScope는 같은 스레드에서 범위를 중첩하고 가장 안쪽부터 각각 한 번만 close하는 조건으로 읽습니다. previous를 저장하는 것만으로 다른 스레드 종료·역순 위반·중복 close를 자동으로 막지는 않습니다.

예시 시작 값이 null일 때 A 범위를 열면 previous=null·current=A, 안쪽 B 범위는 previous=A·current=B입니다. B.close는 A를 복원하고 A.close는 remove합니다. B에서 예외가 나도 try-with-resources가 같은 역순 종료를 수행하므로 이 정리 경로를 유지합니다.

독립 확인: current가 처음 null이고 A→B→C로 중첩한 뒤 C→B→A 순서로 닫습니다. 각 종료 뒤 값을 적으세요. 별도 잘못된 호출에서 A→B를 열고 A.close를 먼저, B.close를 나중에 호출하면 끝 값은 무엇인가요? 모두 같은 스레드이고 추가 set은 없습니다.

풀이 확인

정상 종료 값은 B→A→null입니다. 잘못된 순서에서는 A.close가 previous=null을 보고 remove한 뒤, B.close가 자신이 보관한 A를 다시 넣습니다. 마지막 값이 A여서 루트가 끝났는데도 이전 컨텍스트가 남습니다. 원 코드에는 현재 범위가 가장 안쪽인지 확인하는 검사가 없으므로 이 역순 위반을 거부하지 않습니다. 스레드 소유자·현재 프레임·한 번만 종료 조건을 강화하는 설계와, 이 최소 구현의 사용 전제를 구별해야 합니다.

직접 연습: remove를 실행한 스레드를 확인한다.

호출자의 정리가 워커의 값도 없애는가?

초깃값을 재정의하지 않은 하나의 ThreadLocal<Long>을 메인과 단일 스레드 실행자가 공유합니다. 메인은 값을 넣지 않았습니다. ① 워커의 첫 작업이 41을 넣고 제거 없이 끝납니다. ② 그 Future가 완료된 뒤 메인에서 remove()를 합니다. ③ 같은 실행자의 다음 작업이 추가 set 없이 get()합니다. ④ 그 뒤 같은 워커에 remove() 작업을 제출하고 완료를 기다립니다. ⑤ 다시 같은 워커에서 get()합니다. 동시에 제출한 다른 작업은 없습니다. ③·⑤의 값과 ② 직후 메인의 값을 구하고, 첫 작업이 예외로 끝나는 경우에는 정리를 어디에 둘지 설명하세요.

풀이: ③은 워커에 남은 41, ⑤는 워커에서 제거한 뒤 다시 초기화된 null입니다. ② 직후 메인도 null이지만 워커의 값까지 비었다는 증거는 아닙니다. remove()의 대상은 호출한 현재 스레드의 저장 칸이기 때문입니다. 첫 작업이 설정한 값을 책임지고 try/finally의 finally에서 같은 워커가 제거하면 정상·예외 종료에 같은 정리를 적용할 수 있습니다. 내부 중첩 범위가 있었다면 무조건 제거하는 대신 기존 TraceScope의 조건대로 부모 값을 복원하고, 루트 경계에서 제거합니다. 워커 정리 여부는 메인의 값이 아니라 재사용되는 같은 워커의 후속 작업으로 검증합니다.