ThreadLocal로 스레드별 상태 관리하기

ThreadLocal의 내부 구조(ThreadLocalMap), 초기값 설정, 사용자 컨텍스트·DB 커넥션 전파 패턴, 스레드 풀에서 메모리 누수를 피하는 remove() 습관, 가상 스레드와의 주의사항을 정리합니다.

· 8 min read · PALDYN Team

지난 글에서 happens-before 규칙으로 스레드 간 메모리 가시성을 추론하는 방법을 배웠습니다. 이번에는 완전히 다른 접근법인 ThreadLocal 을 살펴봅니다. 공유 메모리 접근을 동기화하는 대신, 아예 각 스레드에게 독립적인 사본을 제공해서 경쟁 자체를 없애는 전략입니다.

ThreadLocal이란?

ThreadLocal<T>는 각 스레드가 독립적으로 값을 저장하고 읽는 컨테이너입니다. 동일한 ThreadLocal 인스턴스에 대해 get()을 호출해도, 스레드마다 서로 다른 값을 반환합니다.

ThreadLocal<String> userId = new ThreadLocal<>();

// 스레드 A
userId.set("alice");
System.out.println(userId.get()); // "alice"

// 스레드 B (동시에 실행)
userId.set("bob");
System.out.println(userId.get()); // "bob"
// 두 스레드는 서로의 값에 간섭하지 않음

ThreadLocal 내부 구조

내부 구현: ThreadLocalMap

ThreadLocal의 값은 ThreadLocal 객체 자체에 저장되지 않습니다. 값은 Thread 객체 안의 ThreadLocalMap 에 저장됩니다. ThreadLocal 인스턴스는 이 맵의 키(key) 역할만 합니다.

Thread 객체
└── ThreadLocalMap (WeakReference 키 사용)
    ├── (ThreadLocal1) → "alice"
    ├── (ThreadLocal2) → Connection@a1b2
    └── ...

WeakReference로 키를 저장하기 때문에, ThreadLocal 인스턴스가 더 이상 강한 참조를 갖지 않으면 키는 GC 대상이 됩니다. 단, 값(value)은 강한 참조로 남아 있어서 키가 GC되어도 값이 남을 수 있습니다. 이것이 메모리 누수의 원인입니다.

초기값 설정

// 방법 1: withInitial() - 람다로 초기값 제공
ThreadLocal<List<String>> logs = ThreadLocal.withInitial(ArrayList::new);

// 방법 2: initialValue() 오버라이드
ThreadLocal<SimpleDateFormat> dateFormat = new ThreadLocal<>() {
    @Override
    protected SimpleDateFormat initialValue() {
        return new SimpleDateFormat("yyyy-MM-dd");
    }
};

// 처음 get()하면 initialValue()가 호출됨
List<String> myLogs = logs.get(); // 새 ArrayList 생성
myLogs.add("started");

withInitial()이 더 간결하므로 대부분의 경우 이것을 사용합니다.

실전 사용 패턴

패턴 1: 사용자 컨텍스트 전파

웹 프레임워크에서 요청 스레드에 사용자 정보를 바인딩할 때 가장 많이 사용됩니다.

public class SecurityContext {
    private static final ThreadLocal<User> CURRENT_USER = new ThreadLocal<>();

    public static void set(User user) { CURRENT_USER.set(user); }
    public static User get() { return CURRENT_USER.get(); }
    public static void clear() { CURRENT_USER.remove(); }
}

// 필터 또는 인터셉터
public class AuthFilter implements Filter {
    public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
            throws IOException, ServletException {
        User user = authenticate(req);
        SecurityContext.set(user);
        try {
            chain.doFilter(req, res);
        } finally {
            SecurityContext.clear(); // 반드시 정리!
        }
    }
}

// 서비스 레이어 - 파라미터 없이 사용자 접근
public class OrderService {
    void placeOrder(Order order) {
        User user = SecurityContext.get(); // 필터가 설정한 사용자
        order.setUserId(user.getId());
        // ...
    }
}

패턴 2: 스레드별 SimpleDateFormat

SimpleDateFormat은 스레드-안전하지 않습니다. ThreadLocal로 스레드마다 인스턴스를 두면 동기화 없이 안전하게 사용할 수 있습니다.

