본문 바로가기

ThreadLocal 수명 주기

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

ThreadLocal은 하나의 변수에 대해 스레드마다 다른 값을 보관하는 자바 표준 클래스입니다. 워커 A와 B가 싱글톤 추적기의 같은 인스턴스를 공유해도, 두 스레드는 같은 ThreadLocal 객체를 거쳐 각자의 저장 칸만 읽고 씁니다. 앞 글에서 본 공유 필드의 덮어쓰기는 이렇게 피할 수 있습니다.

그러나 서블릿 컨테이너와 실행자(executor)는 스레드를 버리지 않고 다시 씁니다. 요청이 끝날 때 값을 지우지 않으면 같은 스레드를 받은 다음 요청이 이전 컨텍스트를 그대로 물려받습니다. 그래서 이 글은 값을 스레드별로 나누는 방법과 그 값의 수명(lifecycle)을 닫는 방법을 한 묶음으로 다룹니다.

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가 남지 않습니다.

그림의 위쪽은 같은 스레드에서 루트 범위가 trace-A를 깊이 0으로 넣고, 중첩 범위가 깊이 1을 쓴 뒤 부모 값을 되돌리며, 루트의 finally가 remove()하는 순서를 보여 줍니다. 마지막 칸은 같은 워커가 다음 작업을 받았을 때 따로 set하지 않았다면 null에서 시작해야 한다는 조건입니다. 범위는 같은 스레드에서 가장 안쪽부터 한 번씩 닫는다고 가정하며, 이 순서를 어기면 어떻게 되는지는 연습 문제에서 계산합니다. 아래쪽은 실행자를 건너갈 때 제출 시점에 스냅샷을 잡고, 워커에서 설치한 뒤 이전 상태로 복원하는 흐름과, MDC·로케일·보안 컨텍스트처럼 같은 방식으로 스레드에 묶이는 다른 상태를 정리합니다.


ThreadLocal 저장소

ThreadLocal 객체는 키처럼 쓰이고, 실제 값은 각 Thread가 가진 내부 맵에 저장됩니다. 그래서 필드를 static final 하나로 두어도 스레드마다 다른 값이 보입니다. 다만 저장한 값이 변경 가능한 객체이고 그 객체를 다른 스레드에 넘기면 다시 공유 상태 문제가 생깁니다. 앞 글의 TraceFrame처럼 불변 객체를 넣고, 바꿀 때는 새 객체로 교체하는 편이 이해하기 쉽습니다.

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());
    }
}

run은 값을 넣고, 읽고, finally에서 지웁니다. 두 작업이 서로 다른 워커에서 실행되면 출력은 다음과 같습니다.

ThreadLocalIsolationDemo 관찰
pool-1-thread-1=trace-A
pool-1-thread-2=trace-B
main=null

줄의 순서는 main이 first와 second의 결과를 차례로 받은 뒤 자기 값을 출력하는 순서로 정해집니다. 고정 크기 풀은 코어 스레드 수를 채울 때까지 제출마다 새 스레드를 만들어 그 작업을 맡기므로, 첫 작업은 thread-1, 둘째 작업은 thread-2로 끝나는 이름의 워커에서 실행됩니다. 두 줄의 트레이스 값이 섞이지 않는 것은 두 작업이 같은 TRACE 객체를 거쳐 각자 스레드의 저장 칸만 읽기 때문입니다. 마지막 줄의 main=null은 메인 스레드가 값을 넣은 적이 없어 메인의 저장 칸이 비어 있다는 뜻이지, 워커의 칸까지 비었다는 뜻은 아닙니다. ThreadLocal.remove가 지우는 대상도 호출한 현재 스레드의 값이므로, 메인에서 remove()해 워커의 값을 대신 지울 수는 없습니다. 워커가 제대로 정리했는지는 같은 워커에 다음 작업을 보내 그 값을 읽어 확인합니다. 이 예제는 initialValue를 재정의하지 않은 일반 ThreadLocal이므로, remove() 뒤의 get()은 null을 돌려줍니다.


스레드 풀 누수

ThreadLocal 키 객체가 가비지 컬렉션(garbage collection)되더라도, 스레드 맵에 남은 값이 바로 사라진다는 보장은 없습니다. 오래 사는 풀 스레드가 큰 요청 객체를 붙잡고 있으면 메모리 누수(memory leak)가 됩니다. 더 위험한 경우는 이전 회원 ID가 다음 요청의 인가(authorization)에 쓰이는 것입니다.

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);
        }
    }
}

