티스토리 뷰

Backend

Thread의 개인 수납장 ThreadLocal

지마켓 백정현 2023. 2. 1. 17:17

안녕하세요. Fulfillment Engineering 팀의 백정현입니다.

Thread에 개인 수납장이 있다는데 들어보셨나요?

이번 포스팅에서는 ThreadLocal에 대해서 가볍게 살펴보러 들어가 보겠습니다.

가봅시다!

ThreadLocal은 무엇인가요?

이름을 살펴보면 “Thread가 로컬 환경에서의 어떤 것과 연관이 있다.”라는 것을 가늠할 수 있습니다.

공식 문서에서는 java.lang 패키지에 있는 ThreadLocal 클래스를 다음과 같이 요약해서 설명할 수 있습니다.

  1. Thread에 대한 로컬 변수를 제공한다.
  2. 각각의 Thread가 변수에 대해서 독립적으로 접근할 수 있다.

그렇다면 우리는 ‘각자가 독립적으로 사용할 수 있는.. 로컬 변수(물품)’라고 생각하면,

이것에서 수납장을 연상할 수 있습니다.

저만 그런걸까요? :)

ThreadLocal은 한 Thread에서 실행되는 코드가 동일한 객체를 사용할 수 있도록 지원해주기 때문에,

Thread와 관련된 코드에서 파라미터를 사용하지 않고 객체를 전파하도록 할 수 있습니다.

ThreadLocal의 주된 기능 메서드들

// Integer 타입을 가질 수 있는 ThreadLocal
ThreadLocal<Integer> threadLocalValue = new ThreadLocal<>();

우선 선언하는 방법은 위처럼 하되 다른 타입에 대해서도 적용이 가능합니다.

ThreadLocalMap(ThreadLocal<?> firstKey, Object firstValue) {
    table = new Entry[INITIAL_CAPACITY];
    int i = firstKey.threadLocalHashCode & (INITIAL_CAPACITY - 1);
    table[i] = new Entry(firstKey, firstValue);
    size = 1;
    setThreshold(INITIAL_CAPACITY);
}

static class Entry extends WeakReference<ThreadLocal<?>> {
    /** The value associated with this ThreadLocal. */
    Object value;
    Entry(ThreadLocal<?> k, Object v) {
        super(k);
        value = v;
    }
}

ThreadLocal은 기본적으로 Thread의 정보를 Key 값으로 하여 값을 저장하는 Map의 구조(ThreadLocalMap)를 가지고 있습니다.

내부구조를 살펴보면 Entry라는 타입의 배열로 형태로 구성하면 그것을 확인할 수 있습니다.

get() & set()

public T get() {
    Thread t = Thread.currentThread();
    ThreadLocalMap map = getMap(t);
    if (map != null) {
        ThreadLocalMap.Entry e = map.getEntry(this);
        if (e != null) {
            @SuppressWarnings("unchecked")
            T result = (T)e.value;
            return result;
        }
    }
    return setInitialValue();
}

public void set(T value) {
    Thread t = Thread.currentThread();
    ThreadLocalMap map = getMap(t);
    if (map != null) {
        map.set(this, value);
    } else {
        createMap(t, value);
    }
}

데이터의 호출과 저장을 위해 get()과 set()을 사용하고 있습니다.

두 메소드 모두 현재 진행 중인 Thread를 Key 값으로 getMap()을 사용해 ThreadLocalMap를 불러옵니다.

get() 는 Map이 null이 아니라면 ThreadLocalMap의 내부에 있는 Entry를 불러오고 ThreadLocal에 저장한 값을 불러옵니다.

내부에 있는 @SuppressWarnings("unchecked") 는 검증되지 않은 연산자 관련 경고를 제외하는 용도로 사용 중입니다.

set() 는 ThreadLocal 객체를 key로 사용해 ThreadLocalMap에 값을 주입합니다.

내부적으로 현재 ThreadLocal이 가진 hash code를 사용합니다.

getMap() & createMap()

class Thread implements Runnable {
    ThreadLocal.ThreadLocalMap threadLocals = null; 
    (생략)
}

Thread 클래스 내부를 살펴보면 위와 같은 코드가 있습니다.

여기서 ThreadLocal의 ThreadLocalMap으로 참조하는 것으로 작성되어 있습니다.

ThreadLocalMap getMap(Thread t) {
    return t.threadLocals;
}

void createMap(Thread t, T firstValue) {
    t.threadLocals = new ThreadLocalMap(this, firstValue);
}

getMap() Thread를 받아와 해당 Thread에서 사용될 참조할 ThreadLocalMap값을 반환합니다.

createMap() Thread 내부 ThreadLocalMap을 새로 생성해 줍니다.

remove()

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

현재 수행 중인 Thread를 기준으로 getMap()시켜 내부 ThreadLocalMap를 가져옵니다.

그리고 안에 있던 값을 제거합니다.

withInitial()

public static <S> ThreadLocal<S> withInitial(Supplier<? extends S> supplier) {
    return new SuppliedThreadLocal<>(supplier);
}

// 예시
ThreadLocal<Integer> threadLocal = ThreadLocal.withInitial(() -> 0);

1.8 버전 이후에 생겨난 메소드로 로컬 변수를 생성하면서 특정 값으로 초기화하도록 도와줍니다.

아래에 있는 예시 코드는 초깃값을 0으로 설정합니다.

