[Java] 쓰레드 로컬 (ThreadLocal)

 

 

✍️  Stack, Heap, Thread

결론부터 말하면 ThreadLocal이란 Thread마다 독립적인 변수를 갖도록 지원하는 클래스이다. 쉽게 말하면 Thread마다 독립적으로 읽고 쓰는 변수를 제공하는 클래스이다.

 

ThreadLocal을 이해하기 전에 JVM 메모리 정책을 가볍게 짚고 넘어갈 필요가 있다.

 

Java 8 이후 기준, 메서드 파라미터와 지역 변수는 Stack에 할당되고 그 외의 것들은 Heap에 할당된다. 엄밀히 말하면 틀린 설명이지만 메서드에 정의된 것들은 Stack, 그 외의 것들은 Heap 영역이라고 생각하자.

 

Thread(이하 쓰레드)란 한 프로세스 내에서 실행되는 흐름의 단위이다. 한 프로세스당 기본적으로 하나의 쓰레드를 갖는데 이를 메인 쓰레드라고 부른다. 만약 별도의 쓰레드를 생성한 적이 없다면 당신이 작성한 코드는 메인 쓰레드가 처리한다.

 

Stack과 Heap을 이야기하다가 웬 쓰레드를 말하나 싶겠지만, 이 셋은 깊은 연관이 있다.

메인 쓰레드가 있다는 것은 메인 쓰레드가 아닌 쓰레드가 있을 수 있다는 뜻이고, 다른 말로 한 프로세스 내에 여러 쓰레드가 존재할 수 있다는 뜻으로 해석된다. 그리고 여기서 한 가지 중요한 사실은 쓰레드는 서로 Heap 영역은 공유하지만 Stack 영역은 공유하지 않는 것이다.

 

여담으로, 쓰레드가 공유하는 Heap 영역에 할당된 자원을 공유 자원 그리고 오직 한 쓰레드만 공유자원에 접근하도록 설계해야 하는 코드 영역을 임계 영역이라고 한다. 둘 이상의 프로세스가 동시에 임계 영역에 진입하는 것을 방지하기 위해 사용되는 기술이 바로 상호 배제 기술이다. 

 

🍊 ThreadLocal

본론으로 돌아와서 ThreadLocal이 왜 필요할까.

 

Stack에 할당된 변수들은 휘발성이 강하다. 메서드가 종료되면 Stack에 저장된 변수들은 사라진다. 반면, 쓰레드 입장에선 메서드에서 계산한 데이터를 잘 보관했다가 재사용하고 싶을 때가 있다. 가령 다른 메서드에서 데이터를 참조해서 사용하거나, 데이터가 Context의 역할을 할 때 그러하다. 그렇다고 해서 메서드가 종료된 이후에도 그 값을 유지하려고 Heap 영역에 저장했다간 다른  쓰레드에서도 해당 값을 세팅하고 참조하는 문제가 발생한다.

 

가령 메서드에서 계산한 값을 계속 유지하기 위해 반환값으로 넘기고 다시 파라미터로 받는 아래의 코드는 한계점이 있다.

@Data
public class MyRunnable implements Runnable {

    @Override
    public void run() {
        int value = 1;
        value = addOne(value);
        value = multiply(value);

        System.out.println("Result = " + value);
    }

    private int addOne(int value) {
        return value + 1;
    }

    private int multiply(int value) {
        return value * value;
    }
}
public class App {
    public static void main(String[] args) throws InterruptedException {
        MyRunnable myRunnable = new MyRunnable();
        Thread thread = new Thread(myRunnable);

        thread.start();
    }
}
Result = 4

 

그렇다고 해서 MyRunnable에 멤버 변수를 선언하고 여기에 값을 담는 순간 여러 쓰레드에서 변수 접근이 가능하다.

@Data
public class MyRunnable implements Runnable {

    private int value;

    public MyRunnable() {
        value = 1; // default
    }

    @Override
    public void run() {
        System.out.println(Thread.currentThread().getName()
                + " Start value : "
                + threadLocal.get());

        addOne();
        multiply();

        System.out.println(Thread.currentThread().getName()
                + " Start value : "
                + threadLocal.get());
    }

    private void addOne() {
        value += 1;
    }

    private void multiply() {
        value *= value;
    }
}
public class App {
    public static void main(String[] args) throws InterruptedException {
        MyRunnable myRunnable = new MyRunnable();
        Thread threadA = new Thread(myRunnable, "Thread A");
        threadA.start();
        threadA.join();

        Thread threadB = new Thread(myRunnable, "Thread B");
        threadB.start();
        threadB.join();
    }
}

Thread B가 myRunnble 코드를 실행할 땐 Thread A가 저장했던 4로 시작한다.

Thread A Start value : 1
Thread A End value : 4
Thread B Start value : 4
Thread B End value : 25

 

 

지금 예시로 들었던 두 코드의 문제점을 모두 해결한 것이 ThreadLocal이다. 쉽게 요약하면 ThreadLocal은 쓰레드마다 독립적인 변수를 갖도록 지원한다.

public class MyThreadLocalRunnable implements Runnable {

    private static ThreadLocal<Integer> threadLocal = ThreadLocal.withInitial(() -> 1);

    @Override
    public void run() {
        System.out.println(Thread.currentThread().getName()
                + " Start value : " 
                + threadLocal.get());

        addOne();
        multiply();

        System.out.println(Thread.currentThread().getName() 
                + " End value : " 
                + threadLocal.get());
    }

    private void addOne() {
        int value = threadLocal.get();
        threadLocal.set(value + 1);
    }

    private void multiply() {
        int value = threadLocal.get();
        threadLocal.set(value * value);
    }
}
public class App {
    
    public static void main(String[] args) throws InterruptedException {
        MyThreadLocalRunnable myThreadLocalRunnable = new MyThreadLocalRunnable();
        Thread threadA = new Thread(myThreadLocalRunnable, "Thread A");
        threadA.start();
        threadA.join();

        Thread threadB = new Thread(myThreadLocalRunnable, "Thread B");
        threadB.start();
        threadB.join();
    }
}

이제는 쓰레드마다 독립된 계산 결과가 나온다.

Thread A Start value : 1
Thread A End value : 4
Thread B Start value : 1
Thread B End value : 4

 

보통 Spring 서버 구현에서 쓰레드마다 User 정보를 Context로 저장할 때 ThreadLocal을 사용한다.

 

❓ ThreadLocal은 항상 좋을까

그렇다면 ThreadLocal의 단점 내지는 주의할 점은 무엇일까

 

가령 예제 코드에서 Thread A가 작업을 마무리해서 ThreadLocal에 4를 저장했고, 다시 Thread A로 같은 작업을 시작한다면 이전에 저장했던 4를 시작으로 계산을 진행할 것이다. 이처럼 쓰레드가 한 단위의 작업을 마무리했다면 반드시 ThreadLocal.remove 메서드를 호출해야 한다.

public class MyThreadLocalRunnable implements Runnable {

    private static ThreadLocal<Integer> threadLocal = ThreadLocal.withInitial(() -> 1);

    @Override
    public void run() {
        System.out.println(Thread.currentThread().getName()
                + " Start value : "
                + threadLocal.get());

        addOne();
        multiply();

        System.out.println(Thread.currentThread().getName()
                + " End value : "
                + threadLocal.get());
        
        threadLocal.remove();
    }