[Java] ThreadLocal이란?

임재영·2025년 6월 13일
post-thumbnail

현재 수강 중인 스프링 강의에서 싱글톤(Singleton) 패턴에 대해 배우고 있다.
싱글톤 객체는 무상태(stateless) 로 설계되어야 하며, 이를 위해 지역 변수, 파라미터, 그리고 ThreadLocal 등을 활용해야 한다고 한다.

ThreadLocal에 대해 잘 알지 못했던 나는, 이참에 공부하기 좋은 키워드를 얻었다 생각하며 이에 대해 정리해보기로 했다.

배경: 싱글톤과 상태 문제


싱글톤 객체는 왜 무상태(stateless)로 설계해야 할까?

스프링에서 대부분의 빈(Bean)은 싱글톤으로 생성된다.
즉, 모든 요청에 대해 같은 객체 인스턴스를 사용한다는 뜻이다.

그런데, 여기에 필드에 상태(state)를 저장하면 어떤 문제가 생길까?

@Component
public class OrderService {
    private int price; // 상태 저장

    public void order(String user, int price) {
        this.price = price; // 사용자 A와 B가 동시에 접근하면 꼬임
    }

    public int getPrice() {
        return price;
    }
}
  • 사용자 A가 주문하면서 price = 10000
  • 동시에 사용자 B가 주문하면서 price = 20000
    → 사용자 A가 getPrice()를 호출했는데 10000이 아니라, 20000이 나옴

이것이 멀티 쓰레드 환경에서 싱글톤 객체를 상태 있게 쓰면 생기는 문제이다.

이는 동시성 문제라고 불리는 현상 중 하나이다.

✅ 그래서 등장한 해결책 중 하나가 ThreadLocal이다!!

❓여기서 동시성 문제(Concurrency issue)란?
둘 이상의 쓰레드(또는 사용자)가 동시에 같은 자원(변수, 객체 등)에 접근하면서 값이 꼬이거나 예기치 못한 결과가 발생하는 현상을 말한다.

동시성 문제는 여러 유형들을 아우르는 큰 개념이다.
이에 대해서는 다음에 벨로그에서 제대로 다뤄보겠다.



ThreadLocal이란?


쓰레드(Thread)마다 따로 저장소를 가지는 변수.
즉, 같은 객체라도 쓰레드마다 다른 값을 저장할 수 있다.


상황 비유

카페에서 종업원 한 명이 여러 테이블을 맡고 있는 상황 (싱글톤 객체).
근데 테이블마다 주문을 따로 기록해야 하는데, 종업원이 종이에 하나만 써버리면 꼬임.
→ 그래서 테이블마다 자기 주문서 (ThreadLocal)를 따로 주면 됨!

  • 종업원 한 명: 싱글톤 객체
  • 여러 테이블: 여러 사용자 요청 (즉, 여러 쓰레드)
  • 하나의 주문서 공유: 싱글톤 필드에 상태 저장 → 값이 꼬임
  • 테이블마다 주문서를 따로 줌: ThreadLocal사용

쓰레드와 싱글톤, 쓰레드로컬 정리

웹 애플리케이션에서 쓰레드는 각 사용자의 요청을 처리하는 단위이다.
여러 사용자의 요청(= 여러 쓰레드)이 하나의 싱글톤 객체를 동시에 사용하다 보면 공유된 값이 꼬일 수 있기 때문에,
요청(Thread) 하나당 독립된 저장소(ThreadLocal)를 부여해서 값을 분리하는 것이다.



바닥부터 이해하는 ThreadLocal

우선 쓰레드가 공유하여 사용하는 대표적인 싱글톤 객체로 스프링 빈(Bean)을 가져오자.

스프링에서 Bean은 기본적으로 @Component@Service와 같은 어노테이션을 붙이면 싱글톤으로 등록된다.
따라서 여러 사용자의 요청이 와도, 하나의 객체(인스턴스)를 공유하게 된다.

💡 ThreadLocal 예시 코드

@Service // 싱글톤으로 등록됨
public class UserContextService {
    private ThreadLocal<String> username = new ThreadLocal<>();

    public void setUsername(String name) {
        username.set(name); // 현재 쓰레드의 저장소에 저장
    }

    public String getUsername() {
        return username.get(); // 현재 쓰레드의 저장소에서 꺼냄
    }

    public void clear() {
        username.remove(); // 요청 종료 후 반드시 해제 (메모리 누수 방지)
    }
}

여러 요청이 들어올 때

  • UserContextService는 하나 → 공유됨.
  • ThreadLocal은 쓰레드마다 다른 저장공간을 갖고 있음.
  • setUsername()/getUsername()은 요청마다 분리된 공간을 참조하게 됨.

공유 인스턴스 객체(= 싱글톤 방식으로 관리되는 "클래스") 내부에서 ThreadLocal (멤버) 변수를 선언하면, 여러 쓰레드가 그 ThreadLocal 인스턴스를 공유해서 사용한다. 그때, ThreadLocal이 현재 쓰레드 전용 저장소를 참조하여 값을 안전하게 get/set 해준다.

