본문으로 건너뛰기
안동민 개발노트 아이콘

안동민 개발노트

본문 시작
12장 : 스레드 로컬·템플릿·프록시

ThreadLocal 수명주기

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

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

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

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

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


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에서 제거됩니다.


스레드 풀 누수

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의 디스패치 정책을 명시하고, 비동기 작업에 컨텍스트를 언제 복사할지 결정합니다.

원 요청 스레드에서 제거한 뒤 백그라운드 작업이 ThreadLocal을 읽으면 값이 없습니다.

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

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

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


실행자 경계 전달

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

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

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

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

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

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

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

스레드가 살아 있는 동안 큰 값을 붙잡고 라이브러리 간 키 충돌은 여전히 가능합니다.

어휘적 스코프가 맞는 새 코드에는 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를 저장해 검증할 수도 있습니다.