단일 스레드 실행자는 세 작업을 모두 같은 워커에서 실행합니다. 첫 작업이 41을 넣고 지우지 않았으므로, 아무것도 넣지 않은 두 번째 작업도 41을 읽어 first=41 nextRequest=41이 출력됩니다. 격리용 API를 썼어도 수명을 닫지 않으면 데이터가 샙니다. 세 번째 작업은 같은 워커에서 remove()를 실행해 예제를 정리합니다. 프레임워크의 스레드가 요청마다 새로 생긴다고 가정하지 말고, 이런 누수를 다음과 같은 실패 신호로 기록합니다.

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

더 깊이: 키는 약한 참조인데 값은 왜 남을까

JDK 25의 ThreadLocal 소스를 보면, 각 스레드가 가진 내부 맵 ThreadLocalMap의 항목은 WeakReference를 상속해 ThreadLocal 객체를 약한 참조(weak reference)로 가리키고, 값은 보통의 필드로 붙잡습니다. 그래서 ThreadLocal 객체를 가리키는 다른 참조가 모두 사라지면 키는 가비지 컬렉션될 수 있지만, 값은 그 항목이 맵에서 지워질 때까지 남습니다. 소스 주석은 참조 큐(reference queue)를 쓰지 않으므로, 키를 잃은 항목이 확실히 지워지는 것은 맵의 공간이 모자라기 시작할 때뿐이라고 적고 있습니다. 같은 맵에서 get, set, remove가 일어나면 주변의 키를 잃은 항목을 조금씩 정리하기는 하지만, 그 맵을 더 건드리지 않는 스레드라면 값은 스레드가 살아 있는 동안 계속 남을 수 있습니다.

API 문서도 스레드가 살아 있고 ThreadLocal 객체에 접근할 수 있는 동안에는 각 스레드가 자기 값을 암묵적으로 참조하며, 스레드가 사라진 뒤에야 그 값들이 가비지 컬렉션 대상이 된다고 설명합니다. 풀 스레드는 애플리케이션이 끝날 때까지 사라지지 않으므로, 약한 참조나 가비지 컬렉션에 정리를 맡길 수 없습니다. 값을 확실히 놓는 방법은 그 스레드 안에서 remove()를 부르는 것뿐이고, 앞의 예제가 finally에서 remove()를 부르는 이유도 여기에 있습니다.


필터가 맡는 정리

컨텍스트를 처음 만드는 컴포넌트가 제거까지 맡으면 설정과 제거의 짝을 한곳에서 찾을 수 있습니다. 서블릿 애플리케이션에서는 필터(filter)가 요청 ID와 인증 주체의 스냅샷을 설정하고, 필터 체인 호출을 try/finally로 감쌉니다. 컨트롤러와 리포지토리는 읽기 전용 접근자로 값을 보기만 합니다.

비동기 디스패치(async dispatch)와 오류 디스패치(error dispatch)에서는 같은 필터가 한 요청에 여러 번 호출될 수 있습니다. OncePerRequestFilter는 기본적으로 두 디스패치에서 필터를 건너뛰며, shouldNotFilterAsyncDispatch()와 shouldNotFilterErrorDispatch()를 재정의하면 이 정책을 바꿀 수 있습니다. 어느 쪽을 택하든 정책을 코드에 드러내고, 비동기 작업에 컨텍스트를 언제 복사할지 함께 정합니다.

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

범위를 중첩해야 한다면 이전 값을 저장해 두었다가 finally에서 복원합니다. 라이브러리 코드가 호출자의 컨텍스트를 무조건 지우면 바깥쪽 트레이스가 사라지기 때문입니다. 요청의 루트를 연 쪽만 전체를 지우고, 안쪽 범위는 넣고 되돌리는 규칙만 따릅니다.


실행자를 건너는 전달

InheritableThreadLocal은 자식 스레드를 만들 때 부모의 값을 복사합니다. 하지만 스레드 풀은 작업보다 스레드를 먼저 만들어 두므로, 복사된 값은 작업을 제출한 요청과 무관할 수 있습니다. 값의 갱신과 정리도 직관적이지 않아 요청 컨텍스트를 전파하는 방법으로는 맞지 않습니다.

