~/khan

WebFlux 회의에서 다시 꺼낸 두 단어 — ConcurrentHashMap 과 ThreadLocal

· 13 min read · by Khan
#Java#WebFlux#동시성#ConcurrentHashMap#ThreadLocal

회의 중에 "이 store 는 동시성 안전한가요?" 라는 질문이 나왔고, 그 자리에서 ConcurrentHashMapThreadLocal 이 같이 거론됐습니다. 두 단어가 함께 나오는 게 처음에는 살짝 헷갈렸습니다. 둘은 비슷한 문제를 푸는 것 같으면서도, 사실은 공유 범위가 정반대인 도구 였습니다. 이번 글은 그 차이를 다시 정리하고, 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, 동시성 안전합니까?"

그 답을 풀어 가는 과정에서 ConcurrentHashMapThreadLocal 이 같이 거론됐습니다. 두 단어의 자리를 잡으려면 먼저 메모리 구조부터 짚어야 합니다.

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);

getput 을 따로 부르면 그 사이가 비기 때문에 경합이 생깁니다. 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. 둘의 차이를 한 표로

구분ConcurrentHashMapThreadLocal
공유 범위모든 스레드각 스레드 독립
목적동시 접근 안전스레드별 상태 저장
데이터 충돌가능 → 락 / CAS 로 제어없음 (애초에 공유 안 함)
메모리 위치하나의 Map 객체 (Heap)각 Thread 내부 ThreadLocalMap (Heap)
회수 책임보통 명시적 remove() 필요 없음remove() 안 부르면 누수 가능

한 줄로 정리하면 이렇게 적어둘 수 있을 것 같습니다.

ConcurrentHashMap 은 "여러 명이 같은 상자를 본다", ThreadLocal 은 "각자 자기 상자만 본다".

7. 다시 회의 질문으로 — 어느 쪽을 골라야 했을까

처음 회의의 흐름을 다시 보면, store 가 다루는 데이터는 다음과 같았습니다.

  • 요청을 받은 스레드가 requestId → Sink/Mono/Context 를 저장합니다.
  • 다른 작업 스레드가 그 값을 꺼내 처리합니다.
  • 완료 시 또 다른 스레드가 store 에서 제거합니다.

여기서 핵심은 저장하는 스레드와 꺼내는 스레드가 같지 않다 는 점이었습니다. ThreadLocal 은 자기 스레드만 보는 도구이기 때문에 이 경우엔 애초에 작동할 수 없습니다. 작업 스레드는 요청 스레드의 ThreadLocal 칸을 들여다 볼 수 없으니까요.

그래서 이 자리에선 답이 자연스럽게 ConcurrentHashMap 쪽으로 정해졌습니다.

여러 스레드가 같은 store 를 보고 읽고 쓰는 상황이라면 ConcurrentHashMap, 한 스레드 안에서만 흘러 다니는 값이라면 ThreadLocal.

8. 주니어로서 이번에 정리한 것

① 두 단어가 함께 나오는 자리에선, 먼저 "값을 누가 보느냐" 부터 묻기

ConcurrentHashMapThreadLocal 이 같은 문장에 나오면 한 번 멈춰서 적어 두려고 합니다. "여러 스레드가 같이 보는 값이냐, 한 스레드 안에서만 흘러 다니는 값이냐." 이 한 줄이 정해지면 도구는 거의 자동으로 결정됩니다.

② WebFlux 에서 ThreadLocal 은 거의 함정 카드입니다

WebFlux 는 요청 처리 도중에 스레드가 바뀌는 게 기본입니다. 그래서 요청 단위 컨텍스트를 ThreadLocal 로 잡아 두면 작업 스레드에서는 그 값을 잃어버립니다. 이 자리에선 Reactor Context 같은 다른 도구를 써야 합니다. 이번에 정리하면서, 같은 "요청 단위 상태" 라는 표현이라도 실행 모델에 따라 도구가 완전히 달라진다는 걸 다시 느꼈습니다.

ConcurrentHashMap 에서 get + put 은 원자가 아닙니다

흔한 실수가 if (!map.containsKey(k)) map.put(k, v); 입니다. containsKeyput 사이에 다른 스레드가 끼어들 수 있습니다. putIfAbsent, compute, merge 같은 메서드를 쓰는 게 맞는 경로였습니다.

마무리

이번에 가장 크게 남는 한 줄은 이거였습니다.

두 도구의 차이는 "동기화 vs. 동기화 안 함" 이 아니라, "공유 vs. 독립" 이었다.

ConcurrentHashMap 은 공유를 안전하게 만들어 주는 도구였고, ThreadLocal 은 애초에 공유를 안 하기 위한 도구였습니다. 풀어야 하는 문제가 어느 쪽인지를 한 줄로 적어 두기만 해도 둘 사이에서 헤맬 일이 줄어들 것 같습니다.

다음 글에서는 가상 스레드의 힙 메모리와 컨텍스트 스위칭 이야기를 이어서 정리해 보려고 합니다. 가상 스레드는 두 도구의 그림을 한 번 더 다른 모양으로 흔드는 도구라서, 그 위에서 ThreadLocal 이 어떻게 동작하는지를 같이 적어 두면 좋을 것 같습니다.