
ThreadLocal
ThreadLocal API
ThreadLocal은 multi-thread 환경에서 각각의 thread에게 별도의 저장 공간을 할당하여 별도의 상태를 갖도록 돕는 Java 라이브러리로, 쉽게 말해 특정 thread만 접근 가능한 특별한 저장소이다.
여러 thread가 동시에 저장소에 접근하더라도, 각 thread를 식별하여 thread에 맞는 고유한 데이터를 반환한다.
val threadLocal = ThreadLocal<String>()
fun main() {
threadLocal.set("Value from Main Thread")
println("Main Thread : ${threadLocal.get()}")
Thread {
println("New Thread : ${threadLocal.get()}")
threadLocal.set("Value from New Thread")
println("New Thread (after set) : ${threadLocal.get()}")
threadLocal.remove()
println("New Thread (after remove) : ${threadLocal.get()}")
}.start()
Thread.sleep(100)
println("Main Thread (after new thread) : ${threadLocal.get()}")
}
/*
Main Thread : Value from Main Thread
New Thread : null
New Thread (after set) : Value from New Thread
New Thread (after remove) : null
Main Thread (after new thread) : Value from Main Thread
*/
동시성 Context 관리 : Map vs. ThreadLocal
Multi-thread 환경에서 개별 사용자에게 특화된 데이터(Context)를 안전하게 관리하는 대표적인 방식으로 Map과 ThreadLocal이 존재한다.
public class Context {
private String username;
public Context(String username) {
this.userName = userName;
}
}
공유 맵(ConcurrentHashMap)을 사용한 방식
이 방식은 모든 thread가 하나의 중앙 집중식 저장소를 공유하고, thread ID나 사용자 ID를 키로 사용하여 데이터를 분리한다.
여러 thread가 하나의 자원(공유 맵)에 접근하기 때문에 데이터의 동시성(concurrency)를 보장하기 위해 ConcurrentHashMap 같은 동기화(synchronization) 메커니즘을 사용해야 한다.
public class SharedMapWithUserContext implements Runnable {
public static Map<Integer, Context> userContextPerUserId = new ConcurrentHashMap<>();
private Integer userId;
private UserRepository userRepository = new UserRepository();
@Override
public void run() {
String userName = userRepository.getUserNameForUserId(userId);
userContextPerUserId.put(userId, new Context(userName));
}
..
}
fun main() {
SharedMapWithUserContext firstUser = new SharedMapWithUserContext(1);
SharedMapWithUserContext secondUser = new SharedMapWithUserContext(2);
new Thread(firstUser).start();
new Thread(secondUser).start();
assertEquals(SharedMapWithUserContext.userContextPerUserId.size(), 2);
}
장점
- 중앙 집중식 관리 : 모든 thread의 데이터를 한 곳에서 볼 수 있어 모니터링 및 디버깅에 유용하다.
- 쉬운 접근 : 특정 thread가 아니라 다른 thread 및 관리 시스템에서도 사용자 ID만 알면 해당 Context에 접근하고 조작할 수 있다.
- 동시성 보장 : ConcurrentHashMap을 사용하여 여러 thread가 동시에 put / get 작업을 수행해도 데이터 손상이 없다.
한계 및 주의점
- 동기화(Synchronization) 오버헤드 : ConcurrentHashMap은 높은 성능을 제공하지만, 내부적으로 락을 사용하므로, 데이터 접근 시 약간의 동기화 오버헤드가 발생한다.
- 명시적 키 관리 : Thread가 해당 Context를 사용할 때마다 항상 사용자 ID(키)를 함께 전달하고 사용해야 한다.
- Thread pool 환경 : Thread가 종료될 때 (맵에서 해당 데이터를 수동으로 제거하지 않으면) 메모리에 남게 된다.
ThreadLocal을 사용한 방식 (분산 관리)
이 방식은 각 thread에게 변수의 독립적인 복사본을 제공하여 각 thread가 자신의 Context를 고유하게 관리하기 때문에 경쟁 조건(race condition)이 발생하지 않는다.
run() 메서드는 사용자 context를 불러오고, 이를 set() 메서드를 사용하여 ThreadLocal 변수에 저장한다.
public class ThreadLocalWithUserContext implements Runnable {
private static ThreadLocal<Context> userContext = new ThreadLocal();
private Integer userId;
private UserRepository userRepository = new UserRepository();
@Override
public void run() {
String userName = userRepository.getUserNameForUserId(userId);
userContext.set(new Context(userName));
try {
System.out.println("thread context for given userId: " + userId + " is: " + userContext.get());
} finally {
userContext.remove();
}
}
..
}
fun main() {
ThreadLocalWithUserContext firstUser = new ThreadLocalWithUserContext(1);
ThreadLocalWithUserContext secondUser = new ThreadLocalWithUserContext(2);
new Thread(firstUser).start();
new Thread(secondUser).start();
}
// thread context for given userId: 1 is: Context{userNameSecret='18a78f8e-24d2-4abf-91d6-79eaa198123f'}
// thread context for given userId: 2 is: Context{userNameSecret='e19f6a0a-253e-423e-8b2b-bca1f471ae5c'}
장점
- 완벽한 쓰레드 안전성 : 각 thread가 자신의 복사본을 사용하므로, 경쟁 조건(Race Condition) 자체가 발생하지 않는다. 락 등 별도의 동기화 메커니즘도 필요하지 않다.
- 편의성 : Thread가 자신의 Context에 접근할 때 별도의 키를 요구하지 않는다.
- 성능 우위 : 락이 전혀 필요 없어 동기화 오버헤드 없고, 읽기 성능이 향상된다.
한계 및 주의점
- 메모리 누수 위험 (Thread Pool 환경) : run() 실행 후 ThreadLocal 변수를 초기화하지 않으면, 재사용되는 thread는 이전 작업의 Context 데이터를 그대로 가지고 있다.