대신 작업 데코레이터가 제출할 때 필요한 값의 스냅샷을 잡고, 워커에서 실행하기 직전에 설정하며, 끝난 뒤 이전 상태로 복원하게 만듭니다. Spring의 ThreadPoolTaskExecutor는 이 역할을 하는 TaskDecorator를 받을 수 있습니다. CompletableFuture의 공통 풀에서 민감한 컨텍스트가 저절로 따라온다고 기대하지 말고, 사용할 실행자를 주입합니다. 대기열에서 오래 기다리는 작업이라면 트레이스의 기한과 인가 정보가 아직 유효한지도 다시 확인합니다. 보안 결정에 필요한 인증 주체는 명령(command)의 명시적인 필드로 넘기는 편이 안전합니다.

Java 25의 가상 스레드(virtual thread)는 작업마다 새 스레드를 만들 수 있어 풀 재사용으로 생기는 누수 위험을 줄입니다. 그래도 정리할 필요가 사라지지는 않습니다. 스레드가 살아 있는 동안은 큰 값을 계속 붙잡을 수 있고, 여러 라이브러리가 같은 ThreadLocal 객체를 함께 쓰면 값 덮어쓰기와 중첩 호출 뒤의 복원·정리가 서로 충돌할 수 있습니다. 값이 유효한 범위가 코드 블록과 정확히 일치하는 새 코드라면 Java 25에서 정식 API가 된 ScopedValue도 검토할 수 있습니다. ScopedValue로 요청 문맥을 넘기는 방법과 ThreadLocal·매개변수 가운데 무엇을 고를지는 Java 교재의 가상 스레드와 ScopedValue에서 다룹니다. 다만 프레임워크의 트랜잭션 컨텍스트는 여전히 스레드에 묶인 저장소를 쓰므로, 두 방식을 섞는 규칙을 먼저 정합니다.


스레드에 묶인 리소스

명령형 트랜잭션에서는 TransactionSynchronizationManager가 연결 보관 객체(connection holder)를 현재 스레드에 묶습니다. 그래서 트랜잭션 메서드 안에서 새 스레드를 시작하면 같은 연결과 롤백 결과가 그 스레드로 전달되지 않습니다. 엔티티를 백그라운드 스레드로 넘기지 말고, 트랜잭션 안에서 불변 명령을 만든 뒤 별도의 사용 사례(use case)를 호출합니다.

MDC(Mapped Diagnostic Context)도 흔히 ThreadLocal을 기반으로 합니다. 트레이스 컨텍스트를 지웠어도 MDC를 남겨 두면 다른 요청의 로그에 이전 값이 섞입니다. 필터의 정리 점검표에는 사용자 정의 컨텍스트, MDC, 로케일, 보안 컨텍스트를 모두 넣고, 프레임워크가 제공하는 보관 전략이 있다면 그것을 먼저 씁니다.


워커 재사용 누수 테스트

작업마다 새 스레드에서 실행하면 정리 누락을 발견하지 못합니다. 단일 스레드 실행자를 써서 첫 작업이 값을 쓰고 두 번째 작업이 빈 컨텍스트를 보는지 검사합니다. 예외와 취소로 끝나는 경로에서도 제거가 실행되는지 같은 방법으로 확인합니다.

운영 환경의 메트릭에는 컨텍스트의 현재 값 자체를 태그로 넣지 않습니다. 정리 실패 횟수, 컨텍스트 누락 횟수, 전파 래퍼 적용률처럼 카디널리티(cardinality)가 낮은 값으로 지켜봅니다.


정리

  • ThreadLocal은 같은 객체를 통해 스레드마다 다른 저장 칸을 제공하며, remove()는 호출한 스레드의 칸만 지웁니다.
  • 풀 스레드는 재사용되므로, 값을 넣은 쪽이 finally에서 지우지 않으면 다음 요청이 이전 값을 읽습니다.
  • 중첩 범위는 이전 값을 복원하고, 요청의 루트를 연 쪽만 전체를 지웁니다.
  • 실행자를 건너는 전달은 InheritableThreadLocal 대신 제출 시점의 스냅샷과 작업 데코레이터로 처리합니다.
  • 트랜잭션 연결, MDC, 보안 컨텍스트도 스레드에 묶여 있으므로 함께 정리하고, 정리 여부는 같은 워커의 다음 작업으로 검증합니다.

