요즘에 나는 인프런 강의를 즐겨 듣고 있다.
오늘은 스프링 핵심 원리 - 고급편 강의를 들었는데 강의 내용이
'ThreadLocal'를 이용하여 동시성 이슈를 해결하는 거였다.
ThreadLocal은 자바를 공부하던 시절부터 자주 들어왔어서
기능 자체가 낯설지는 않았다.
쓰레드 별로 본인의 특별한 저장공간? 을 만들어서 사용하는 기능인데,
어떻게 쓰고, 무슨 일을 하는지는 알지만
'ThreadLocal이 어떻게 이런 기술을 구현했는지'는 따로 알아본 적이 없는 거 같다!
그래서 나는 이번 기회에 ThreadLocal 클래스를 분석해서
어떻게 쓰레드간 동시성 이슈를 해결했는지 정리해보고자 한다!

(동시성 이슈는 서버 개발을 하다 보면 무조건 겪게 되는 거 같다.
이번 기회에 원리와 구현방식을 공부하면 많은 도움이 될 거라 생각한다!)
먼저 ThreadLocal 클래스는 public 접근제한자로 외부에 공개된 함수는
set / get / remove 로 총 3가지이다.
함수 이름에서도 바로 짐작 가능하듯
- set : 데이터 저장
- get : 데이터 조회
- remove : 데이터 제거
라고 생각하면 될 것 같다.
가장 중요하다고 판단하는(?) set에 대해서 분석해보자.
public void set(T value) {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null) {
map.set(this, value);
} else {
createMap(t, value);
}
}
먼저 Thread.currentThread()를 통해 현재 실행 중인 Thread 정보를 받아온다.
그 후 getMap(t) 를 통해 ThreadLocalMap을 조회한다.
(ThreadLocalMap은 Thread의 필드로 전역변수에 할당되어 있다.)
만약 조회해온 ThreadLocalMap이 있다면 (null 이 아니라면)
저장할 데이터를 ThreadLocalMap에 저장 (set) 해주고
존재하지 않는다면 (null이라면)
새로 만들어 (createMap) 저장해준다.
흐름을 잠깐 봤지만 사실 중요한 부분은
LocalThread 가 아닌
실제 데이터를 저장하는 클래스인 LocalThreadMap이다.
LocalThread는 Thread 별 데이터 공간인 LocalThreadMap에 데이터를 저장해주고
조회 및 삭제를 해주는 기능만 구현되어 있을 뿐이다.
static class Entry extends WeakReference<ThreadLocal<?>> {
Object value;
Entry(ThreadLocal<?> k, Object v) {
super(k);
value = v;
}
}
private static final int INITIAL_CAPACITY = 16;
private Entry[] table;
private int size = 0;
private int threshold; // Default to 0
LocalThreadMap은 value 필드를 가지고 있는 Entry 내부 클래스 하나와
네 가지의 전역 필드들을 가지고 있다.
그렇다면 이제
LocalThreadMap의 set을 살펴보자!
(현재 진행 중인 Thread의 LocalThreadMap이 null이 아닐 때 호출)
private void set(ThreadLocal<?> key, Object value) {
Entry[] tab = table;
int len = tab.length;
int i = key.threadLocalHashCode & (len-1);
for (Entry e = tab[i];
e != null;
e = tab[i = nextIndex(i, len)]) {
if (e.refersTo(key)) {
e.value = value;
return;
}
if (e.refersTo(null)) {
replaceStaleEntry(key, value, i);
return;
}
}
tab[i] = new Entry(key, value);
int sz = ++size;
if (!cleanSomeSlots(i, sz) && sz >= threshold)
rehash();
}
필요한 변수들을 할당하고
(무언가의 수식에 의한) 인덱스 증가에 따른 반복문을 돌며 Entry 객체를 꺼낸다.
Entry 객체가 파라미터로 넘어온 ThreadLocal을 참고하고 있다면
(refersTo() 함수는 native 함수로 되어있어 구현 코드를 볼 수 없다. 검색하면 나오겠지만..)
Entry의 value 필드를 파라미터로 넘어온 value 필드로 변경한 후 return 한다.
다시 정리해 보자면
Thread 별로 ThreadLocalMap 이 존재하고,
ThreadLocalMap에는 Entry라는 value를 담는 내부 클래스가 있고
종류별로 한 개씩 여러 Entry를 보관하기 위해 Entry[] 를 전역 변수로 사용 중이다.
ThreadLocal<T> 은 제네릭 하기 때문에
ThreadLocalMap 에는 T 유형의 Entry를 하나씩 가지고 있을 수 있고 (정책상 여러 개이면 안되니까!)
T 유형의 존재 판단 여부를 refersTo() 함수로 처리했다고 볼 수 있다!
그래서 참고하는 데이터가 있으면 새로운 값으로 덮어쓰는 거다!
그 후 만약에 ThreadLocal을 참고하는 것이 아닌 null을 참고하고 있다면
(= Entry 객체가 존재는 하나 그 어느 ThreadLocal 도 참고하고 있지 않다면)
replaceStaleEntry() 함수를 호출한다.
private void replaceStaleEntry(ThreadLocal<?> key, Object value,
int staleSlot) {
Entry[] tab = table;
int len = tab.length;
Entry e;
int slotToExpunge = staleSlot;
for (int i = prevIndex(staleSlot, len);
(e = tab[i]) != null;
i = prevIndex(i, len))
if (e.refersTo(null))
slotToExpunge = i;
for (int i = nextIndex(staleSlot, len);
(e = tab[i]) != null;
i = nextIndex(i, len)) {
if (e.refersTo(key)) {
e.value = value;
tab[i] = tab[staleSlot];
tab[staleSlot] = e;
if (slotToExpunge == staleSlot)
slotToExpunge = i;
cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
return;
}
if (e.refersTo(null) && slotToExpunge == staleSlot)
slotToExpunge = i;
}
tab[staleSlot].value = null;
tab[staleSlot] = new Entry(key, value);
if (slotToExpunge != staleSlot)
cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
}
함수는 이렇게 생겼는데 (알고리즘 레벨이기 때문에)
대충 확인만 해보자면 배열 특성상 새로운 Entry 객체의 삽입 및 삭제가 어렵기 때문에
index 기준으로 이전 공간 이후 공간을 훑어가며
사용되지 않는 공간 index를 추출해 해당 공간에 새로운 Entry를 생성하여 추가하는 내용이다!
만약 전체 Entry[] 배열을 돌았음에도 조건문에 걸리지 않는다면
ThreadLocal 을 참조하는 데이터도 존재하지 않고, 비어있는 공간도 없는것으로 판단해
새로운 Entry 객체를 만들어 추가해준다!
그 후 다시 ThreadLoca set() 함수로 돌아가
조회해온 ThreadLocalMap이 없다면 (null이라면)
ThreadLocalMap 생성자를 통해 새로 만들어준다!
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);
}
Entry 들을 담을 Entry[] 을 생성해주고
그 안에 새롭게 추가된 값을 저장해준다!
여기까지가 ThreadLcoal set() 함수의 흐름이다.
분석하면서 느낀 건데
사실 이 부분이 동시성 이슈를 해결했다? 는 부분은 아닌 거 같다.
단지 Thread 클래스에는 전역 변수로 LocalThreadMap 이 있기에
(즉, 한 Thread당 한 LocalThreadMap 이 있다.)
Thread Safe 하게 데이터를 관리할 수 있는 것뿐이었다.

