WebFlux 회의에서 다시 꺼낸 두 단어 — ConcurrentHashMap 과 ThreadLocal
회의 중에 "이 store 는 동시성 안전한가요?" 라는 질문이 나왔고, 그 자리에서
ConcurrentHashMap과ThreadLocal이 같이 거론됐습니다. 두 단어가 함께 나오는 게 처음에는 살짝 헷갈렸습니다. 둘은 비슷한 문제를 푸는 것 같으면서도, 사실은 공유 범위가 정반대인 도구 였습니다. 이번 글은 그 차이를 다시 정리하고, WebFlux 의 실행 모델 위에서 어느 쪽이 어울리는지를 확인한 기록입니다.
1. 시작은 WebFlux 의 실행 모델
WebFlux 는 전통적인 "요청당 스레드 1개" 모델이 아닙니다. 거기서부터 이야기를 풀어야 두 단어의 자리가 잡힙니다.
1.1 기존 Servlet 모델
요청 → Thread-1 → 처리 → 응답요청을 받은 스레드가 응답을 내보낼 때까지 그대로 점유합니다. 한 요청 = 한 스레드 가 거의 1:1 입니다.
1.2 WebFlux 의 이벤트 루프 모델
요청 → 이벤트 루프 스레드
↓
작업 스레드 (비동기)
↓
이벤트 루프로 결과 전달
↓
응답- 이벤트 루프 스레드는 보통 OS 코어 수에 맞춰 적게 만듭니다.
- 블로킹 작업은 별도 작업 스레드에서 수행합니다.
- 요청을 받은 스레드와 작업을 처리한 스레드가 다른 게 기본 입니다.
이 모델 위에서 "요청 하나에 묶인 상태" 를 어디에 둘 것인가 라는 질문이 자연스럽게 나옵니다.
2. 회의에서 나온 문제 상황
대략 이런 흐름을 다루고 있었습니다.
1) 요청 수신
2) requestId 생성
3) 별도 store 에 (requestId → Sink/Mono/Context) 저장
4) 작업 스레드에서 처리
5) 완료 시 requestId 로 store 조회
6) 응답 전송
7) store 에서 제거여기서 나왔던 질문은 이거였습니다.
"이 store, 동시성 안전합니까?"
그 답을 풀어 가는 과정에서 ConcurrentHashMap 과 ThreadLocal 이 같이 거론됐습니다. 두 단어의 자리를 잡으려면 먼저 메모리 구조부터 짚어야 합니다.
3. 스레드와 메모리 — Heap 은 공유, Stack 은 독립
자바 메모리 모델에서 스레드 사이의 공유 범위는 이렇게 갈립니다.
- Heap 영역 — 모든 스레드가 공유합니다.
- Stack 영역 — 각 스레드가 독립적으로 가집니다.
Heap (공유)
├─ Map
├─ 객체들
Thread-1 Stack
Thread-2 Stack
Thread-3 Stack여기서 중요한 사실 하나가 있습니다. ConcurrentHashMap 도, ThreadLocal 도 결국 값을 Heap 에 둡니다. 다만 누가 그 값에 접근할 수 있느냐가 다릅니다.
ConcurrentHashMap의 값 — 모든 스레드가 같은 Map 객체를 참조해 같은 값을 읽고 쓸 수 있습니다.ThreadLocal의 값 — 각Thread객체 안에 있는ThreadLocalMap에 저장됩니다. 다른 스레드는 그 Map 에 접근할 수 없습니다.
이 한 줄이 두 도구의 운명을 가릅니다.
4. ConcurrentHashMap — 여러 스레드가 같이 보는 자료구조
ConcurrentHashMap 은 여러 스레드가 동시에 접근해도 안전한 Map 입니다.
ConcurrentHashMap<K, V> map = new ConcurrentHashMap<>();내부적으로는 다음과 같은 장치들을 사용합니다.
- 버킷 단위(
bin)로 락을 잘게 쪼개서 경합을 줄입니다. - CAS(Compare-And-Swap) 기반 원자 연산을 활용합니다.
- 읽기(
get) 는 대부분의 경우 락이 거의 들지 않습니다.
4.1 원자적 연산이 들어 있음
값 변경을 안전하게 묶어 주는 연산들이 들어 있습니다.
map.compute(key, (k, v) -> v == null ? 1 : v + 1);get 후 put 을 따로 부르면 그 사이가 비기 때문에 경합이 생깁니다. compute, merge, putIfAbsent 같은 메서드는 그 사이 빈 자리를 없애 줍니다.
4.2 흔한 패턴 — 요청 중복 방지
private final ConcurrentHashMap<String, Boolean> running = new ConcurrentHashMap<>();
if (running.putIfAbsent(userId, true) == null) {
// 이 자리에 들어온 스레드만 실제 작업 수행
}putIfAbsent 는 "키가 없을 때만 넣고, 있으면 가만히 둔다" 를 한 번의 원자 연산으로 처리합니다. 동시에 들어온 같은 사용자 ID 의 요청 중 하나만 작업을 시작하도록 잠금 역할을 해 줍니다.
4.3 사용 시점
여러 스레드가 공유 데이터를 같이 읽고 쓰는 경우.
일반 HashMap 을 멀티스레드에서 그대로 쓰면 동시에 put 이 들어왔을 때 내부 구조가 깨지면서 무한 루프나 데이터 손실이 발생할 수 있습니다. 공유가 전제라면 ConcurrentHashMap 이 기본값입니다.
5. ThreadLocal — 스레드마다 자기 전용 칸
ThreadLocal 은 같은 변수처럼 보이지만, 스레드마다 자기 전용 값을 가지는 구조입니다.
private static final ThreadLocal<String> currentUser = new ThreadLocal<>();
currentUser.set("user1");Thread-1 이 set("user1") 한 값을, Thread-2 는 절대 보지 못합니다. 각 Thread 객체 내부의 ThreadLocalMap 에 따로 저장되기 때문입니다.
5.1 사용 시점
한 요청(한 스레드) 내에서만 공유해야 하는 값.
대표적인 자리가 이런 것들입니다.
- 요청 컨텍스트(현재 사용자, 트랜잭션 ID, 로깅용 MDC)
- 단일 요청 안에서만 유효한 임시 객체 (SimpleDateFormat 같은 스레드 비안전 객체의 캐시)
5.2 주의 — 스레드 풀과 만나면 위험합니다
ThreadLocal 의 값은 스레드가 살아 있는 동안 남습니다. 스레드 풀처럼 스레드가 재활용되는 환경에서 remove() 를 빠뜨리면, 다음 요청이 이전 요청의 값을 보게 될 수 있습니다. 이건 보안 이슈로 직결되는 자리이기도 해서, try / finally 로 항상 정리하는 습관이 필수입니다.
try {
currentUser.set(userId);
// ... 작업 ...
} finally {
currentUser.remove();
}6. 둘의 차이를 한 표로
| 구분 | ConcurrentHashMap | ThreadLocal |
|---|---|---|
| 공유 범위 | 모든 스레드 | 각 스레드 독립 |
| 목적 | 동시 접근 안전 | 스레드별 상태 저장 |
| 데이터 충돌 | 가능 → 락 / CAS 로 제어 | 없음 (애초에 공유 안 함) |
| 메모리 위치 | 하나의 Map 객체 (Heap) | 각 Thread 내부 ThreadLocalMap (Heap) |
| 회수 책임 | 보통 명시적 remove() 필요 없음 | remove() 안 부르면 누수 가능 |
한 줄로 정리하면 이렇게 적어둘 수 있을 것 같습니다.
ConcurrentHashMap은 "여러 명이 같은 상자를 본다",ThreadLocal은 "각자 자기 상자만 본다".
7. 다시 회의 질문으로 — 어느 쪽을 골라야 했을까
처음 회의의 흐름을 다시 보면, store 가 다루는 데이터는 다음과 같았습니다.
- 요청을 받은 스레드가
requestId → Sink/Mono/Context를 저장합니다. - 다른 작업 스레드가 그 값을 꺼내 처리합니다.
- 완료 시 또 다른 스레드가 store 에서 제거합니다.
여기서 핵심은 저장하는 스레드와 꺼내는 스레드가 같지 않다 는 점이었습니다. ThreadLocal 은 자기 스레드만 보는 도구이기 때문에 이 경우엔 애초에 작동할 수 없습니다. 작업 스레드는 요청 스레드의 ThreadLocal 칸을 들여다 볼 수 없으니까요.
그래서 이 자리에선 답이 자연스럽게 ConcurrentHashMap 쪽으로 정해졌습니다.
여러 스레드가 같은 store 를 보고 읽고 쓰는 상황이라면
ConcurrentHashMap, 한 스레드 안에서만 흘러 다니는 값이라면ThreadLocal.
8. 주니어로서 이번에 정리한 것
① 두 단어가 함께 나오는 자리에선, 먼저 "값을 누가 보느냐" 부터 묻기
ConcurrentHashMap 과 ThreadLocal 이 같은 문장에 나오면 한 번 멈춰서 적어 두려고 합니다. "여러 스레드가 같이 보는 값이냐, 한 스레드 안에서만 흘러 다니는 값이냐." 이 한 줄이 정해지면 도구는 거의 자동으로 결정됩니다.
② WebFlux 에서 ThreadLocal 은 거의 함정 카드입니다
WebFlux 는 요청 처리 도중에 스레드가 바뀌는 게 기본입니다. 그래서 요청 단위 컨텍스트를 ThreadLocal 로 잡아 두면 작업 스레드에서는 그 값을 잃어버립니다. 이 자리에선 Reactor Context 같은 다른 도구를 써야 합니다. 이번에 정리하면서, 같은 "요청 단위 상태" 라는 표현이라도 실행 모델에 따라 도구가 완전히 달라진다는 걸 다시 느꼈습니다.
③ ConcurrentHashMap 에서 get + put 은 원자가 아닙니다
흔한 실수가 if (!map.containsKey(k)) map.put(k, v); 입니다. containsKey 와 put 사이에 다른 스레드가 끼어들 수 있습니다. putIfAbsent, compute, merge 같은 메서드를 쓰는 게 맞는 경로였습니다.
마무리
이번에 가장 크게 남는 한 줄은 이거였습니다.
두 도구의 차이는 "동기화 vs. 동기화 안 함" 이 아니라, "공유 vs. 독립" 이었다.
ConcurrentHashMap 은 공유를 안전하게 만들어 주는 도구였고, ThreadLocal 은 애초에 공유를 안 하기 위한 도구였습니다. 풀어야 하는 문제가 어느 쪽인지를 한 줄로 적어 두기만 해도 둘 사이에서 헤맬 일이 줄어들 것 같습니다.
다음 글에서는 가상 스레드의 힙 메모리와 컨텍스트 스위칭 이야기를 이어서 정리해 보려고 합니다. 가상 스레드는 두 도구의 그림을 한 번 더 다른 모양으로 흔드는 도구라서, 그 위에서 ThreadLocal 이 어떻게 동작하는지를 같이 적어 두면 좋을 것 같습니다.