← all posts
DEV 2026.05.02 · 11 min read Intermediate

Java 동시성은 왜 이렇게 설계됐을까

Thread 생명주기부터 Virtual Thread까지, Java 동시성 API 7개 챕터를 관통하는 설계 철학과 핵심 트레이드오프를 추적한다.


Java 동시성 API는 Thread에서 시작해 synchronized, ExecutorService, ConcurrentHashMap, AtomicInteger, CompletableFuture, 그리고 Virtual Thread까지 30년에 걸쳐 확장됐다. 각 챕터는 독립된 API처럼 보이지만, 사실 하나의 질문에 대한 반복된 대답이다. “어떻게 하면 공유 자원을 안전하게, 그러나 최대한 빠르게 다룰 수 있는가?”

단일 스레드의 한계, 그리고 공유의 대가

멀티스레딩의 출발점은 단순하다. CPU가 여러 개 있으니 동시에 실행하면 빠르다. 문제는 스레드들이 메모리를 공유한다는 데 있다.

static int count = 0;

// 1000개 스레드가 동시에
count++;  // 실제로는: 읽기 → +1 → 쓰기 (3단계)

count++는 코드 한 줄이지만 원자적이지 않다. 두 스레드가 동시에 읽으면 한 번의 증가가 사라진다. 이 **경쟁 조건(Race Condition)**이 동시성 문제의 뿌리다. Java는 이를 해결하기 위해 synchronized, volatile, Lock, Atomic 변수라는 네 가지 계층을 제공한다.

락의 진화: synchronized에서 Lock까지

synchronized는 가장 단순한 해답이다. 메서드나 블록 전체를 임계 영역으로 선언하고, 한 번에 하나의 스레드만 통과시킨다. JVM이 락 획득과 해제를 자동으로 관리하므로 실수가 없다. 대신 타임아웃을 걸 수 없고, 인터럽트도 불가능하며, 읽기와 쓰기를 구분하지 않는다.

ReentrantLock은 이 한계를 명시적 제어로 메운다. tryLock(500, MILLISECONDS)로 타임아웃을 걸고, lockInterruptibly()로 대기 중 인터럽트를 허용하며, ReentrantReadWriteLock으로 읽기 동시성을 높인다.

rwLock.readLock().lock();   // 읽기는 여러 스레드 동시 허용
rwLock.writeLock().lock();  // 쓰기는 배타적
트레이드오프

synchronized는 코드가 단순하고 JVM 최적화를 받지만, 세밀한 제어가 불가능하다. ReentrantLock은 타임아웃·공정성·조건 변수를 제공하지만 finally에서 반드시 unlock()을 호출해야 하는 책임이 따른다. 복합 조건이 없으면 synchronized가 더 안전한 선택이다.

락 없는 동기화: CAS와 Atomic 변수

락 기반 동기화의 약점은 블로킹이다. 락을 기다리는 스레드는 아무 일도 못 한다. AtomicInteger는 CPU 명령어 수준의 **CAS(Compare-And-Swap)**로 이를 우회한다.

do {
    expected = counter.get();
    newValue  = expected + 1;
} while (!counter.compareAndSet(expected, newValue));

“현재 값이 내가 읽은 값과 같으면 바꾸고, 다르면 다시 시도한다.” 락 없이 원자성을 보장한다. 경합이 낮을 때는 synchronized보다 빠르고, 데드락이 발생하지 않는다. 대신 경합이 극도로 높으면 CAS 루프가 CPU를 낭비한다. 그 경우 LongAdder가 내부를 여러 셀로 분산해 더 효율적이다.

같은 철학이 컬렉션에도 적용된다. ConcurrentHashMap은 전체 락 대신 버킷 단위 락 분할로 동시 쓰기를 허용하고, CopyOnWriteArrayList는 쓰기마다 배열을 복사해 읽기에 락을 걸지 않는다. 읽기가 압도적으로 많은 이벤트 리스너 목록 같은 구조에 적합하다.

스레드 관리의 추상화: ExecutorService

스레드를 매번 직접 생성하는 것은 비용이 크다. ExecutorService는 스레드 풀로 이 문제를 해결한다. 작업은 큐에 쌓이고 풀 안의 스레드들이 꺼내 실행한다.

Executors.newFixedThreadPool(n)은 CPU 집약적 작업에, newCachedThreadPool()은 짧은 I/O 작업에 어울린다. 더 세밀한 제어가 필요하면 ThreadPoolExecutor로 핵심 크기·최대 크기·큐·거부 정책을 직접 설정한다.

CompletableFuture는 여기서 한 걸음 더 나아간다. Future.get()의 블로킹 대신 콜백 체인으로 비동기 흐름을 선언적으로 표현한다.

fetchUser(id)
    .thenCompose(user -> fetchOrders(user))
    .thenCombine(fetchProfile(user), (orders, profile) -> assemble(orders, profile))
    .exceptionally(ex -> fallback());

세 API 호출이 적절히 병렬화되고, 예외는 체인 끝에서 한 번에 처리된다. 콜백 지옥 없이 비동기 조합이 가능하다.

Virtual Thread: 블로킹의 재발명

Platform Thread는 OS 스레드와 1:1로 대응한다. 스택만 1MB, 생성 비용도 크다. I/O를 기다리는 동안 그 스레드는 완전히 낭비된다. 수만 개의 동시 연결을 처리하려면 CompletableFuture 같은 비동기 스타일로 코드를 전면 재작성해야 했다.

Java 21의 Virtual Thread는 이 전제를 뒤집는다. JVM이 관리하는 경량 스레드로, 수백만 개를 생성할 수 있다. I/O에서 블로킹되면 JVM이 자동으로 Carrier Thread(OS 스레드)에서 분리하고, 다른 Virtual Thread를 그 자리에 올린다.

// Before: 스레드 풀 크기를 고민해야 했다
ExecutorService executor = Executors.newFixedThreadPool(200);

// After: 작업마다 Virtual Thread, 크기 걱정 없음
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();

동기 스타일 코드 그대로, 비동기 수준의 확장성을 얻는다.

트레이드오프

Virtual Thread는 I/O 바운드 작업에서 탁월하지만 CPU 집약적 작업에서는 이점이 없다. synchronized 블록 안에서 블로킹하면 Carrier Thread에 고정(Pinning)되어 다른 Virtual Thread를 막는다. 기존 코드를 마이그레이션할 때는 synchronizedReentrantLock으로 교체하고 ThreadLocal 사용을 최소화해야 한다.

정리

  • Java 동시성 API의 각 계층은 “공유 자원을 안전하게, 그러나 더 빠르게”라는 하나의 목표를 향한 점진적 해결책이다.
  • synchronized는 단순성, Lock은 유연성, Atomic은 락 프리 성능, ConcurrentCollection은 자료구조 수준의 안전성을 담당한다.
  • ExecutorServiceCompletableFuture는 스레드 관리와 비동기 조합을 추상화한다.
  • Virtual Thread는 블로킹 I/O의 비용을 JVM 수준에서 흡수해 동기 코드의 단순성과 비동기 코드의 확장성을 동시에 제공한다.

어떤 도구를 쓸지는 작업의 성격이 결정한다. I/O 많은 서버라면 Virtual Thread, 공유 카운터라면 AtomicLong, 읽기 집중 캐시라면 CopyOnWriteArrayList — 트레이드오프를 알고 선택하는 것과 모르고 쓰는 것은 완전히 다르다.