
예를 들어 다음과 같은 코드가 있다고 가정해보겠다.
public class concurrencyTest {
@Test
void 동시성_문제_테스트() throws InterruptedException {
ContextRepository repository = new ContextRepository();
RequestService service = new RequestService(repository);
Thread threadA = new Thread(() -> {
try {
service.logic("A");
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}, "Thread-A");
Thread threadB = new Thread(() -> {
try {
service.logic("B");
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}, "Thread-B");
threadA.start();
threadB.start();
threadA.join();
threadB.join();
}
// ======================
// Service
// ======================
static class RequestService {
private final ContextRepository repository;
// 문제의 원인이 되는 공유 필드
private List<String> context = new ArrayList<>();
public RequestService(ContextRepository repository) {
this.repository = repository;
}
public void logic(String parameter) throws InterruptedException {
System.out.println(Thread.currentThread().getName() + " 요청 시작");
context = repository.getContext(parameter);
System.out.println(Thread.currentThread().getName() + " 조회 데이터: " + context);
// 오래 걸리는 로직
Thread.sleep(5000);
System.out.println(Thread.currentThread().getName() + " 최종 데이터: " + context);
}
}
// ======================
// Repository
// ======================
static class ContextRepository {
public List<String> getContext(String parameter) {
List<String> result = new ArrayList<>();
if (parameter.equals("A")) {
result.add("A-user");
} else {
result.add("B-user");
}
return result;
}
}
}
코드를 간략히 설명해보겠다.
위의 테스트 코드를 실행하면 어떤 결과가 나올까?
threadA 먼저 실행을 시켰으니 다음과 같은 출력 결과를 기대할 수 있을것이다.
Thread-A 요청 시작
Thread-B 요청 시작
Thread-A 조회 데이터: [A-user]
Thread-B 조회 데이터: [B-user]
Thread-A 최종 데이터: [A-user]
Thread-B 최종 데이터: [B-user]
하지만 실제 결과는 위와같이 나올수가 없고 아래와 같이 나올것이다.
Thread-A 요청 시작
Thread-B 요청 시작
Thread-B 조회 데이터: [B-user]
Thread-A 조회 데이터: [A-user]
Thread-A 최종 데이터: [B-user]
Thread-B 최종 데이터: [B-user]
주목할 부분은 Thread-A 최종 데이터가 [A-user] 가 아닌 [B-user] 라는 것이다.
왜 이런 결과가 발생했을까?
결론부터 말하면 RequestService의 context 필드가 여러 스레드에서 접근하여 수정할 수 있기 때문이다.
실행 순서를 확인해보자.
Thread-A 요청 시작
context = repository.getContext("A");
위 코드가 실행되면서 context 리스트에는 다음 상태가 된다.
context = [A-user]
Thread-A가 5초 동안 대기
Thread.sleep(5000);
위 코드에 의해 Thread-A는 5초가 sleep 상태에 들어간다. 그동안 다른 스레드는 실행이 가능하다.
Thread-B 실행
context = repository.getContext("B");
위 코드가 실행되면서 context 변수는 새로운 List 객체를 참조하게 된다. 즉, Thread-B가 조회한 새로운 List 객체로 참조가 변경된다.
context = [B-user]
Thread-A에서 만든 context는 Thread-B가 context의 참조를 새로운 List로 변경하였다.
Thread-B가 5초동안 대기
이후 Thread-A가 최종 데이터를 출력
System.out.println(Thread.currentThread().getName() + " 최종 데이터: " + context);
sleep 이후에 context에 있는 값을 출력한다.
Thread-A 최종 데이터: [B-user]
Thread-B가 이미 데이터를 덮어썼기 때문에 Thread-A는 기대했던 A-user가 아닌 B-user 라는 값을 출력하게 된다.
이러한 문제가 발생하는 이유는 RequestService 객체의 생명주기와 메모리 공유 방식 때문이다.
일반적으로 Spring 환경에서 빈으로 등록된 클래스는 싱글톤 인스턴스로 생성된다.(여기서는 RequestService가 그에 해당한다고 가정한다.)
싱글톤 빈은 애플리케이션 전반에서 하나의 객체만 생성되며, 여러 요청을 처리하는 스레드들이 이 객체를 공유하게 된다.
이때 객체 내부의 인스턴스 필드는 힙 메모리에 저장되며 여러 스레드가 동시에 접근하고 수정할 수 있다.
따라서 요청마다 다른 데이터를 인스턴스 필드에 저장할 경우 멀티스레드 환경에서 서로의 데이터를 덮어쓰는 동시성 문제가 발생할 수 있다.
이를 해결하기 위해 스레드별로 독립적인 저장 공간을 제공하는ThreadLocal을 사용할 수 있다.
ThreadLocal은 멀티스레드 환경에서 각 스레드마다 독립적인 저장 공간을 할당받아, 스레드별로 독립적인 값을 저장할 수 있게 하는 Java 클래스이다.
즉 하나의 객체를 여러 스레드가 공유하더라도, ThreadLocal에 저장된 값은 각 스레드만이 조회하고 수정하게 되고 따라서 멀티스레드 환경에서 동기화 문제를 해결할 수 있다.
ThreadLocal에서 주로 사용하는 메서드는 다음과 같다.
앞에서 작성한 예제를 ThreadLocal을 사용하도록 수정해보면 다음과 같다.
static class RequestService {
private final ContextRepository repository;
// ThreadLocal 사용
private ThreadLocal<List<String>> context = new ThreadLocal<>();
public RequestService(ContextRepository repository) {
this.repository = repository;
}
public void logic(String parameter) throws InterruptedException {
System.out.println(Thread.currentThread().getName() + " 요청 시작");
context.set(repository.getContext(parameter));
System.out.println(Thread.currentThread().getName() + " 조회 데이터: " + context.get());
Thread.sleep(5000);
System.out.println(Thread.currentThread().getName() + " 최종 데이터: " + context.get());
// 사용 후 제거
context.remove();
}
}
위와 같이 수정 후 실행하면 다음과 같이 각 스레드별로 자신의 데이터를 조회해오는 것을 볼 수 있다.
Thread-B 요청 시작
Thread-A 요청 시작
Thread-B 조회 데이터: [B-user]
Thread-A 조회 데이터: [A-user]
Thread-B 최종 데이터: [B-user]
Thread-A 최종 데이터: [A-user]
ThreadLocal<String> threadLocal = new ThreadLocal<>();
threadLocal.set("value");
현재 실행중인 스레드에 값을 저장한다.
다른 스레드에서는 이 값을 조회할 수 없다.
String value = threadLocal.get();
현재 스레드에 저장된 값을 조회한다.
set()으로 저장한 값을 현재 스레드 기준으로 조회하며, 만약 저장된 값이 없다면 null이 반환된다.
threadLocal.remove();
현재 스레드에 저장된 값을 제거한다.
ThreadLocal은 스레드 내부의 ThreadLocalMap에 값을 저장하기 때문에 사용이 끝난 후 remove()를 호출하여 반드시 값을 정리해줘야 한다.
만약 스레드 풀 환경에서 remove()를 호출하지 않으면 작업이 끝난 스레드는 소멸되는 것이 아닌 스레드풀로 돌아가 다음 작업을 기다리기 때문에 다음 작업 수행시 데이터가 남아있는 문제가 발생할 수 있다.
이러한 이유 때문에 메모리 누수가 발생할 수 있기 때문에 꼭 작업을 완료한 경우 remove()를 호출하여 ThreadLocalMap을 비워줘야 한다.
ThreadLocal<Integer> threadLocal = ThreadLocal.withInitial(() -> 0);
초기 값을 설정하여 ThreadLocal을 생성할 수 있다.
public class ThreadLocal<T> {
private final int threadLocalHashCode = nextHashCode();
private static AtomicInteger nextHashCode =
new AtomicInteger();
private static final int HASH_INCREMENT = 0x61c88647;
private static int nextHashCode() {
return nextHashCode.getAndAdd(HASH_INCREMENT);
}
public ThreadLocal() {
}
}
생성자 내부에는 아무런 로직이 없다. 즉, ThreadLocal 객체를 생성한다고 해서 바로 어떤 저장 공간이 만들어지는 것은 아니다.
하지만 우리가 주목할 것은 필드 초기화 과정이다.
private final int threadLocalHashCode = nextHashCode();
private static AtomicInteger nextHashCode =
new AtomicInteger();
private static int nextHashCode() {
return nextHashCode.getAndAdd(HASH_INCREMENT);
}
객체가 생성될 때 nextHashCode() 를 호출하여 고유한 해시값을 하나 생성하여 변수로 가지고 있는다.
static으로 선언된 nextHashCode는 ThreadLocal 객체들이 공통으로 사용하는 전역 카운터 역할을 한다.
즉 새로운 ThreadLocal 객체가 생성될 때마다 이 값이 HASH_INCREMENT 만큼 증가하며(nextHashCode()), 각 ThreadLocal 인스턴스는 서로 다른 해시값을 갖게 된다.
이러한 장치 덕분에 스레드가 동시에 ThreadLocal 객체를 생성하더라도 원자적으로 해시값을 가질 수 있게 된다.
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;
}
}
}
ThreadLocal은 값을 내부적으로 ThreadLocalMap이라는 자료구조에 저장한다.
ThreadLocalMap은 내부적으로 Entry를 이용해 Key-value 형태로 데이터를 저장한다.
즉, 다음과 같은 구조이다
Thread
└─ ThreadLocalMap
└─ Entry (ThreadLocal : value)
ThreadLocal 객체는 해당 ThreadLocalMap에서 key 역할을 수행한다.
ThreadLocalMap 내부 구조에 주목해보자.
private Entry[] table;
ThreadLocalMap은 내부적으로 Entry 배열을 이용한 해시 테이블 구조를 가지고 있다.
즉 데이터를 LinkedList나 Tree 구조로 저장하는 것이 아니라 배열 기반 구조에 저장한다.
마치 다음과 같이 말이다.
index Entry
-------------------------
0 null
1 (ThreadLocalA → valueA)
2 (ThreadLocalB → valueB)
3 null
4 (ThreadLocalC → valueC)
각 배열 슬롯에는 하나의 Entry만 저장된다.
만약 두 ThreadLocal 객체가 동일한 index로 계산되어 해시 충돌이 발생하면, ThreadLocalMap은 Linear Probing 방식을 사용하여 다음 index를 순차적으로 탐색하며 비어있는 슬롯을 찾는다.(아래에서 자세히 보겠다.)
배열의 끝에 도달하면 다시 0부터 탐색하는 circular probing 방식을 사용한다.
Entry를 보면 key인 ThreadLocal이 WeakReference로 선언되어 있다는 것이다.
왜 약한 참조를 이용하여 ThreadLocalMap에 저장할까?
일반적인 참조 상황에서 다음 코드를 봐보자.
void service() {
ThreadLocal<User> local = new ThreadLocal<>();
local.set(new User("choi"));
}
위의 메서드가 실행 된 후에는 Stack 영역에서 local 변수는 사라져있을 것이다. 그리고 ThreadLocalMap이 local을 Key로 가지고 있을것이다.
Thread
└─ ThreadLocalMap
└─ Entry
├ key → ThreadLocal 객체
└ value → User("choi")
즉, Entry가 ThreadLocal 객체를 참조하고 있다.
이 경우에 GC는 참조가 아직 살아있다고 판단하고 GC의 대상에서 제외하게 된다.
그러면 결국 Entry를 ThreadLocalMap에서 제거해주지 않는 이상 해당 참조는 GC 대상으로 잡히는일은 없을것이다.
즉, 일반 참조를 사용하면 메모리 누수가 발생할 수 있다.
위의 케이스를 약한참조의 경우로 다시 생각해보자.
메서드가 실행된 후에 local 변수는 스택영역에서 사라진다. 그리고 Entry의 key인 ThreadLocal은 약한 참조를 가지고 있다.
그러면 GC는 ThreadLocal 객체를 GC 대상으로 판단하여 수거해가고 결국 key가 null인 상태로 남게된다.
Entry
├ key → null
└ value → User("kim")
즉, 이 형태가 stale entry다.
ThreadLocalMap은 stale entry가 계속 쌓이는 것을 방지하기 위해 특정 시점에 이를 정리하는 로직을 가지고 있다.(여기서는 간단히 설명만 하겠다.)
이 과정에서 key가 null인 Entry를 발견하면 해당 Entry를 제거하고 Map을 재정리한다.
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();
}
ThreadLocalMap getMap(Thread t) {
return t.threadLocals;
}
get 메서드를 보면 현재 스레드를 가져온 뒤 getMap()을 통해 해당 스레드의 threadLocalMap을 가져온다.
ThreadLocalMap getMap(Thread t) {
return t.threadLocals;
}
Map이 존재한다면 ThreadLocalMap의 getEntry() 메서드를 수행한다.
이때 파라미터로 ThreadLocal 자기 자신(this) 을 전달한다.
private Entry getEntry(ThreadLocal<?> key) {
int i = key.threadLocalHashCode & (table.length - 1);
Entry e = table[i];
if (e != null && e.refersTo(key))
return e;
else
return getEntryAfterMiss(key, i, e);
}
getEntry()를 보면 현재 ThreadLocal 객체가 가지고 있는 threadLocalHashCode를 이용해 ThreadLocalMap 내부 배열(table)에서 조회할 index를 계산한다.
이때 비트 AND 연산을 사용하여 배열 범위 내의 index를 구한다.
해당 index의 Entry가 현재 ThreadLocal과 동일한 key라면 바로 반환한다.
하지만 Entry가 존재하지 않거나 다른 ThreadLocal 객체가 저장되어 있다면 getEntryAfterMiss()를 호출한다.
private Entry getEntryAfterMiss(ThreadLocal<?> key, int i, Entry e) {
Entry[] tab = table;
int len = tab.length;
while (e != null) {
if (e.refersTo(key))
return e;
if (e.refersTo(null))
expungeStaleEntry(i);
else
i = nextIndex(i, len);
e = tab[i];
}
return null;
}
해당 인덱스에 Entry가 존재하지만 다른 ThreadLocal 객체가 저장되어 있는 경우. 즉, 해시 충돌 경우에는 getEntryAfterMiss() 메서드를 호출하여 다음 슬롯을 탐색하게 된다.
ThreadLocalMap은 일반적인 HashMap과 달리 Separate Chaining 방식이 아니라 Linear Probing 방식으로 충돌을 해결한다.
즉 충돌이 발생하면 다음 인덱스를 순차적으로 탐색하며 원하는 Entry를 찾는다.
위의 코드를 보면 null인 Entry 슬롯이 나올때까지 while문을 순회하여서 빈 공간을 찾는 것을 알 수 있다.
public void set(T value) {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null) {
map.set(this, value);
} else {
createMap(t, value);
}
}
getMap()을 통해 현재 Thread의 ThreadLocalMap을 가져온다.
해당 map이 null이 아니면 set()을 통해 값을 저장한다.
private void set(ThreadLocal<?> key, Object value) {
// We don't use a fast path as with get() because it is at
// least as common to use set() to create new entries as
// it is to replace existing ones, in which case, a fast
// path would fail more often than not.
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();
}
set 과정은 다음과 같이 동작한다.
즉 ThreadLocalMap은 Linear Probing 방식으로 저장할 위치를 찾는다.
다시 말해 key.threadLocalHashCode & (len-1) 계산을 통해 얻은 index에 이미 값이 존재하는 경우, 다음 index를 순차적으로 탐색하면서 비어있는 공간을 찾아 Entry를 저장한다.
탐색 과정에서 같은 ThreadLocal을 발견하면 e.value = value 를 통해 값을 업데이트한다.
이 부분은 일반적인 Map과 동일하다.
이번에 ThreadLocal의 실제 구현 코드를 직접 살펴보면서 느낀 점은, 평소에는 단순하게 사용하던 API 뒤에 생각보다 많은 설계 의도가 숨어 있다는 것이었다.
ThreadLocal은 겉으로 보기에는 set(), get() 정도의 간단한 API만 제공하는 단순한 클래스처럼 보이지만, 내부적으로는 ThreadLocalMap, WeakReference, Linear Probing, stale entry 등 다양한 구조를 통해 동작하고 있었다.
특히 인상 깊었던 부분은 메모리 누수 가능성을 고려한 설계였다. ThreadLocalMap의 Entry가 ThreadLocal을 WeakReference로 참조하도록 만든 이유나, stale entry를 정리하는 로직들을 보면서 단순히 기능만 구현하는 것이 아니라 장기적으로 발생할 수 있는 문제까지 고려하여 설계되어 있다는 점을 느낄 수 있었다.
또한 ThreadLocalMap이 일반적인 HashMap과 다르게 체이닝 방식이 아니라 Linear Probing 방식으로 충돌을 해결하는 구조를 사용한다는 점도 흥미로웠다. ThreadLocal의 사용 패턴을 고려했을 때 수많은 값을 Map에 담는다기보다 몇개의 값을 스레드간 공유되지 않도록 하기 위한 용도가 적합하고, 이를 고려해 Linear Probing 방식이 메모리 사용량과 성능 측면에서 더 효율적일 수 있다는 점을 알게 되었다.
이번 공부를 통해 단순히 ThreadLocal의 사용 방법을 아는 것에서 그치는 것이 아니라, 내부 구현을 이해하는 것이 왜 중요한지를 다시 한 번 느끼게 되었다. 특히 ThreadPool 환경에서 발생할 수 있는 메모리 누수 문제처럼, 내부 동작을 이해하지 못하면 쉽게 놓칠 수 있는 부분들도 있다는 것을 알게 되었다.
앞으로도 라이브러리나 프레임워크를 사용할 때 단순히 사용하는 것에 그치지 않고, 필요하다면 실제 구현 코드를 직접 살펴보면서 설계 의도와 내부 동작을 이해하려는 습관을 가져야겠다.