동시성 — 여러 일을 동시에, 안전하게
스레드 둘이 같은 데이터를 건드리면 왜 깨질까? 가시성과 원자성이라는 두 문제에서 출발해, 락·원자 연산·스레드 풀을 거쳐 가상 스레드까지 — "동시에 빠르게"와 "안전하게"를 어떻게 양립시키는지 따라간다.
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은 왜 생기나. 가상 스레드의 핵심은 블로킹을 만나면 캐리어에서 언마운트해 스택을 힙에 보관하는 것이다. 그런데
synchronized모니터를 쥔 채로는 그 모니터 소유권이 캐리어(OS 스레드)에 묶여 있어 안전하게 떼어낼 수 없다 → 캐리어에 **고정(pin)**되고, 그 캐리어가 블로킹 내내 점유돼 수천 가상 스레드의 이점이 사라진다. 그래서 그 자리를ReentrantLock(모니터가 아니라 일반 객체 상태라 언마운트 가능)으로 바꾼다(Java 24에서 상당 완화). 그리고 가상 스레드는 풀을 만들지 말고 작업마다 새로 만든다(newVirtualThreadPerTaskExecutor).
여기에 구조적 동시성(부모-자식 작업을 한 블록으로 묶어 함께 취소·대기 → 고아 작업 방지)과 Scoped Values(ThreadLocal의 불변·자동 해제 대체)가 더해져, 대규모 동시성을 안전하게 다루게 해 준다.
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 주의. 구조적 동시성·Scoped Values. - 실무: ThreadLocal
remove, 불변=스레드안전, 공유는 동시성 컬렉션, 생산자-소비자는 BlockingQueue.