다만 추가로 든 생각은
우리는 웹 서비스를 개발할 때 WAS에서는 Thread 생성 수거의 비용을 줄이기 위해
Thread Pool에 Thread를 미리 만들어 담아놓은 후
요청이 있을 때마다 가져다가 쓰게 된다.
그 말은 즉, 서버가 기동 되면 한 번에 일정 개수에 Thread Class 가 생성되게 되고
각 Thread 마다 저장 공간인 ThreadLocalMap 이 있다는 거다.
하지만 Thread는 한번 쓰이고 제거되는 것이 아니라 재 사용되기 때문에
(그렇기 때문에 요청에서는 최초의 set 이더라도 LocalThreadMap은 null이 아닐 수도 있다)
ThreadLocalMap에 저장된 Entry 데이터들을 제거해 주지 않으면
다른 요청에 같은 Thread 사용 시 데이터 동시성 이슈가 다시 일어날 수 있다는 점이다!
(중요 중요!!)
이때 데이터 제거는 remove() 함수를 사용한다.
물론 오늘 분석한 내용으로
동시성 이슈를 어떻게 해결했는지에 '아주 근본적인 해결 방안'은 알지 못했지만
'대략적인 방안' 은 알게 된 거 같고, 추가 이슈 가능성도 깨닫게 되어 좋은 시간이었다!
(대략적인 방안? Thread 별로 전역 변수에 데이터 저장 공간인 LocalEntryMap을 할당했다)
그럼 안녕!
'Programing > Java' 카테고리의 다른 글
우아한 테크캠프 Pro 프리코스 회고록 - 객체지향 생활체조 (2) | 2022.11.02 |
---|---|
Java Stream 모르고(?) 쓰면 일어나는 일들 (0) | 2022.09.16 |
Concurrency Problem 올바른 대응방법 (1) - Lock (0) | 2022.09.09 |
하나의 결제건이 다중 결제가 된다면? - Concurrency Programming (0) | 2022.08.01 |