ThreadLocal의 get()set() 메서드는 Java 표준 라이브러리(java.lang.ThreadLocal) 내부에 이미 구현돼 있는 기능이다.
그리고 이 메서드들은 "현재 실행 중인 쓰레드"에 자동으로 연결되도록 잘 설계되어 있다.

💡 그림으로 표현한 동작 방식

이때, 참조되는 Thread 전용 저장소ThreadLocalMap이라고 한다.

이해를 돕기 위해 코드 내부를 잠깐 살펴보자.


Thread 클래스 내부

public class Thread implements Runnable{
	...
    
    ThreadLocal.ThreadLocalMap threadLocals = null;
}

ThreadLocal 클래스 내부

public class ThreadLocal<T> {
	...
    
    public void set(T value) {
        Thread t = Thread.currentThread();
        ThreadLocalMap map = t.threadLocals;
        map.put(this, value); // this = 현재 ThreadLocal 인스턴스
    }

    public T get() {
        Thread t = Thread.currentThread();
        ThreadLocalMap map = t.threadLocals;
        return map.get(this);
    }
    
    ...
}

setter

  • 공유 인스턴스 객체 내부에서 set() 메서드를 통해 들어온 값은 현재 쓰레드(Thread.currentThread())를 기준으로, 그 쓰레드가 가진 ThreadLocalMap에 값을 저장한다.

getter

  • 현재 쓰레드의 ThreadLocalMap에서 해당 ThreadLocal 객체를 키로 하여 값을 꺼낸다.

여기서 핵심은 this = 현재 사용 중인 ThreadLocal 인스턴스.
이걸 "키(Key)"로 해서 현재 쓰레드의 저장소에 값을 저장하고 꺼내는 것.


이렇듯, ThreadLocal현재 실행 중인 쓰레드에 맞는 저장소를 자동으로 참조하도록 자바 라이브러리 내부에서 잘 구현되어 있다.
덕분에 개발자는 복잡한 쓰레드 관리를 신경 쓰지 않고, 단순히 ThreadLocal을 선언하고 set()/get() 메서드를 사용하기만 하면 된다!



✅ ThreadLocal 클래스 추가 설명

public class ThreadLocal<T> {
	...

    public void remove() {
        ThreadLocalMap m = getMap(Thread.currentThread());
        if (m != null)
            m.remove(this);
   }

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

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

Remove (remove())

  • 웹 서버는 매 요청마다 새로운 쓰레드를 만드는 게 아니라, ThreadPool에 있는 쓰레드를 재사용한다.
    그런데 이전 요청에서 쓰레드에 저장된 ThreadLocal값을 지우지 않으면, 다음 요청에서도 그 값이 남아있게 된다.
  • 또, ThreadLocal값이 계속 남아 있으면 메모리 누수(Leak)가 발생할 수 있어, 요청이 끝난 뒤에는 반드시 remove()로 삭제해줘야 한다.

    👉 여기서 쓰레드풀(ThreadPool)이란?

    필요할 때마다 새 쓰레드를 만들지 않고,
    미리 만들어둔 쓰레드들을 재사용하는 구조를 말한다.
    즉, "작업을 처리할 쓰레드들의 묶음(풀)"이다.

    사용 이유

    • 쓰레드를 생성하는 비용(CPU, 메모리 등)이 꽤 크다.
    • 동시에 너무 많은 쓰레드를 만들면 서버가 과부하된다.
    • 요청이 끝나면 그 쓰레드를 버려야 해서 비효율적이다.

ThreadLocalMap - Entry (Type)

  • ThreadLocalMap 내부에서 ThreadLocal 인스턴스를 키(Key)로, 실제 값을 value로 저장하는 객체이다.
  • 여기서 kThreadLocal 인스턴스인데, 이걸 약한 참조(WeakReference)로 저장한다.

    ❗왜 WeakReference인가?
    ThreadLocal 인스턴스가 더 이상 참조되지 않으면 GC(Garbage Collector)가 회수할 수 있도록 하기 위함이다.
    만약 Strong Reference였다면, 해당 ThreadLocalMap이 계속 참조하고 있어서 GC가 회수하지 못하고 메모리 누수가 발생했을 것이다.


Entry.key는 약한 참조라서 GC가 회수해버릴 수 있지만, Entry.value는 강한 참조라서 계속 남아있다. 이때, key는 없어지고 value만 남은 entry가 맵에 계속 남으면 메모리 누수가 발생한다.
remove()를 꼭 해줘야 하는 이유이다!






[참고]
https://velog.io/@semi-cloud/%EC%8A%A4%ED%94%84%EB%A7%81-%EA%B3%A0%EA%B8%89%ED%8E%B81-ThreadLocal
https://jaehoney.tistory.com/302
https://catsbi.oopy.io/3ddf4078-55f0-4fde-9d51-907613a44c0d

0개의 댓글