연습 문제

1. 이전 값을 복원하는 범위

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);
        }
    }
}

시작 값이 null일 때 A 범위를 열면 previous는 null, 현재 값은 A입니다. 그 안에서 B 범위를 열면 previous는 A, 현재 값은 B입니다. B를 닫으면 A가 복원되고, A를 닫으면 remove()가 실행됩니다. B 안에서 예외가 나도 try-with-resources가 같은 역순으로 닫으므로 정리 경로는 같습니다.

이 TraceScope는 같은 스레드에서 범위를 중첩하고, 가장 안쪽부터 각각 한 번만 닫는다는 조건에서 올바르게 동작합니다. previous를 저장하는 것만으로는 다른 스레드에서 닫거나, 순서를 어기거나, 두 번 닫는 경우를 막지 못합니다. 필요하다면 범위를 연 스레드의 ID를 저장해 두고, 닫을 때 같은 스레드인지 검사할 수 있습니다.

2. 닫는 순서가 어긋나면

연습 1의 TraceScope에서 현재 값이 처음에 null이고, A, B, C 순서로 중첩해 연 뒤 C, B, A 순서로 닫습니다. 각각 닫은 직후의 현재 값을 적으세요. 별도의 잘못된 호출에서는 A와 B를 차례로 연 뒤 A를 먼저, B를 나중에 닫습니다. 마지막 값은 무엇인가요? 모두 같은 스레드이고 추가 set은 없습니다.

해설 보기

정상 순서로 닫으면 값은 B, A, null 순으로 바뀝니다. 잘못된 순서에서는 A를 닫을 때 previous가 null이므로 remove()하고, 이어서 B를 닫을 때 자신이 보관한 A를 다시 넣습니다. 마지막 값이 A로 남아, 루트가 끝났는데도 이전 컨텍스트가 스레드에 남습니다.

이 코드에는 닫히는 범위가 가장 안쪽인지 확인하는 검사가 없으므로 이런 역순 위반을 거부하지 않습니다. 소유 스레드, 현재 프레임, 한 번만 닫기 조건을 검사하도록 강화한 설계와, 이 최소 구현이 전제하는 사용 조건을 구별해야 합니다.

3. remove를 실행한 스레드

초기값을 재정의하지 않은 ThreadLocal<Long> 하나를 메인 스레드와 단일 스레드 실행자가 함께 씁니다. 메인은 값을 넣지 않았습니다. 워커의 첫 작업이 41을 넣고 지우지 않은 채 끝나고, 그 Future가 완료된 뒤 메인에서 remove()를 호출합니다. 이어서 같은 실행자의 다음 작업이 추가 set 없이 get()하고, 같은 워커에 remove() 작업을 제출해 완료를 기다린 뒤, 다시 같은 워커에서 get()합니다. 동시에 제출한 다른 작업은 없습니다.

두 번의 워커 get() 결과와, 메인에서 remove()한 직후 메인의 값을 구하세요. 첫 작업이 예외로 끝나는 경우에는 정리를 어디에 두어야 하는지도 설명하세요.

해설 보기

첫 번째 워커 get()은 워커에 남은 41을, 두 번째 워커 get()은 워커에서 지운 뒤 다시 초기화된 null을 돌려줍니다. 메인에서 remove()한 직후 메인의 값도 null이지만, 그것이 워커의 값까지 비었다는 뜻은 아닙니다. remove()가 지우는 대상은 호출한 현재 스레드의 저장 칸이기 때문입니다.

첫 작업이 넣은 값은 그 작업이 같은 워커의 try/finally에서 지워야 정상 종료와 예외 종료에 같은 정리가 적용됩니다. 안쪽 중첩 범위가 있다면 무조건 지우지 말고 TraceScope처럼 부모 값을 복원하며, 루트에서만 지웁니다. 워커가 정리되었는지는 메인의 값이 아니라 재사용되는 같은 워커의 다음 작업으로 확인합니다.

다음 글 템플릿 메서드에서는 컨트롤러와 리포지토리마다 반복되는 트레이스 시작·종료·예외 기록 코드를, 실행 순서를 상위 클래스가 고정하는 템플릿 메서드 패턴으로 모읍니다.