저건 쓰레드냐 로컬이냐 ThreadLocal이다 [ThreadLocal 개념/ 사용법 알아보기]

이가희·2025년 3월 11일

spring + java

목록 보기
6/14
post-thumbnail

다중 사용자 환경에서 Thread 를 잘 관리하는 것은 중요하다.
두 개 이상의 Thread가 동시에 같은 인스턴스의 필드에 접근하면 처음 스레드가 조작한 데이터가 유실되는 등의 동시성 문제가 발생할 수 있다.

더 알아가기 : 동시성 문제란? 🚦
여러 개의 스레드가 동시에 공유 자원을 수정할 때 발생하는 예기치 않은 문제를 의미한다. 대표적인 동시성 문제에는 경쟁 조건, 데드락, 라이브락, 기아 상태 등이 있다.

이 문제를 ThreadLocal 를 통해 해결할 수 있다.


1. ThreadLocal ?

ThreadLocal 이란 각 스레드마다 개별적으로 변수를 저장할 수 있는 JDK 1.2부터 제공된 Java 의 기능이다.
해당 스레드만 접근할 수 있는 저장소라고 보아도 좋다.

1.1 ThreadLocal 의 내부 구조

ThreadLocal의 내부에는 고유 코드인 threadLocalHashCode가 있고 ThreadLocalMap이 정의되어 있다.

주요 메소드는 initialValue, withnInitial, get, set, remove 가 있다.

ThreadLocalMap 은 ThreadLocal 을 key로 하여 실질적으로 값을 저장하는 공간이다.
(✨정의된 곳은 ThreadLocal 이지만 Thread 안에 ThreadLocalMap 인스턴스로 존재하고 있다.
따라서 실질적으로는 Thread 안에 있는 ThreadLocalMap 인스턴스에 저장하게 된다.)

ThreadLocalMap 의 내부에는 private Entry[] table 이 있고 이곳에 값을 저장한다.

Entry class 는 아래와 같이 생겼다.

        static class Entry extends WeakReference<ThreadLocal<?>> {
            /** The value associated with this ThreadLocal. */
            Object value;

            Entry(ThreadLocal<?> k, Object v) {
                super(k);
                value = v;
            }
        }

가비지 컬렉터 (=GC)가 ThreadLocal를 제거할 때, ThreadLocalMap의 Entryvalue 값은 그대로 남아있다. 이것을 고아 Entry (=Stale Entry) 라고 부르며 이것은 메모리 누수를 야기한다.
그래서 remove() 를 호출하여 Entry를 삭제하는 것이 중요하다.


2. ThreadLocal 사용 예시

동시성 문제를 야기해 보겠다.


public class ThreadLocalTest {
    private static String threadStrValue = null; //공유 자원

    public static void main(String[] args) {
        Runnable taskA = () -> {
            threadStrValue = "A";
            Thread.currentThread().setName("ThreadA");
            System.out.println(Thread.currentThread().getName() + " : " + threadStrValue);
        };

        Runnable taskB = () -> {
            threadStrValue = "B";
            Thread.currentThread().setName("ThreadB");
            System.out.println(Thread.currentThread().getName() + " : " + threadStrValue);
        };

        for(int i=0; i<100 ; i++){
            Thread t1 = new Thread(taskA);
            Thread t2 = new Thread(taskB);

            t1.start();
            t2.start();
        }
    }
}

콘솔을 보니 " ThreadA : B " 라는 콘솔이 2개가 찍혀있었다.

ThreadA 가 공유 자원 threadStrValue 의 값을 수정을 한 후 이를 출력하기 전에, ThreadB 가 threadStrValue 값을 수정해버려 ThreadA 가 의도하지 않은 값을 출력하게 되었다.
동시에 공유 자원을 수정할 때 예기치 않은 문제가 발생 (=동시성 문제)한 것이다.

위의 상황을 ThreadLocal 을 활용한 것으로 수정해 보겠다.

아래 코드를 보면 알겠지만, ThreadLocalThreadLocal을 생성한 후, set() 을 통해 값을 저장하고 get() 을 통해 값을 가져오고, 사용이 끝나면 remove() 를 통해 제거하는 식으로 간단히 사용할 수 있다.

public class ThreadLocalTest {
    private static ThreadLocal threadLocal = new ThreadLocal(); //공유 자원

    public static void main(String[] args) {
        Runnable taskA = () -> {
            threadLocal.set("A");
            Thread.currentThread().setName("ThreadA");
            System.out.println(Thread.currentThread().getName() + " : " + threadLocal.get());
        };

        Runnable taskB = () -> {
            threadLocal.set("B");
            Thread.currentThread().setName("ThreadB");
            System.out.println(Thread.currentThread().getName() + " : " + threadLocal.get());
        };

        for(int i=0; i<100 ; i++){
            Thread t1 = new Thread(taskA);
            Thread t2 = new Thread(taskB);

            t1.start();
            t2.start();
        }
    }
}

콘솔을 확인하니 이번에는 동시성 문제가 생기지 않았다.

+)
여담이지만 하나의 Thread 는 하나의 ThreadLocalMap을 가지고 (= Thread 의 인스턴스로 존재하고 있다.), 여러 개의 ThreadLocal 을 가질 수 있다.

그리고 하나의 ThreadLocal 은 하나의 값 밖에 저장하지 못한다.

따라서 여러 값을 저장해야 할 땐, 비즈니스를 파악해 하나의 객체로 만들어 저장하거나 비즈니스 연관도가 떨어진다면 여러 개의 ThreadLocal 을 활용해야 한다.


3. ThreadLocal 주의사항

Java 혹은 Spring 에서 ThreadPool 을 사용 중이면서 ThreadLocal을 사용할 때 주의해야 할 점이 있다.

ThreadPool 을 사용하면 요청이 끝난 후 Thread 를 종료시키는 것이 아니라 Thread 를 반환하여 다른 사용자가 사용하게 됨으로,
이전 사용자가 사용했던 ThreadLocalMap 을 공유받아 사용하게 된다.

따라서 반드시 remove() 를 하여 ThreadLocalMap 을 비워주어야 한다.

이를 코드 상으로 간단히 확인해보자면 아래와 같다.

ThreadPool 의 max thread 수를 3으로 설정하고 아래 api 를 5번 요청 하였다.

@RestController
public class ThreadLocalThreadPoolTest {
    public static ThreadLocal threadLocal = new ThreadLocal();

    @GetMapping("/threadPoolTest")
    public void threadPoolTestApi(){
        System.out.println(threadLocal.get());
        threadLocal.set("A");
        System.out.println(threadLocal.get());

        System.out.println();
    }
}

결과는 다음과 같았다.

앞선 3번의 요청은 각기 다른 thread 를 사용하고 있기에 각자 다른 threadLocal 을 가지고 있었다.
하지만, 나머지 2번의 요청은 앞서 사용했던 thread 를 사용했기 때문에 앞선 api 요청에서 threadLocal 을 사용하고 비워주지 않아 값을 설정하지 않았는데도 A 라는 값이 출력이 되어, 최종적으로 A가 두 번 출력되게 되었다.

해당 api 의 마지막에 threadLocal.remove() 한 줄만 추가하면 제대로 비워져 해당 현상이 발생하지 않게 된다.

이상으로 포스팅을 마치며
미약하지만 조금이라도 도움이 되면 좋겠다.


📎참조
Java 공식 문서

profile
안녕하세요 개발하는 사람입니다.

0개의 댓글