private static final ThreadLocal<SimpleDateFormat> DATE_FORMAT =
    ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"));

public String formatDate(Date date) {
    return DATE_FORMAT.get().format(date); // 각 스레드의 인스턴스 사용
}

단, Java 8 이후의 DateTimeFormatter는 불변(immutable)이므로 ThreadLocal 없이도 안전합니다.

패턴 3: DB 트랜잭션 커넥션 바인딩

public class TransactionManager {
    private static final ThreadLocal<Connection> CONNECTION = new ThreadLocal<>();

    public static Connection getConnection() {
        Connection conn = CONNECTION.get();
        if (conn == null) {
            conn = dataSource.getConnection();
            conn.setAutoCommit(false);
            CONNECTION.set(conn);
        }
        return conn;
    }

    public static void commit() {
        try {
            getConnection().commit();
        } finally {
            cleanup();
        }
    }

    private static void cleanup() {
        Connection conn = CONNECTION.get();
        if (conn != null) {
            try { conn.close(); } catch (SQLException e) { /* log */ }
        }
        CONNECTION.remove(); // 반드시 제거
    }
}

주의사항: 스레드 풀과 메모리 누수

ThreadLocal 위험 패턴과 해결

스레드 풀의 스레드는 재사용됩니다. ThreadLocal.remove()를 호출하지 않으면 이전 요청의 값이 다음 요청에 남아 있게 됩니다. 이는 두 가지 문제를 일으킵니다.

  1. 메모리 누수: 이전 요청 객체가 GC되지 않음
  2. 보안 문제: 이전 요청의 사용자 정보가 다른 요청에 노출될 수 있음
// 잘못된 패턴
void handleRequest(User user) {
    ctx.set(user);
    process(); // remove() 없으면 이 스레드를 재사용하는 다음 요청에 user 남음
}

// 올바른 패턴
void handleRequest(User user) {
    ctx.set(user);
    try {
        process();
    } finally {
        ctx.remove(); // 예외가 발생해도 반드시 제거
    }
}

InheritableThreadLocal: 자식 스레드에 값 전달

InheritableThreadLocal은 부모 스레드에서 set()한 값을 자식 스레드가 자동으로 상속합니다.

InheritableThreadLocal<String> traceId = new InheritableThreadLocal<>();
traceId.set("req-123");

Thread child = new Thread(() -> {
    System.out.println(traceId.get()); // "req-123" 출력 (부모로부터 상속)
});
child.start();

단, 스레드 풀에서는 스레드 생성이 재사용 스레드가 아닌 최초 생성 시 한 번만 발생하기 때문에 InheritableThreadLocal이 제대로 동작하지 않을 수 있습니다. 이 경우 TransmittableThreadLocal 같은 서드파티 라이브러리가 필요합니다.

가상 스레드(Virtual Thread)와 ThreadLocal

Java 21의 가상 스레드는 경량이라 수백만 개를 생성할 수 있습니다. 가상 스레드마다 ThreadLocal 값을 보유하면 메모리 소비가 늘어날 수 있습니다. Java 21에서 도입된 ScopedValue가 이 문제를 해결하기 위한 대안입니다(다음 글에서 다룹니다).

// 가상 스레드에서 ThreadLocal 사용 시 주의
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    executor.submit(() -> {
        heavyObject.set(new LargeObject()); // 100만 가상 스레드라면?
        process();
        heavyObject.remove(); // 더욱 중요해짐
    });
}

정리

상황권장
요청별 사용자 컨텍스트 전파ThreadLocal + finally { remove() }
스레드 안전하지 않은 객체 재사용ThreadLocal.withInitial()
부모 → 자식 스레드 전달InheritableThreadLocal
가상 스레드 컨텍스트 전파ScopedValue (Java 21+)

ThreadLocal의 핵심 규칙은 단 하나입니다. 스레드 풀을 사용한다면 반드시 finally 블록에서 remove()를 호출하라. 이 규칙만 지키면 ThreadLocal은 매우 유용하고 효율적인 도구입니다.


지난 글: happens-before 규칙 완전 정복

다음 글: ScopedValue: 가상 스레드 시대의 컨텍스트 전파


읽어주셔서 감사합니다. 😊