자바 동시성의 역사는 “스레드는 비싸고 블로킹은 그 비싼 스레드를 낭비한다”는 단 하나의 문제를 풀어 온 과정이며, 그 종착점이 가상 스레드입니다.
쉽게 말하면
스레드를 식당에서 비싸게 고용한 요리사라고 해 봅시다. 그런데 요리사 대부분은 정작 칼질하는 시간보다 오븐 앞에서 음식이 익기를(= I/O 응답을) 멍하니 기다리는 시간이 더 깁니다. 고용 비용은 비싼데 손은 놀고 있는 셈입니다.
자바 동시성의 모든 진화는 “이 노는 요리사를 어떻게 줄일까”라는 한 가지 고민의 연속이었습니다. 이그제큐터는 요리사를 미리 뽑아 돌려쓰게 했고(스레드 풀), CompletableFuture와 리액티브는 “기다리는 동안 다른 일을 하라”며 요리사를 재배치했습니다. 다만 재배치 규칙이 복잡해서 주방 운영 방식을 통째로 다시 배워야 하는 비용이 따라왔습니다.
가상 스레드 (virtual thread)는 발상을 바꿉니다. 요리사가 오븐 앞에 서는 순간 자리를 비켜 주고 그 자리에서 다른 요리사가 일하다가, 음식이 익으면 원래 요리사가 돌아옵니다. 익숙한 “기다리는” 코드를 그대로 쓰면서도 요리사를 거의 놀리지 않는 것입니다.
왜 중요한가?
스레드는 가볍지 않습니다
힙 밖에서 스레드당 약 2 MiB 메모리를 점유합니다. 수천 동시 요청이면 메모리가 급격히 소진되고, 물리 메모리를 다 쓰면 페이징(디스크 I/O)이 발생해 성능이 급락합니다(디스크는 RAM보다 약 1,000배 느림).
자바 스레드는 OS 네이티브 스레드를 얇게 감싼 것입니다. 따라서 생성 가능한 최대 개수가 OS 한도에 묶이고, 곧 애플리케이션 확장성의 상한이 됩니다. 책의 실험에서는 16,363개를 생성한 뒤 OutOfMemoryError가 발생했습니다.
스레드 전환은 곧 컨텍스트 전환이라 CPU 사이클을 소모합니다. 고부하 시스템에서 과소평가되기 쉬운 비용입니다.
진짜 낭비는 “노는 스레드”입니다
신용 점수 계산 예제(getPerson → getAssets → getLiabilities → …)에서 각 메서드가 DB·API 응답을 200ms씩 기다리면 5번을 순차 호출하는 데 1초가 걸립니다. 이 시간 동안 스레드는 일하고 싶어도 I/O에 막혀 아무것도 못 합니다. 비싼 스레드가 단순히 기다리는 데 허비되는 것 — 클라우드 기반의 고확장성 시스템에서 확장성을 갉아먹는 핵심이 바로 이것입니다.
블로킹이 비싼 스레드를 낭비한다. 이 한 문장이 챕터 전체를 관통하며, 이후 등장하는 모든 기법은 이 낭비를 줄이려는 시도입니다.
핵심 내용
동시성 vs 병렬성
롭 파이크(Rob Pike)
동시성(concurrency)은 여러 가지 일을 한 번에 처리(dealing with)하는 것을 말하고, 병렬성(parallelism)은 여러 가지 일을 한 번에 수행(doing)하는 것을 말한다.
구분
의미
전제
비유
동시성
작업이 겹쳐서 진행되도록 설계하는 것
코어 1개로도 가능
요리사 한 명이 여러 요리를 능숙하게 다룸
병렬성
여러 작업을 실제로 동시에 수행
최소 2코어 필요
여러 명이 나란히 집을 지음
자바는 스레드 위에 세워졌다
자바 1.0(1996)부터 언어 수준에서 스레드를 지원했습니다. Hello World조차 메인 스레드에서 실행되고, 예외(호출 스택), 디버거(step into/over/out), 프로파일러, GC·JIT·finalizer 같은 JVM 서비스가 모두 스레드 위에서 동작합니다. 스레드는 자바 플랫폼의 중추라서 의식하지 않아도 늘 그 힘을 쓰고 있는 셈입니다.
스레드 생성 방식의 진화
방식
도입
특징
extends Thread
1.0
스레드 메서드에 직접 접근하지만 단 하나뿐인 상속을 소모
implements Runnable
1.0
할 일(태스크)과 스레드 관리를 분리, 상속을 아끼므로 더 나음
익명 내부 클래스
1.1
Runnable 구현을 더 간결하게
람다
8
함수형 인터페이스라 한 줄로
Executor
5
스레드 풀로 추상화 (아래에서 상세)
약 30년간 이 기본 틀이 그대로라는 점이 스레드 API가 견고하게 설계됐다는 방증입니다.
run()을 직접 호출하지 말 것
스레드 실행은 start() 호출로 시작됩니다. start()는 시스템 자원 할당 같은 준비 작업을 마친 뒤 새 스레드에서run()을 호출합니다. run()을 직접 호출하면 새 스레드가 아니라 현재 스레드에서 실행돼 버립니다.
블로킹을 줄이려는 진화
챕터의 본론입니다. 각 기법은 앞 기법이 남긴 한계를 메우며 등장했습니다.
무제한 스레드 병렬화 — 의존 관계 없는 작업을 별도 스레드로 띄워 병렬 실행합니다(스레드 간 안전 공유는 AtomicReference). 순차 1,026ms → 병렬 616ms로 약 1.66배 빨라졌습니다. 하지만 수명주기·자원 제어가 없어 무분별하게 생성하면 OutOfMemoryError로 이어집니다.
이그제큐터 프레임워크 — 스레드 풀로 미리 만들어 재사용하고 구조화합니다. try-with-resources로 풀이 자동 정리됩니다. 남은 한계: Future.get()은 여전히 블로킹(블로킹 위치만 옮길 뿐 제거하지 못함), 캐시 일관성/거짓 공유, 명령형이라 합성력 부족.
포크/조인 풀 — Work-Stealing(스레드마다 전용 큐를 두고, 일을 끝낸 스레드가 다른 스레드 큐의 끝에서 태스크를 훔쳐옴)으로 노는 스레드를 없애고 CPU 사용률을 극대화합니다. 또 워커가 로컬 큐 작업을 처리하므로 캐시 선호도(cache affinity)를 살려 캐시 미스를 최소화합니다.
CompletableFuture (자바 8) — 콜백 지옥 대신 평문형(fluent) 합성 API로 비동기 연산을 선언적으로 연결합니다. 내부적으로 포크/조인 위에서 Work-Stealing을 활용합니다. 남은 한계: get()은 여전히 블로킹, 가파른 학습 곡선, 행 단위 순차 실행이 아니라 디버깅이 어려움.
리액티브 (RxJava·Akka·Vert.x·Spring WebFlux) — 데이터 스트림·이벤트·논블로킹에 초점을 둔 패러다임으로 복잡한 비동기를 단순화합니다. 남은 한계: 발행자/구독자/배압 등 가파른 학습 곡선, 인지 부하, 디버깅 난해, 오버엔지니어링 유혹, 벤더 종속.
캐시 선호도와 거짓 공유
같은 코어에서 연이어 실행되는 태스크는 앞 태스크가 캐시에 올려 둔 데이터를 재사용해 메인 메모리 접근을 아낍니다(캐시 선호도). 반대로 서로 다른 스레드가 같은 캐시 라인의 서로 다른 변수를 수정하면 캐시 라인 무효화·동기화가 반복되어 성능이 떨어지는데, 이를 거짓 공유(false sharing)라 합니다. 이그제큐터가 태스크를 코어 간에 분산할 때 악화될 수 있습니다.
InterruptedException은 인터럽트 상태를 복구해야
catch (InterruptedException e)에서 그냥 삼키면 인터럽트 플래그가 지워져 호출 스택 상부로 신호가 전파되지 않습니다. Thread.currentThread().interrupt()로 플래그를 다시 세워야 isInterrupted() 등으로 상태를 정확히 감지할 수 있습니다. checked 예외를 RuntimeException으로 감싸 던지면 throws가 선언되지 않은 인터페이스 메서드 구현에서도 예외를 전달할 수 있습니다.
catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new RuntimeException(e);}
가상 스레드: 트레이드오프의 해소
프로젝트 룸(Project Loom)이 도입한 가상 스레드는 생성 비용이 거의 0인 경량 스레드라 수백만 개를 만들어도 자원이 고갈되지 않습니다.
핵심 메커니즘은 지능적인 블로킹 처리입니다. 가상 스레드는 sleep이나 네트워크 I/O 같은 블로킹 연산을 만나면 멈추고 제어권을 플랫폼 스레드 (platform thread)에 자동으로 반납합니다. 플랫폼 스레드는 그 사이 다른 가상 스레드를 실행하고, 블로킹이 끝나면 가상 스레드는 멈췄던 지점에서 재개됩니다. 플랫폼 스레드는 포크/조인 풀의 지원을 받으므로 그 장점도 그대로 누립니다.
결과적으로 익숙한 블로킹(명령형) 코드를 그대로 쓰면서 논블로킹의 자원 효율을 얻습니다. 새 학습 곡선이나 디버깅 난해함 없이 말입니다. 통합도 매끄러워서, 이미 이그제큐터를 쓰고 있다면 한 줄이면 됩니다.
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) { // 기존 submit / get 코드를 그대로 사용}
비교 / 트레이드오프
가상 스레드 이전의 모든 해법은 코드 단순성(명령형·블로킹 스타일)과 자원 효율(논블로킹) 사이에서 한쪽을 포기하게 만들었습니다. 진화의 본질은 이 두 축의 줄다리기입니다.
기법
코드 단순성
자원 효율
핵심 한계
직접 스레드 생성
높음(블로킹)
낮음
수명주기·자원 제어 없음 → OOM
이그제큐터
높음(블로킹)
중간
Future.get() 블로킹, 합성력 부족
포크/조인 풀
중간
높음
분할 후 정복 외엔 과함, 명령형
CompletableFuture
낮음
높음
학습 곡선·디버깅, get() 블로킹
리액티브
낮음
매우 높음
학습 곡선·디버깅·벤더 종속
가상 스레드
높음(블로킹)
높음
두 축을 동시에 잡은 첫 해법
효율을 높이려고 비동기로 갈수록 코드는 어려워졌고, 단순함을 지키려면 블로킹으로 스레드를 낭비했습니다. 가상 스레드는 “블로킹처럼 쓰되 실제로는 블로킹하지 않는다”로 이 딜레마를 끊어냅니다.
내 생각
이 장은 결국 “왜 가상 스레드인가”를 위한 빌드업입니다. 리액티브/CompletableFuture의 한계(특히 디버깅·학습 곡선)를 직접 겪어 본 입장에서, “블로킹 코드 그대로 + 논블로킹 효율”이라는 약속은 과장이 아니라 실무 도입 장벽 자체를 없애는 가치입니다.
WebFlux 도입은 기술 선택이 아니라 조직 결정입니다. 팀 전체의 리액티브 경험이 고르지 않으면 디버깅·유지보수 비용이 장점을 잡아먹습니다. 가상 스레드가 매력적인 이유는 기존 thread-per-request 코드를 거의 그대로 두고 확장성을 얻기 때문입니다.
단, 가상 스레드가 만능은 아닙니다. CPU 바운드 작업은 여전히 코어 수가 한계이고, synchronized 구간에서 캐리어 스레드가 고정(pinning)되는 함정도 있습니다. “블로킹이 비싸다”는 전제가 깨지는 곳에서는 이득이 적습니다.
관련 개념
Ch13 동시성 성능 기법 — JMM, CAS, AtomicInteger, ReentrantLock, 포크/조인 등 이 장이 언급한 동시성 도구들의 저수준 동작