Atomic
Atomic 클래스(ex. AtomicInteger)는 락-프리(Lock-Free), 논블로킹(Non-Blocking) 방식으로 동시성을 해결한다.
원자성(Atomicity)은 특정 연산이 더 이상 나눌 수 없는 하나의 단위로 실행됨을 보장한다. 즉, 연산이 시작되면 완료될 때까지 다른 thread가 개입할 수 없어, 중간 상태가 노출되지 않는다.
일반적으로 i++ 같은 간단한 연산도 실제로는 '변수 값 읽기' > '1 더하기' > '변수 값 쓰기'의 세 단계로 이뤄진다.
Multi-thread 환경에서는 한 thread가 읽기 단계를 수행한 후, 다른 thread가 값을 변경하면 데이터 불일치(data inconsistency) 문제가 발생할 수 있다.
Atomic 클래스는 이러한 문제를 방지하기 위해 Compare-And-Swap (CAS) 알고리즘을 사용한다.
Compare and Swap (CAS)
Compare and Swap은 하드웨어 수준에서 원자성을 보장하는 하드웨어 명령어다.
기본적으로, 세 개의 피연산자를 사용한다.
| 피연산자 | 설명 |
| 메모리 위치 (V) | 값을 업데이트할 변수의 메모리 주소 |
| 예상 값 (A) | 현재 메모리 위치에 들어있을 것으로 기대하는 값 |
| 새로운 값 (B) | 업데이트할 새로운 값 |
동작 방식
- 현재 메모리 값(V)을 예상 값(A)과 비교한다.
- 같다면, 값이 변경되지 않은 것으로, V를 B로 원자적으로 업데이트한다.
- 다르다면, 다른 thread가 먼저 값을 변경한 것이기 때문에 어떤 작업도 수행하지 않고 재시도한다. (Spin-Lock 방식)
장점
- 락-프리(Lock-Free) 방식 : Thread를 대기 상태로 만들지 않아 context-swithing 오버헤드가 없다.
- 고성능 : 경합이 적은 환경에선 synchronized 보다 훨씬 빠르다.
- 교착 상태(Deadlock) 방지 : 락을 사용하지 않아 교착 상태의 위험이 없다.
단점
- ABA 문제 : CAS는 값만 비교하므로, 만약 값이 A > B > A로 바뀌는 경우, 값이 변경되지 않았다고 잘못 판단할 수 있다. (이를 해결한 라이브러리로 AtomicStampedReference가 있다.)
- 스핀(Spin) : 경합이 매우 심하다면 thread가 계속 실패하고 재시도하여 CPU 자원을 낭비할 수 있다.
Atomic vs. synchronized
아래 코드는 1,000개의 thread가 각각 10,000번씩 카운터를 증가시키는 예제다.
AtomicInteger와 synchronized를 사용하는 두 가지 경우를 비교하여 최종 카운터 값이 올바른지 확인한다.
import java.util.concurrent.atomic.AtomicInteger;
class ConcurrencyComparison {
// 1. AtomicInteger
private val atomicCounter = AtomicInteger(0)
// 2. synchronized
private val lock = Any()
private var synchronizedCounter = 0
fun runComparison() {
val numberOfThreads = 1000
val incrementsPerThread = 10000
val atomicThreads = Array(numberOfThreads) {
Thread {
for (j in 0 until incrementsPerThread) {
atomicCounter.incrementAndGet()
}
}
}
atomicThreads.forEach{ it.start() }
atomicThreads.forEach{ it.join() }
println("AtomicInteger Final Count : ${atomicCounter.get()}")
val synchronizedThreads = Array(numberOfThreads) {
Thread {
for (j in 0 until incrementsPerThread) {
synchronized(lock) {
synchronizedCounter++
}
}
}
}
synchronizedThreads.forEach { it.start() }
synchronizedThreads.forEach { it.join() }
println("Synchronized Final Count : $synchronizedCounter")
}
}
fun main() {
ConcurrencyComparison().runComparison()
}
/*
AtomicInteger Final Count : 10000000
Synchronized Final Count : 10000000
*/
AtomicInteger와 synchronized 모두 항상 10,000,000을 출력할 것을 보장한다.
하지만 내부적으로 synchronized는 synchronized(lock) 블록을 통해 한 번에 하나의 thread만 접근하도록 보장하여 연산을 수행하는 반면, AtomicInteger는 CAS 재시도를 통해 대기 없이 효율적으로 처리한다.
이 차이는 단순한 연산의 경우 Atomic이 더 빠르고 가볍게 동작함을 의미한다.
락을 구현하려면 CPU에서 반드시 Atomic을 지원해야 하기 때문에 Atomic은 항상 같이 지원된다.
결론
Atomic
- 락-프리(Lock-free) 기반의 논블로킹(non-blocking) 방식
- 단일 변수에 대한 간단한 연산에 매우 효율적이며, 높은 동시성을 요구하는 환경에 적합하다.
- ex. 고성능 카운터, Boolean flag 제어, Linked list 노드 교체, thread pool 상태 관리
synchronized
- 락 기반 블로킹(blocking) 방식
- 여러 연산을 묶어 하나의 단위로 처리할 때 유용하며, 복잡한 동기화 요구사항에 적합하다.
- ex. 공유 자원 접근 제어, Singleton 객체 생성, ArrayList 등 thread-safe 컬렉션 구현
참고 자료
- ThreadLocal (Java Platform SE 8 )
- An Introduction to ThreadLocal in Java | Baeldung
- Java Thread Local(쓰레드 로컬)은 무엇일까?
- [Java] Atomic Type vs Synchronized
Powered By. ChatGPT & Gemini
'Backend' 카테고리의 다른 글
| [Backend] Concurrent Collections (동시성 컬렉션) (1) | 2025.09.25 |
|---|---|
| [Backend] Pub/Sub Pattern (1) | 2025.09.24 |
| [Backend] 경쟁 상태와 락 (Race Condition & Lock) (1) | 2025.09.19 |
| [Backend] Process & Thread (2) | 2025.09.17 |
| [Backend] 데이터 구조 (Queue) (0) | 2025.09.11 |