동시성 — 여러 일을 동시에, 안전하게
스레드 둘이 같은 데이터를 건드리면 왜 깨질까? 가시성과 원자성이라는 두 문제에서 출발해, 락·원자 연산·스레드 풀을 거쳐 가상 스레드까지 — "동시에 빠르게"와 "안전하게"를 어떻게 양립시키는지 따라간다.
1. 모든 문제의 뿌리 — 가시성과 원자성
동시성 버그는 거의 다 둘 중 하나다.
① 가시성 — 한 스레드가 바꾼 값이 다른 스레드에 안 보일 수 있다. 07에서 본 대로 각 CPU 코어가 값을 자기 캐시에 들고 있어서다.
boolean stop = false; // 다른 스레드의 stop=true가 영영 안 보일 수 있다
while (!stop) { } // → 무한 루프
volatile boolean stop = false; // volatile: 항상 메인 메모리로 읽고 쓰고, 재정렬 차단
② 원자성 — count++는 사실 읽고-더하고-쓰는 세 동작이라, 둘이 동시에 하면 갱신이 유실된다. volatile은 가시성만 줄 뿐 이건 못 막는다.
volatile int count;
void inc() { count++; } // 💥 여전히 경쟁 (두 스레드가 같은 값을 읽고 둘 다 +1)
이 둘을 규율하는 게 **JMM(자바 메모리 모델)**의 happens-before — "이 쓰기는 저 읽기에 보인다"는 약속이다(volatile 쓰기→읽기, unlock→lock, Thread.start→실행 등). 07에서 본 것처럼 이건 결국 메모리 배리어로 내려가고, 하드웨어 메모리 모델(x86 TSO / ARM weak) 위의 이식 가능한 추상화다.
2. 막거나 — 락
가장 직접적인 해법은 한 번에 하나만 들어오게 막는 것이다.
synchronized void inc() { count++; } // 내장 모니터 락: 가시성 + 원자성 + 자동 해제
synchronized는 단순하고 블록을 벗어나면 자동 해제되는 게 장점이다. 더 많은 제어가 필요하면 ReentrantLock을 쓴다 — tryLock(timeout)·인터럽트·공정성·여러 Condition을 주지만, *반드시 finally에서 unlock*해야 한다.
lock.lock();
try { /* 임계 영역 */ } finally { lock.unlock(); }
CS 연결 —
synchronized는 객체 헤더(04에서 본 mark word)의 락 비트를 쓴다. 경합이 없으면 경량 락(CAS로 가볍게), 경합하면 중량 락(OS 뮤텍스로 승격해 블로킹)으로 단계가 오른다.
락이 둘 이상이면 **교착(deadlock)**을 조심한다. 두 스레드가 락을 반대 순서로 잡으면 서로를 영영 기다린다. 막는 법은 단순하다 — 전역 락 순서를 정해 항상 같은 순서로 획득한다.
3. 락 없이 — 원자 연산과 CAS
막는 건 비싸다(블로킹·컨텍스트 스위치). 그래서 가벼운 경합은 락 없이 처리한다.
AtomicInteger c = new AtomicInteger();
c.incrementAndGet(); // 원자적, 락 없음
이게 가능한 건 07에서 본 CAS(compare-and-swap) — LOCK CMPXCHG(x86) 같은 단일 CPU 명령 덕이다. "기대값이면 바꾸고, 아니면 실패→재시도"하는 낙관적 방식. 다만 경합이 아주 심한 카운터는 AtomicLong보다 **LongAdder**가 빠르다 — 셀을 여러 개로 쪼개 거짓 공유(07)를 피하고 마지막에 합산하기 때문이다.
4. 스레드를 직접 만들지 마라 — Executor와 Future
new Thread()를 손으로 만들면 개수 통제가 안 된다. 그래서 스레드 풀(ExecutorService)에 작업을 맡긴다.
ExecutorService pool = Executors.newFixedThreadPool(8);
Future<Integer> f = pool.submit(() -> heavy());
Integer r = f.get(2, TimeUnit.SECONDS); // 타임아웃과 함께 결과 대기
여러 비동기 작업을 엮을 땐 CompletableFuture로 파이프라인을 만든다(thenApply·thenCompose·allOf·exceptionally).
실무에서는 — 풀 크기는 왜 그렇게 잡나. 핵심은 커넥션 풀과 똑같다 — 코어 수가 진짜 병렬의 상한이다. CPU 바운드(계산만 하는) 작업은 스레드를 코어 수보다 늘려 봐야 병렬성은 안 늘고 컨텍스트 스위치·캐시 축출 오버헤드만 는다(사고사례 ⑥ 커넥션 풀과 같은 메커니즘) → 그래서 코어 수 근처. 반면 I/O 바운드(DB·네트워크 대기가 긴) 작업은 대기 중엔 코어를 안 쓰므로, 그 빈 코어를 다른 작업이 채우게 코어보다 크게 잡는다 — 대략
코어 수 × (1 + 대기시간/연산시간)(JCIP). 거부 정책도 필수다: 큐를 무한히 두면 처리 못 한 작업이 쌓여 힙이 차다 OOM → 큐에 상한을 두고 가득 차면 호출자 직접 실행/버리기/예외로 *역압(backpressure)*을 건다.
5. 그리고 가상 스레드 — 블로킹 코드로 대규모 동시성
전통적 스레드는 OS 스레드와 1:1이라 비싸다(스택 ~1MB·커널 컨텍스트 스위치). 그래서 동시성 한계가 낮았고, 복잡한 비동기/리액티브 코드로 우회해야 했다. 가상 스레드(Loom, Java 21)가 이 판을 바꾼다.
가상 스레드는 블로킹을 만나면 캐리어(진짜 OS 스레드)에서 언마운트되어 OS 스레드를 점유하지 않는다. 그래서 단순한 블로킹 코드 그대로 수백만 동시성을 얻는다.
실무에서는 — pinning은 왜 생겼고, 왜 이제는 거의 안 생기나. 가상 스레드의 핵심은 블로킹을 만나면 캐리어에서 언마운트해 스택을 힙에 보관하는 것이다. 그런데 Java 21~23에서는
synchronized모니터의 소유권이 캐리어(OS 스레드)의 정체성에 묶여 있었다. 그 안에서 블로킹하면 소유권을 안전하게 떼어낼 수 없으니 캐리어에 **고정(pin)**되고, 그 캐리어가 블로킹 내내 점유돼 수천 가상 스레드의 이점이 사라졌다. 당시 처방이 그 자리를ReentrantLock(모니터가 아니라 일반 객체 상태라 언마운트 가능)으로 바꾸는 것이었다.
Java 24의 JEP 491이 이 문제 자체를 없앴다. 모니터 소유권을 캐리어가 아니라 가상 스레드 자신의 정체성으로 추적하도록 JVM 모니터 구현을 다시 썼다. 이제 synchronized 블록 안에서도 언마운트되고, Object.wait()로 대기했다가 모니터를 다시 잡는 것도 캐리어를 붙잡지 않는다. 즉 synchronized → ReentrantLock 리팩터링은 더 이상 pinning 회피 목적으로는 필요 없다 — tryLock·Condition처럼 기능이 필요할 때만 쓴다.
다만 아직 캐리어를 고정하는 경우가 셋 남아 있다.
| 남은 pinning | 왜 | 실무 체감 |
|---|---|---|
| 네이티브 프레임(JNI·FFM) | 네이티브 코드가 raw 스택 포인터를 쥘 수 있어 안전한 언마운트가 불가능 | 암호화 프로바이더·특수 I/O 라이브러리 |
| 클래스 로딩·정적 초기화 | 클래스 초기화 중에는 언마운트 불가. 우회법 없음 | 기동 직후 한 번, 정상 운영 중엔 드묾 |
| 리눅스 로컬 파일 I/O | JDK에 아직 프로덕션 수준 io_uring 통합이 없음 | 대량 파일 읽기/쓰기 배치 |
정리하면 "웹 요청 → DB·HTTP 호출"이 대부분인 서버에서 pinning은 사실상 사라졌다. 그리고 가상 스레드는 풀을 만들지 말고 작업마다 새로 만든다(newVirtualThreadPerTaskExecutor).
그래서 병목은 어디로 갔나 — 스레드가 아니라 그 아래. pinning이 사라지자 지배적 실패 모드가 캐리어 고갈에서 다운스트림 자원 고갈로 옮겨갔다. 벤치마크가 이걸 그대로 보여준다 — JDBC 워크로드(동시 사용자 500, 60초)에서 처리량은 플랫폼 스레드 624 req/s → 가상 스레드 1,539 req/s로 +147% 올랐는데, 에러율은 양쪽 다 37%로 똑같았다. 원인은 커넥션 풀 포화다. 스레드 모델의 천장을 없앴을 뿐, 병목을 없앤 게 아니라 옮긴 것이다.
예전엔 스레드 풀이 암묵적으로 동시 요청 수를 묶어 줬다 — 풀이 200이면 동시에 DB를 때리는 요청도 200건이 최대였다. 가상 스레드에는 그 브레이크가 없다. 5,000개가 동시에 커넥션을 달라고 몰려도 풀이 10이면 4,990개가 줄을 선다. 그래서 자원마다 상한을 명시적으로 거는 것이 새 관용구가 된다.
// 스레드 풀이 대신 해 주던 "동시 실행 상한"을 이제 자원 앞에 직접 건다
private static final Semaphore DB_PERMITS = new Semaphore(50);
String findUser(String id) throws InterruptedException {
DB_PERMITS.acquire();
try {
return repository.find(id);
} finally {
DB_PERMITS.release();
}
}
가상 스레드에서
ThreadLocal은 조용히 손해가 된다. 플랫폼 스레드는 풀에서 재사용되니ThreadLocal캐시도 스레드 수만큼만 만들어진다. 가상 스레드는 요청마다 새로 만들고 버리므로 그 캐시가 요청마다 다시 만들어진다. 같은 60초 워크로드에서 초기화 횟수가 200회 → 443,267회로 약 2,200배가 된 측정이 있다. 예외도 경고도 없이 GC 압력만 늘어나서, 일부러 찾아보기 전에는 안 보인다. 무거운 객체를ThreadLocal에 캐싱하고 있었다면 가상 스레드로 넘어가기 전에 걷어내야 한다.
여기에 구조적 동시성(부모-자식 작업을 한 블록으로 묶어 함께 취소·대기 → 고아 작업 방지)과 Scoped Values(Java 25에서 정식화. ThreadLocal의 불변·자동 해제 대체이고, 위의 재초기화 폭증 문제도 여기서 풀린다)가 더해져, 대규모 동시성을 안전하게 다루게 해 준다.
(출처: JEP 491 — Synchronize Virtual Threads without Pinning · Virtual Threads after JDK 24 — InfoQ · JEP 506 Scoped Values)
6. 자주 데는 곳
실무에서는 — ①
ThreadLocal을 풀에서 안 지우면 다음 요청에 값이 새고 누수까지 난다 →try { set } finally { remove() }, 가능하면 Scoped Values. ② 불변 객체는 그 자체로 스레드 안전하다(01의 불변이 여기서 빛난다 — 공유해도 아무도 못 바꾸니까). ③ 공유가 읽기 압도적·쓰기 드묾이면CopyOnWriteArrayList, 동시 맵은ConcurrentHashMap(단computeIfAbsent안에서 같은 맵을 또 갱신하면 무한 루프 — 사고사례 참고). ④ 생산자-소비자는 직접wait/notify말고 **BlockingQueue**가 깔끔하다.
한눈에 정리
- 두 문제: 가시성(
volatile)과 원자성(volatile로는 부족). JMMhappens-before가 규율, 결국 메모리 배리어. - 락:
synchronized(자동 해제) vsReentrantLock(tryLock·Condition·수동 해제). 교착은 전역 락 순서로. - CAS: 락 없는 원자 연산(
CMPXCHG). 고경합 카운터는LongAdder. - Executor: 스레드 직접 생성 금지, 풀 + Future/CompletableFuture. 풀 크기·거부 정책 설계.
- 가상 스레드(21): 블로킹 코드로 대규모 동시성.
synchronizedpinning은 Java 24 JEP 491에서 제거됐고, 네이티브 프레임·클래스 초기화·리눅스 파일 I/O만 남았다. 구조적 동시성·Scoped Values. - 병목은 옮겨간다: 스레드 천장이 사라지면 다음 천장(커넥션 풀·rate limit)이 드러난다 → 자원마다
Semaphore로 상한을 명시.ThreadLocal캐시는 요청마다 재생성되니 재검토. - 실무: ThreadLocal
remove, 불변=스레드안전, 공유는 동시성 컬렉션, 생산자-소비자는 BlockingQueue.