예시를 통해 알아보기

이번에는 위에서 나왔던 메서드들을 활용해서 예시를 만들어봅니다.

public static void main(String[] args) {
	// Thread 시작
	for (int number = 1; number <= 5; number++) {
		final CustomThread thread = new CustomThread(number);
		thread.start();
	}
}

메인 메서드에서는 CustomThread를 하나씩 생성해서 number 값을 넘겨줍니다.

static class CustomThread extends Thread {
	// ThreadLocal 선언
	private static final ThreadLocal<Integer> threadLocal = ThreadLocal.withInitial(() -> 0);
	private final Integer number;

	public CustomThread(Integer number) {
		this.number = number;
	}

	@Override
	public void run() {
		// thread 로컬에 있는 초기값 get()
		int initValue = threadLocal.get();
		System.out.println("[" + number + " Thread]\nThreadLocal initValue: " + initValue );
		// thread 로컬에 값 set()
		threadLocal.set(number);
		// thread 로컬에 셋팅된 값 get()
		int setValue = threadLocal.get();
		// 출력으로 확인해보기
		System.out.println("[" + number + " Thread]\nThreadLocal setValue: " + setValue );
	}
}

CustomThread에서는 Integer형식을 받는 ThreadLocal을 만들어내고,
withInitial을 통해 기본 초깃값을 0으로 설정해 줍니다.

오버라이드된 run()에서는 초깃값과 세팅값을 출력해주도록 합니다.

그에 대한 결괏값은 다음과 같이 각각의 초깃값은 0으로 세팅되어있고, 각 Thread의 번호를 출력하는 것을 확인할 수 있습니다.

첫번째 결과
과정

과정을 요약하면 위 그림처럼 0으로 초기화되었다가, 각각의 Thread 번호를 추가하는 식으로 진행이 됩니다.

ThreadPool을 사용한 경우

ThreadLocal은 Thead의 정보를 key로 하여 Map의 형식으로 데이터를 저장한 후 사용합니다.

따라서 Thread Pool을 사용하여 Thread를 재활용한다면 이전에 사용한 ThreadLocal의 정보가 남아있어 원치 않는 동작을 할 수 있습니다.

이 경우에는 Thread가 끝나가는 시점에 remove()로 값을 제거해주는 것이 필요합니다.

remove()을 사용하지 않은 경우를 먼저 확인해 보겠습니다.

@SpringBootApplication
public class ThreadLocalApplication {
	// pool 선언
	private static final ExecutorService executorService = Executors.newFixedThreadPool(3);

	public static void main(String[] args) {
		// thread 시작
		for (int number = 1; number <= 5; number++) {
			final CustomThread thread = new CustomThread(number);
			executorService.execute(thread);
		}
		// 종료
		executorService.shutdown();
	}
}

ThreadPool의 선언 개수는 3개로 제한했습니다.

실행한 결과는 다음과 같습니다.

ThreadPool이 3개인 경우 결과
과정

이전과 비교했을 때 4번과 5번의 ThreadLocal의 초깃값으로 이전에 사용했던 값들이 들어가 있는 것을 할 수 있었습니다.

@Override
public void run() {
	// ThreadLocal에 있는 초기값 get()
	int initValue = threadLocal.get();
	// ThreadLocal에 값 set()
	threadLocal.set(number);
	// ThreadLocal에 셋팅된 값 get()
	int setValue = threadLocal.get();
	// 출력으로 확인해보기
	System.out.println("[" + number + " Thread]\nThreadLocal initValue: " + initValue + " setValue:" + setValue );
	threadLocal.remove(); <--- remove() 추가
}

이제 remove()를 사용해서 값을 넣어보면 Thread Pool을 사용하지 않던 결과와 동일하게 정상적으로 나오는 걸 확인할 수 있습니다.

remove() 적용 후 결과값

활용

ThreadLocal은 각각의 Thread가 독립적으로 데이터를 처리해야 하는 곳에서 활용될 수 있습니다.

예를 들어 Spring Security에서는 ContextHolder가 ThreadLocal로 구현되어 있습니다.

final class ThreadLocalSecurityContextHolderStrategy implements SecurityContextHolderStrategy {
    private static final ThreadLocal<SecurityContext> contextHolder = new ThreadLocal<>();
    ... (생략)
}

SecurityContextHolder의 내부 구조

ContextHolder의 내부에는 authentication 정보를 담게 되는데,

여기서 여러 Thread의 요청에도 꼬이지 않는다는 ThreadLocal만의 장점이 적용되고 있습니다.

결론

이상으로 ThreadLocal에 대해서 살펴보았습니다.

실제로는 작성한 내용보다 더 깊고 어려운 내용들이지만, 가볍게 살펴보는 내용으로는 충분하다고 생각됩니다.

평소에 사용해본 적이 없던 내용이라 주변 주니어분들에게 물어봤을 때 모르는 분들이 많았었습니다.

이 글이 많은 주니어분께서 이해하시는 데 도움이 되길 바랍니다.

References

ThreadLocal (Java SE 17 & JDK 17)

[Java] ThreadLocal 정의 및 기본 사용법 - Tutorial(Sample)

baeldung - java-threadlocal

[java] ThreadLocal에 관하여

ThreadLocal 사용법과 활용

A Deep Dive into Java ThreadLocal

Spring Security Document

댓글