자바 동시성 30년사: Thread에서 Virtual Thread까지, 그 긴 여정

자바를 배우기 시작하면 어느 순간 반드시 마주치는 단어가 있습니다. 스레드(Thread)죠. 자바 교재 어디를 펴도 스레드 챕터가 빠지지 않고, 면접에서도 단골로 나옵니다. 그런데 막상 "스레드가 뭔가요?"라고 물으면 명쾌하게 대답하기가 쉽지 않습니다. 더 어려운 점은, 자바의 스레드가 30년 동안 꾸준히 변해왔다는 사실입니다.
1996년 Java 1.0이 처음 세상에 나왔을 때부터, 자바는 스레드를 언어 차원에서 지원한 거의 최초의 주류 프로그래밍 언어였습니다. 그로부터 약 30년이 지난 2023년, Java 21은 "가상 스레드"라는 완전히 새로운 방식을 정식으로 도입했습니다. 이 글에서는 그 30년간의 여정을 따라가 봅니다. 어려운 전문 용어는 최대한 쉽게 풀어 설명하겠습니다. 자바를 막 시작한 분이라도 "아, 그래서 이런 게 나왔구나" 하고 고개를 끄덕일 수 있도록 말입니다.
다음 그림은 자바 동시성이 30년간 어떻게 진화해왔는지를 한눈에 보여줍니다.

1996년 Thread 클래스에서 시작해 2025년 Java 24의 Pinning 문제 해결까지, 각 단계는 이전 단계의 한계를 극복하기 위해 등장했습니다. 이제 이 여정을 하나씩 따라가 보겠습니다.
그래서 스레드가 뭔데?
먼저 스레드가 뭔지부터 짚고 넘어가겠습니다.
프로그램을 실행하면 컴퓨터는 그 프로그램의 코드를 한 줄 한 줄 읽어서 처리합니다. 이때 "코드를 읽어서 처리하는 흐름" 하나를 스레드라고 부릅니다. 식당의 요리사에 비유하면 이해가 쉬운데요. 요리사 한 명이 주문을 하나씩 처리하는 것, 그게 스레드 하나로 돌아가는 프로그램입니다.
그런데 주문이 100개가 밀려 있는데 요리사가 한 명이면 어떻게 될까요? 손님이 한참 기다려야 합니다. 요리사를 여러 명 고용하면 주문을 동시에 처리할 수 있습니다. 이것이 멀티스레딩의 핵심 아이디어입니다. 여러 개의 실행 흐름을 동시에 돌려서 일을 빨리 끝내자는 것입니다.
스레드 vs 프로세스
프로세스는 실행 중인 프로그램 자체를 말합니다. 식당 하나가 프로세스라면, 그 안에서 일하는 요리사 각각이 스레드입니다. 한 식당(프로세스) 안의 요리사들(스레드)은 주방(메모리)을 함께 쓸 수 있습니다. 서로 다른 식당(프로세스)의 요리사들은 주방을 공유하지 못합니다.
자바는 왜 처음부터 스레드를 품었나
1990년대 중반, 대부분의 프로그래밍 언어에서 동시성은 운영체제 수준의 어려운 작업이었습니다. C나 C++로 멀티스레딩을 구현하려면 운영체제마다 다른 API를 직접 호출해야 했고, 코드가 복잡하고 오류가 나기 쉬웠습니다.
제임스 고슬링과 자바 설계팀은 다른 선택을 했습니다. 스레드를 언어와 표준 라이브러리에 내장시킨 것입니다. Thread 클래스와 Runnable 인터페이스를 표준 API로 제공하고, synchronized 키워드를 문법 차원에서 지원했습니다. Sven Ruppert의 분석에 따르면, 자바는 주류 프로그래밍 언어 중 동시성을 언어 수준에서 직접 지원한 최초의 언어였습니다.
왜 그랬을까요? 자바가 태어난 배경을 보면 이해가 됩니다. 자바는 원래 가전제품 내장 소프트웨어용으로 설계되었고, 이후 웹 애플릿으로 방향을 틀었습니다. 두 경우 모두 동시에 여러 작업을 처리해야 하는 상황이 많았습니다. 네트워크에서 데이터를 받아오면서 화면을 업데이트하고, 사용자 입력도 받아야 했습니다. 단일 코어 CPU 시절에도 동시성이 필요했던 이유는, CPU가 네트워크 응답을 기다리는 동안 아무 일도 하지 않고 멍하니 있는 것은 낭비이기 때문입니다.
1996년의 컴퓨터는
Java 1.0이 나온 1996년, 대부분의 PC는 CPU 코어가 하나였습니다. 그런데도 스레드가 필요했습니다. 한 작업이 네트워크나 디스크 응답을 기다리는 동안 다른 작업을 처리하기 위해서입니다. 진정한 "동시 실행"이 아니라 "번갈아 실행"이지만, 사용자 입장에서는 프로그램이 멈추지 않으니 동시에 돌아가는 것처럼 느껴졌습니다.
Java 1.0, 스레드를 시작하다
Java 1.0에서 스레드를 만드는 방법은 직관적이었습니다. Thread 클래스를 상속하거나, Runnable 인터페이스를 구현하면 됩니다.
public class MyThread extends Thread { @Override public void run() { System.out.println("안녕하세요, 저는 새 스레드입니다!"); System.out.println("스레드 이름: " + getName()); } public static void main(String[] args) { MyThread thread = new MyThread(); thread.start(); // run()이 아니라 start()를 호출해야 새 스레드에서 실행됩니다 } }
public class MyTask implements Runnable { @Override public void run() { System.out.println("저도 새 스레드에서 실행 중입니다!"); } public static void main(String[] args) { Thread thread = new Thread(new MyTask()); thread.start(); } }
여기서 주의할 점이 하나 있습니다. run() 메서드를 직접 호출하면 새 스레드가 만들어지지 않습니다. 단순히 현재 스레드에서 메서드를 호출하는 것과 다를 바 없습니다. 반드시 start()를 호출해야 JVM이 운영체제에 새 스레드 생성을 요청하고, 그 새 스레드 위에서 run()이 실행됩니다.
Java 1.0은 synchronized 키워드와 wait()/notify() 메서드도 함께 제공했습니다. 여러 스레드가 같은 데이터를 동시에 건드리면 문제가 생길 수 있으니, 한 번에 하나의 스레드만 접근하도록 잠금을 거는 장치입니다. 식당 비유로 돌아가면, 냉장고 문을 동시에 두 요리사가 열면 부딪히니까, "냉장고 사용 중" 표시를 걸어두는 것과 비슷합니다.
public class Counter { private int count = 0; // synchronized를 붙이면 한 번에 하나의 스레드만 이 메서드를 실행할 수 있습니다 public synchronized void increment() { count++; } public synchronized int getCount() { return count; } public static void main(String[] args) throws InterruptedException { Counter counter = new Counter(); Thread t1 = new Thread(() -> { for (int i = 0; i < 1000; i++) counter.increment(); }); Thread t2 = new Thread(() -> { for (int i = 0; i < 1000; i++) counter.increment(); }); t1.start(); t2.start(); t1.join(); t2.join(); System.out.println("Count: " + counter.getCount()); // 2000 } }
이렇게 자바는 태어날 때부터 스레드를 품고 있었습니다. JVM의 가비지 컬렉터도 별도 스레드에서 돌아가고, GUI 프레임워크인 AWT/Swing도 이벤트 처리 전용 스레드를 사용합니다. 자바 플랫폼 자체가 스레드 위에 세워진 건물인 셈입니다.
스레드 하나에 1MB, 공짜 점심은 없다
Java 1.0의 스레드는 분명 편리했습니다. 하지만 인터넷이 빠르게 성장하면서 문제가 드러나기 시작했습니다. 웹 서버가 동시에 수천, 수만 명의 사용자 요청을 처리해야 하는 시대가 온 것입니다.
"요청 하나당 스레드 하나"는 가장 직관적인 설계입니다. 사용자 A의 요청은 스레드 A가, 사용자 B의 요청은 스레드 B가 처리하죠. 코드도 단순하고 이해하기 쉽습니다. 그런데 동시 접속자가 10,000명이면 스레드도 10,000개가 필요합니다. 여기서 문제가 생깁니다.
메모리 비용
자바의 스레드 하나는 기본적으로 1MB의 스택 메모리를 차지합니다(64비트 JVM 기준, Oracle 공식 문서). 스택이란, 스레드가 실행 중인 메서드 호출 정보를 쌓아두는 공간입니다. 스레드가 메서드 A를 호출하고, A 안에서 메서드 B를 호출하면, 이 호출 정보가 스택에 차곡차곡 쌓입니다.
1MB가 별것 아닌 것 같지만, 10,000개면 약 10GB입니다. Oracle Java Magazine의 측정에 따르면, 플랫폼 스레드 10,000개를 생성하는 데 약 14.3GB의 메모리가 필요하고요. 스택 외에 스레드 메타데이터 등이 추가로 붙기 때문입니다. 서버 메모리가 16GB라면 스레드만으로 거의 다 써버리는 셈입니다.
운영체제와의 관계
자바의 스레드(이를 "플랫폼 스레드"라고 부릅니다)는 운영체제의 커널 스레드와 1:1로 연결됩니다. 자바에서 new Thread()로 스레드를 하나 만들면, 운영체제도 커널 스레드를 하나 만들어야 합니다. 이 과정에는 운영체제의 자원이 소모됩니다.
Baeldung의 실험에 따르면, 운영체제가 생성할 수 있는 스레드 수에는 물리적 한계가 있습니다. 힙 크기, 스택 크기(-Xss JVM 옵션), 운영체제 설정(vm.max_map_count 등) 여러 요인에 의해 결정되는데, 일반적인 서버에서 수천에서 수만 개 사이가 현실적인 한계입니다.
컨텍스트 스위칭 비용
CPU 코어 수보다 스레드가 많으면, 운영체제는 스레드들을 빠르게 번갈아 실행합니다. 한 스레드의 실행 상태를 저장하고 다른 스레드의 상태를 복원하는 과정이 필요합니다. 이것이 컨텍스트 스위칭입니다. Eli Bendersky의 실측 결과, CPU 레지스터 값을 저장하고 복원하는 데 약 1.2~2.2마이크로초가 걸립니다. 1마이크로초는 100만분의 1초니까 아주 짧아 보이지만, 초당 수십만 번의 스위칭이 발생하면 무시할 수 없는 비용이 됩니다.
정리하면
자바 1.0 스타일로 "요청마다 새 스레드"를 만드는 방식은 동시 접속자가 수천 명을 넘어가면 메모리 부족, 운영체제 자원 한계, 컨텍스트 스위칭 누적이라는 세 가지 벽에 부딪힙니다. 뭔가 다른 방법이 필요했습니다.
스레드를 재활용하자: Executor Framework의 등장
2004년, Java 5가 출시되면서 동시성 프로그래밍에 큰 변화가 찾아왔습니다. java.util.concurrent 패키지, 통칭 "Executor Framework"의 도입입니다.
이 프레임워크의 설계를 주도한 사람은 Doug Lea 교수(뉴욕주립대 오스위고 캠퍼스)입니다. JSR-166이라는 이름으로 표준화 과정을 거쳤고, 그 결과물이 자바 5의 java.util.concurrent 패키지입니다.
핵심 아이디어: 스레드 풀
Executor Framework의 핵심은 "스레드 풀(Thread Pool)"이라는 개념입니다.
앞서 식당 비유에서, 주문이 올 때마다 요리사를 새로 고용하고 주문 처리가 끝나면 해고하는 방식이 Java 1.0의 스레드 생성/소멸이었습니다. 당연히 비효율적입니다. 대신, 요리사를 미리 10명 고용해놓고 주문이 들어오면 빈 요리사에게 할당하는 방식이 스레드 풀입니다.
import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; public class ThreadPoolExample { public static void main(String[] args) { // 요리사 10명을 미리 고용 (스레드 10개짜리 풀 생성) ExecutorService pool = Executors.newFixedThreadPool(10); // 주문 100개를 던집니다 for (int i = 0; i < 100; i++) { final int orderNumber = i; pool.submit(() -> { System.out.println("주문 #" + orderNumber + " 처리 중 - 스레드: " + Thread.currentThread().getName()); }); } pool.shutdown(); // 모든 주문 처리 후 퇴근 } }
이 코드에서 스레드는 10개만 만들어집니다. 100개의 작업이 10개의 스레드에 번갈아 할당되어 실행됩니다. 스레드를 새로 만들고 없애는 비용이 사라진 것입니다.
다양한 스레드 풀 전략
Executor Framework는 상황에 맞는 여러 종류의 스레드 풀을 제공합니다.
// 스레드 수를 정확히 지정합니다 ExecutorService fixed = Executors.newFixedThreadPool(10);
스레드 수가 고정되어 안정적입니다. 작업이 폭주해도 스레드가 늘어나지 않으므로 자원 사용을 예측할 수 있습니다. 서버 애플리케이션에서 가장 많이 사용하는 방식입니다.
// 필요할 때 스레드를 만들고, 60초간 안 쓰이면 제거합니다 ExecutorService cached = Executors.newCachedThreadPool();
필요에 따라 스레드를 늘리고 줄이는 탄력적인 방식입니다. 짧은 작업이 많을 때 유용하지만, 작업이 폭주하면 스레드가 무한히 생길 수 있어서 주의해야 합니다.
// 스레드 딱 1개. 작업을 순서대로 처리합니다 ExecutorService single = Executors.newSingleThreadExecutor();
스레드 1개가 작업을 순서대로 처리합니다. 실행 순서가 보장되어야 할 때 사용합니다.
Future: 결과를 나중에 받겠습니다
Executor Framework는 Future라는 개념도 함께 가져왔습니다. 스레드에게 작업을 맡겨놓고, 결과는 나중에 받는 방식이죠.
import java.util.concurrent.*; public class FutureExample { public static void main(String[] args) throws Exception { ExecutorService pool = Executors.newFixedThreadPool(2); // 시간이 오래 걸리는 작업을 스레드에게 맡깁니다 Future<String> future = pool.submit(() -> { Thread.sleep(2000); // 2초 걸리는 작업 return "작업 완료!"; }); System.out.println("다른 일을 하는 중..."); // 결과가 필요한 시점에 가져옵니다 (준비될 때까지 기다립니다) String result = future.get(); System.out.println(result); pool.shutdown(); } }
작업을 맡겨놓고 다른 일을 하다가, 결과가 필요한 시점에 .get()으로 가져오는 구조입니다.
Java 5 이전과 이후
Java 5 이전에는 스레드를 직접 만들고, 결과를 받으려면 공유 변수와 synchronized를 조합해서 직접 구현해야 했습니다. Executor Framework 덕분에 개발자는 "어떤 작업을 실행할지"에만 집중하고, "스레드를 어떻게 관리할지"는 프레임워크에 맡길 수 있게 되었습니다.
아직 남은 과제
Executor Framework는 스레드 관리 문제를 크게 개선했지만, 모든 것을 해결한 것은 아니었습니다.
Future.get()을 호출하면 결과가 준비될 때까지 현재 스레드가 멈춰서 기다립니다. 비동기로 작업을 시작해 놓고, 정작 결과를 가져오는 부분에서는 동기적으로 블로킹되는 셈이죠. 여러 비동기 작업을 연결해서 "A 끝나면 B 하고, B 끝나면 C 하고"와 같은 흐름을 만들기도 까다로웠습니다. 이 문제는 나중에 CompletableFuture가 해결하게 됩니다.
더 똑똑하게 일을 나누자: ForkJoinPool과 Work-Stealing
2011년, Java 7은 ForkJoinPool이라는 새로운 스레드 풀을 도입했습니다. 기존 스레드 풀과 무엇이 다를까요?
기존 스레드 풀의 한계
ThreadPoolExecutor(고정 크기 풀, 캐시 풀 등의 기반 클래스)는 하나의 작업 큐를 모든 스레드가 공유합니다. 모든 작업이 하나의 큐에 들어가고, 스레드들이 거기서 하나씩 가져가는 구조입니다.
이 방식의 문제는 작업 처리 시간이 불균일할 때 드러납니다. 어떤 스레드는 가벼운 작업을 빨리 끝내고 놀게 되고, 어떤 스레드는 무거운 작업에 오랫동안 묶여 있습니다. 놀고 있는 스레드가 바쁜 스레드의 작업을 대신 가져올 방법이 없습니다.
Work-Stealing: 남의 일을 훔쳐라
ForkJoinPool은 "Work-Stealing(작업 훔치기)" 알고리즘을 사용합니다. 이름이 재미있는데, 말 그대로 일이 없는 스레드가 바쁜 스레드의 작업 큐에서 작업을 "훔쳐오는" 방식입니다.
ForkJoinPool에서는 각 스레드가 자기만의 작업 큐(덱, deque)를 갖고 있습니다. Oracle 공식 API 문서에 따르면, 각 워커 스레드는 자신의 덱에서 LIFO(후입선출, 나중에 넣은 것을 먼저 꺼냄) 방식으로 작업을 꺼내 처리합니다. 그리고 자기 덱이 비면, 다른 스레드의 덱 반대쪽에서 FIFO(선입선출, 먼저 넣은 것을 먼저 꺼냄) 방식으로 작업을 가져옵니다. 이렇게 하면 놀고 있는 스레드가 없어집니다.
import java.util.concurrent.RecursiveTask; import java.util.concurrent.ForkJoinPool; // 1부터 n까지의 합을 구하는 작업 (큰 문제를 작은 문제로 쪼개기) public class SumTask extends RecursiveTask<Long> { private final long start; private final long end; private static final long THRESHOLD = 10_000; // 이보다 작으면 직접 계산 public SumTask(long start, long end) { this.start = start; this.end = end; } @Override protected Long compute() { // 작업이 충분히 작으면 직접 계산합니다 if (end - start <= THRESHOLD) { long sum = 0; for (long i = start; i <= end; i++) { sum += i; } return sum; } // 작업이 크면 반으로 쪼갭니다 (Fork) long mid = (start + end) / 2; SumTask leftTask = new SumTask(start, mid); SumTask rightTask = new SumTask(mid + 1, end); leftTask.fork(); // 왼쪽 절반을 다른 스레드에 맡깁니다 Long rightResult = rightTask.compute(); // 오른쪽 절반은 현재 스레드가 처리합니다 Long leftResult = leftTask.join(); // 왼쪽 결과를 기다립니다 return leftResult + rightResult; // 합칩니다 (Join) } public static void main(String[] args) { ForkJoinPool pool = new ForkJoinPool(); // CPU 코어 수만큼 스레드 생성 long result = pool.invoke(new SumTask(1, 100_000_000)); System.out.println("합계: " + result); } }
이 코드의 핵심은 "분할 정복"입니다. 1부터 1억까지 더하는 큰 작업을 잘게 쪼개서 여러 스레드에 나눠줍니다. 각 스레드는 자기 몫을 처리하고, 일이 일찍 끝나면 다른 스레드의 몫을 가져와서 처리합니다.
캐시 선호도
Work-Stealing이 왜 LIFO와 FIFO를 다르게 쓰는지 궁금할 수 있습니다. 이유는 "캐시 선호도(Cache Affinity)" 때문입니다.
CPU에는 캐시라는 작은 고속 메모리가 있습니다. 최근에 사용한 데이터를 캐시에 저장해두면 다음에 다시 쓸 때 빠르게 접근할 수 있습니다. 스레드가 자기 덱의 가장 최근 항목(LIFO)을 먼저 처리하면, 그 데이터가 아직 캐시에 남아 있을 가능성이 높습니다. 반면 다른 스레드의 오래된 항목(FIFO)을 훔칠 때는 어차피 캐시에 없을 가능성이 높으므로, 가장 오래전에 넣은 것을 가져가는 편이 충돌을 줄입니다.
Java 8의 Parallel Stream
Java 8에서 등장한 Parallel Stream도 내부적으로 ForkJoinPool을 사용합니다. list.parallelStream().map(...).collect(...)처럼 쓰면, 내부에서 Work-Stealing이 돌아가고 있는 것입니다. 개발자가 직접 ForkJoinPool을 다룰 일이 줄어든 셈입니다.
비동기 작업을 레고처럼 조립하다: CompletableFuture
2014년, Java 8은 CompletableFuture를 도입했습니다. 앞서 이야기한 Future의 한계, "결과를 기다리는 동안 멈춘다"는 문제를 해결한 것입니다.
Future의 한계
기존 Future로 여러 비동기 작업을 연결하는 상황을 생각해봅시다. "사용자 정보 조회 → 주문 내역 조회 → 추천 상품 생성"처럼 앞의 결과가 뒤의 입력이 되는 경우입니다.
// Future만으로는 이렇게 해야 합니다 Future<User> userFuture = pool.submit(() -> getUser(userId)); User user = userFuture.get(); // 여기서 멈춤! (블로킹) Future<List<Order>> ordersFuture = pool.submit(() -> getOrders(user)); List<Order> orders = ordersFuture.get(); // 또 멈춤! Future<List<Product>> recommendFuture = pool.submit(() -> recommend(orders)); List<Product> products = recommendFuture.get(); // 또 멈춤!
비동기를 쓰고 있지만 실질적으로는 순차 실행과 다를 바 없습니다. 각 단계에서 .get()으로 기다리고 있으니까요.
CompletableFuture의 해법
CompletableFuture는 작업들을 레고 블록처럼 연결할 수 있게 해줍니다. "A가 끝나면 자동으로 B를 실행하고, B가 끝나면 자동으로 C를 실행해라"를 선언적으로 표현할 수 있습니다.
import java.util.concurrent.CompletableFuture; public class CompletableFutureExample { public static void main(String[] args) { CompletableFuture<String> result = CompletableFuture .supplyAsync(() -> { // 1단계: 사용자 정보 조회 System.out.println("사용자 조회 중... [" + Thread.currentThread().getName() + "]"); return "홍길동"; }) .thenApply(user -> { // 2단계: 주문 내역 조회 (1단계 결과를 입력으로 받음) System.out.println(user + "의 주문 조회 중... [" + Thread.currentThread().getName() + "]"); return user + "의 주문 3건"; }) .thenApply(orders -> { // 3단계: 추천 상품 생성 (2단계 결과를 입력으로 받음) System.out.println("추천 생성 중... [" + Thread.currentThread().getName() + "]"); return orders + " 기반 추천 5개"; }); // 최종 결과가 필요한 시점에만 기다립니다 System.out.println("최종 결과: " + result.join()); } }
.get()으로 중간중간 멈추는 대신, .thenApply()로 다음 작업을 미리 등록해 두는 방식입니다. 각 단계가 끝나면 자동으로 다음 단계가 실행됩니다.
독립적인 작업을 동시에 실행하고 결과를 합치는 것도 간결하게 표현할 수 있습니다.
// 두 개의 독립적인 작업을 동시에 실행 CompletableFuture<String> userFuture = CompletableFuture.supplyAsync(() -> getUser(userId)); CompletableFuture<String> weatherFuture = CompletableFuture.supplyAsync(() -> getWeather(location)); // 둘 다 끝나면 결과를 합칩니다 CompletableFuture<String> combined = userFuture.thenCombine( weatherFuture, (user, weather) -> user + "님, 오늘 날씨는 " + weather + "입니다" ); System.out.println(combined.join());
Baeldung의 CompletableFuture 가이드에서 설명하듯, thenCompose()는 의존적인 작업 체이닝에, thenCombine()은 독립적인 작업 합치기에, allOf()는 여러 작업이 모두 끝날 때까지 기다리기에 사용합니다. 비동기 작업의 조합이 훨씬 유연해진 것입니다.
내부적으로 ForkJoinPool 사용
CompletableFuture의 supplyAsync() 등 비동기 메서드는 기본적으로 ForkJoinPool.commonPool()을 사용합니다. 별도의 Executor를 지정하지 않으면 Java 7에서 도입된 ForkJoinPool 위에서 돌아가는 것입니다. Java의 동시성 기능들이 서로 위에 쌓여가는 모습을 볼 수 있습니다.
리액티브: 좋은 아이디어, 험난한 현실
CompletableFuture가 비동기 프로그래밍을 한층 편리하게 만들었지만, 새로운 과제가 나타났습니다. 데이터가 끊임없이 흘러들어오는 상황, 예를 들어 실시간 주식 시세, 채팅 메시지, IoT 센서 데이터 같은 "스트림" 처리입니다.
CompletableFuture는 "하나의 결과"를 다루는 데 적합합니다. 하지만 "끝없이 흘러오는 데이터"를 다루려면 다른 패러다임이 필요했습니다. 이 배경에서 등장한 것이 리액티브 프로그래밍입니다.
리액티브 프로그래밍이란
리액티브 프로그래밍은 "데이터가 흘러오면 반응한다"는 개념입니다. 기존 방식이 "내가 데이터를 가져온다(pull)"였다면, 리액티브는 "데이터가 오면 처리한다(push)"입니다.
Java 생태계에서는 2013년경 RxJava가 먼저 등장했고, 이후 Spring 진영의 Project Reactor가 나왔습니다. Java 9(2017년)에서는 Flow API로 Reactive Streams 명세를 표준 라이브러리에 포함시켰습니다.
// Project Reactor 스타일 (참고용) Flux.fromIterable(userIds) .flatMap(id -> userService.findById(id)) // 비동기로 사용자 조회 .filter(user -> user.isActive()) // 활성 사용자만 필터 .flatMap(user -> orderService.findByUser(user)) // 주문 조회 .subscribe(order -> System.out.println(order)); // 결과 처리
코드를 보면 마치 Java Stream API처럼 깔끔해 보입니다. 내부적으로 스레드를 효율적으로 쓰기 때문에 처리량도 높은 편이고요.
그런데 왜 어렵다고 하는 걸까
위의 코드는 정상 동작만 보여줍니다. 실제 서비스에서는 에러 처리가 들어가야 하는데, 이때부터 코드가 급격히 복잡해집니다.
// 에러 처리가 포함된 리액티브 코드 (Project Reactor 스타일, 참고용) Flux.fromIterable(userIds) .flatMap(id -> userService.findById(id) .onErrorResume(e -> { log.warn("사용자 {} 조회 실패: {}", id, e.getMessage()); return Mono.empty(); // 실패한 항목은 건너뜀 }) ) .filter(user -> user.isActive()) .flatMap(user -> orderService.findByUser(user) .timeout(Duration.ofSeconds(3)) .onErrorResume(TimeoutException.class, e -> { log.error("주문 조회 타임아웃: {}", user.getId()); return Flux.empty(); }) .retryWhen(Retry.backoff(3, Duration.ofMillis(100))) ) .subscribe( order -> process(order), error -> log.error("스트림 전체 실패", error), () -> log.info("처리 완료") );
flatMap 안에 onErrorResume, timeout, retryWhen이 중첩되면서 코드의 가독성이 급격히 떨어집니다. 같은 로직을 가상 스레드 기반의 동기 코드로 작성하면 이렇게 됩니다.
// 같은 로직을 가상 스레드 + 동기 코드로 작성한 경우 try (var executor = Executors.newVirtualThreadPerTaskExecutor()) { for (Long id : userIds) { executor.submit(() -> { try { User user = userService.findById(id); if (user.isActive()) { List<Order> orders = orderService.findByUser(user); orders.forEach(order -> process(order)); } } catch (TimeoutException e) { log.error("주문 조회 타임아웃: {}", id); } catch (Exception e) { log.warn("사용자 {} 처리 실패: {}", id, e.getMessage()); } }); } }
동기 코드의 try-catch와 if문으로 동일한 로직이 훨씬 읽기 쉽게 표현됩니다. 디버깅할 때도 스택 트레이스가 일반적인 메서드 호출 순서로 나오므로 문제 지점을 바로 파악할 수 있습니다.
리액티브의 현실적 문제
리액티브 프레임워크는 성능 면에서는 효과적이었지만, 현업 개발자들 사이에서 불만의 목소리가 꾸준히 나왔습니다.
첫 번째 문제는 학습 곡선입니다. RxJava의 API는 수백 개의 연산자를 제공합니다. flatMap과 concatMap과 switchMap의 차이를 정확히 이해하는 데만 상당한 시간이 걸리죠. Project Reactor 공식 문서에서도 Callback 기반 코드가 "Callback 안의 Callback 안의 Callback"으로 중첩되는 "Callback Hell" 문제를 인정하고 있습니다.
두 번째 문제는 디버깅의 어려움입니다. 리액티브 코드에서 에러가 발생하면 스택 트레이스가 매우 길고 읽기 어렵습니다. 데이터가 여러 연산자를 거치면서 어디서 문제가 생겼는지 추적하기가 까다롭고요.
세 번째 문제는 기존 코드와의 호환성입니다. 자바 생태계의 수많은 라이브러리와 프레임워크는 동기식, 블로킹 방식으로 작성되어 있습니다. JDBC(데이터베이스 접속), 파일 I/O, 대부분의 서드파티 라이브러리가 그렇습니다. 리액티브 코드 안에서 이런 블로킹 라이브러리를 호출하면 성능 이점이 사라집니다. 결국 라이브러리부터 리액티브 전용으로 바꿔야 하는데, 이것은 엄청난 작업량입니다.
리액티브의 딜레마
리액티브 프로그래밍은 "적은 스레드로 높은 처리량을 달성한다"는 목표를 이루었습니다. 하지만 그 대가로 코드 복잡성이 크게 증가했습니다. 현업 개발자에게 들어보면, "리액티브로 전환했더니 성능은 좋아졌는데 코드를 이해하는 사람이 줄었다"는 이야기가 적지 않습니다. 성능과 코드 가독성 중 하나를 포기해야만 하는 상황이었습니다.
한편, Java 10에서 18까지의 기간 동안 눈에 띄는 동시성 API 변화는 없었지만, JVM 내부에서는 스레드 처리 성능이 꾸준히 개선되었습니다. Java 10의 Thread-Local Handshakes는 GC 성능을 개선하기 위해 모든 스레드를 일시 정지시키는 대신 개별 스레드에 신호를 보내는 방식을 도입했고, ForkJoinPool의 스케줄링 알고리즘도 버전을 거듭하면서 세밀하게 튜닝되었습니다. 화려한 새 API는 아니었지만, 자바의 동시성 기반을 다지는 조용한 작업이 이어진 시기였습니다.
이 딜레마를 해결할 수 있는 방법은 없을까요? "적은 자원으로 높은 처리량"이라는 리액티브의 장점을 가져가면서, "쉽고 읽기 좋은 코드"를 유지할 수는 없을까요?
가상 스레드: 30년 만에 찾은 답
2023년 9월, Java 21이 출시되면서 "가상 스레드(Virtual Threads)"가 정식 기능으로 포함되었습니다. JEP 444로 확정된 이 기능은 자바 동시성 역사에서 가장 근본적인 변화입니다.
Project Loom: 시작은 2017년
가상 스레드는 하루아침에 나온 것이 아닙니다. 2017년 시작된 OpenJDK의 "Project Loom" 프로젝트가 그 출발점입니다. JDK 19(2022년)에서 프리뷰로 처음 공개되고, JDK 20에서 2차 프리뷰를 거친 뒤, JDK 21에서 정식 확정되었습니다. 약 6년간의 설계와 검증을 거친 셈입니다.
가상 스레드란 무엇인가
지금까지의 자바 스레드(이제 "플랫폼 스레드"라고 부릅니다)는 운영체제의 커널 스레드와 1:1로 연결되어 있었습니다. 가상 스레드는 이 연결을 끊었습니다.
가상 스레드는 JVM이 직접 관리하는 가벼운 스레드입니다. 운영체제의 커널 스레드 하나(이를 "캐리어 스레드"라고 부릅니다) 위에 여러 개의 가상 스레드가 번갈아 올라탈 수 있는 구조이고요.
비유하자면, 플랫폼 스레드는 자기 전용 차를 타고 다니는 사람입니다. 차 한 대에 사람 하나. 10,000명이 이동하려면 차 10,000대가 필요하죠. 가상 스레드는 버스나 택시를 합승하는 사람입니다. 차(캐리어 스레드)는 소수지만, 사람(가상 스레드)은 수만, 수십만 명까지 처리할 수 있습니다.
만들어보기: 생각보다 간단합니다
// 플랫폼 스레드 (기존 방식) Thread platformThread = Thread.ofPlatform() .name("platform-thread") .start(() -> System.out.println("플랫폼 스레드에서 실행")); // 가상 스레드 (새 방식) Thread virtualThread = Thread.ofVirtual() .name("virtual-thread") .start(() -> System.out.println("가상 스레드에서 실행"));
Thread.ofPlatform() 대신 Thread.ofVirtual()을 쓰는 것이 전부입니다. 더 간단한 방법도 있습니다.
// 가상 스레드를 사용하는 ExecutorService try (var executor = Executors.newVirtualThreadPerTaskExecutor()) { for (int i = 0; i < 100_000; i++) { final int taskId = i; executor.submit(() -> { // 10만 개의 작업을 각각 가상 스레드에서 실행합니다 Thread.sleep(1000); // 1초 대기 (네트워크 호출 시뮬레이션) System.out.println("작업 #" + taskId + " 완료"); return null; }); } }
이 코드는 가상 스레드 10만 개를 생성합니다. 플랫폼 스레드로 이것을 시도하면 메모리 부족으로 프로그램이 죽겠지만, 가상 스레드는 거뜬히 처리합니다.
14.3GB vs 4.4MB: 수치가 그 차이를 보여줍니다
가상 스레드가 왜 가벼운지, 수치로 비교해보겠습니다.
Oracle Java Magazine의 측정 결과에 따르면, 10,000개의 스레드를 생성할 때의 메모리 사용량은 다음과 같습니다.
| 구분 | 플랫폼 스레드 | 가상 스레드 |
|---|---|---|
| 10,000개 생성 시 메모리 | 약 14.3GB | 약 4.4MB |
| 스레드당 메모리 | 약 1.4MB | 약 0.44KB |
| 비율 | 1 | 약 1/3,000 |
약 3,000배의 차이입니다. 플랫폼 스레드가 기본 1MB의 고정 스택을 미리 할당받는 반면, 가상 스레드는 필요한 만큼만 메모리를 사용합니다. 가상 스레드의 스택은 JVM 힙 메모리에 "스택 청크(stack chunk)" 객체로 저장되며, 필요에 따라 늘어나고 줄어듭니다. 가비지 컬렉터가 관리할 수 있는 일반 객체처럼 취급되는 것입니다.
블로킹해도 괜찮습니다: Continuation의 비밀
가상 스레드의 가장 중요한 특성은 블로킹 처리 방식입니다.
플랫폼 스레드가 네트워크 응답을 기다리면서 블로킹되면, 그 스레드에 연결된 운영체제 커널 스레드도 함께 멈춥니다. 비싼 자원이 아무 일도 안 하면서 묶여 있는 것입니다. 이것이 "스레드를 아껴야 한다"는 말의 근본 원인이었습니다.
가상 스레드는 다르게 동작합니다. 가상 스레드가 블로킹 작업(네트워크 I/O, 파일 읽기, Thread.sleep() 등)을 만나면, JVM은 그 가상 스레드를 캐리어 스레드에서 "언마운트(unmount)"합니다. 쉽게 말해, "너는 잠깐 내려. 다른 사람 태울게"라고 하는 것입니다. 캐리어 스레드는 즉시 다른 가상 스레드를 "마운트(mount)"하여 실행합니다. 블로킹 작업이 완료되면, 원래 가상 스레드는 다시 빈 캐리어 스레드에 올라타서 이어서 실행합니다.
Foojay.io의 기술 분석에 따르면, 이 동작의 핵심에는 "Continuation(컨티뉴에이션)"이라는 내부 메커니즘이 있습니다. Continuation은 실행 흐름의 "북마크"입니다. 책을 읽다가 북마크를 꽂아두면 나중에 그 페이지부터 다시 읽을 수 있듯이, 가상 스레드의 실행 상태를 힙 메모리에 저장해두었다가 나중에 정확히 그 지점부터 다시 실행하는 것입니다.
가상 스레드 A가 네트워크 요청을 보내고 응답을 기다림 → JVM: A의 실행 상태를 힙에 저장 (언마운트) → JVM: 캐리어 스레드에 가상 스레드 B를 올림 (마운트) → B가 자기 작업을 처리 → A의 네트워크 응답이 도착 → JVM: 빈 캐리어 스레드에 A를 다시 올림 (마운트) → A가 중단된 지점부터 이어서 실행
다음 그림은 가상 스레드의 마운트/언마운트 과정을 시각적으로 보여줍니다.

가상 스레드 A가 블로킹되면 캐리어 스레드에서 내려오고, 대기 중이던 가상 스레드 C가 올라타서 실행됩니다. A의 I/O가 완료되면 빈 캐리어 스레드에 다시 올라타 이어서 실행합니다.
핵심은 이 과정이 개발자가 전혀 신경 쓸 필요 없이 자동으로 처리된다는 점입니다. 개발자는 그냥 socket.read()같은 블로킹 코드를 작성하면 됩니다. JVM이 알아서 가상 스레드를 내렸다 올렸다 하거든요. 리액티브 프로그래밍처럼 코드 스타일을 바꿀 필요가 없습니다.
기존 코드와 바로 쓸 수 있다
가상 스레드의 또 다른 큰 장점은 기존 코드베이스와의 호환성입니다.
Oracle 공식 문서에서도 "기존 자바 라이브러리는 별도의 수정 없이도 가상 스레드 환경에서 제 성능을 발휘한다"고 명시하고 있습니다. 가상 스레드는 java.lang.Thread의 인스턴스이므로, Thread를 사용하는 기존 코드가 대부분 그대로 동작합니다. Spring Boot 3.2에서는 설정 한 줄로 가상 스레드를 활성화할 수 있습니다.
# application.yml spring: threads: virtual: enabled: true
이 한 줄이면, 톰캣이 요청을 처리할 때 플랫폼 스레드 대신 가상 스레드를 사용하게 됩니다. 리액티브로 전환하려면 코드 전체를 바꿔야 했던 것과 비교하면 전환 비용이 매우 낮습니다.
실무 적용 사례도 나오고 있습니다. Spring Boot에서 가상 스레드를 도입한 후 I/O 바운드 워크로드에서 처리량이 약 22% 향상되었다는 벤치마크가 있고(93 req/sec → 113 req/sec), JEP 444의 공식 벤치마크에서는 HTTP 서버 처리량이 2,300 req/sec에서 14,500 req/sec로 약 6배 뛰었습니다. p99 레이턴시(상위 1%의 가장 느린 응답 시간)는 78% 줄어들었고요.
가상 스레드 vs 플랫폼 스레드: 정리
두 스레드의 차이를 표로 정리하겠습니다.
| 특성 | 플랫폼 스레드 | 가상 스레드 |
|---|---|---|
| OS 커널 스레드와의 관계 | 1:1 매핑 | M:N 매핑 (다수 가상 : 소수 캐리어) |
| 스택 메모리 | 기본 1MB 고정 | 필요한 만큼 동적 할당 |
| 최대 생성 수 | 수천~수만 개 | 수백만 개 가능 |
| 블로킹 시 동작 | OS 스레드도 함께 블로킹 | 캐리어 스레드에서 언마운트 |
| 컨텍스트 스위칭 | OS 수준 (느림) | JVM 수준 (빠름) |
| 적합한 작업 | CPU 집약적 작업 | I/O 집약적 작업 |
| 생성 비용 | 높음 (OS 자원 할당) | 낮음 (JVM 객체 생성) |
가상 스레드가 만능은 아닙니다
가상 스레드는 I/O 바운드 작업(네트워크, 데이터베이스, 파일 등 대기 시간이 긴 작업)에 효과적입니다. 반면 CPU 바운드 작업(복잡한 계산, 이미지 처리 등 CPU를 계속 쓰는 작업)에서는 별다른 이점이 없습니다. CPU를 많이 쓰는 작업은 가상 스레드든 플랫폼 스레드든 결국 CPU 코어 수만큼만 동시에 실행할 수 있기 때문입니다.
Pinning 문제와 Java 24의 해결
가상 스레드에도 초기 한계가 있었습니다. synchronized 블록 안에서 블로킹이 발생하면, 가상 스레드가 캐리어 스레드에서 언마운트되지 못하고 "고정(pinning)"되는 현상입니다. 이렇게 되면 플랫폼 스레드와 마찬가지로 캐리어 스레드가 묶여버립니다.
JEP 491을 통해 Java 24(2025년)에서 이 문제가 해결되었습니다. synchronized 블록에서도 가상 스레드가 정상적으로 언마운트될 수 있게 된 것입니다. 자바 팀이 가상 스레드를 계속 다듬고 있는 셈입니다.
가상 스레드 도입 시 주의할 점
Pinning 외에도 가상 스레드를 실무에 도입할 때 반드시 알아야 할 주의사항이 있습니다.
첫째, ThreadLocal 메모리 문제입니다. 플랫폼 스레드 환경에서는 스레드 풀 크기가 수십~수백 개로 제한되므로 ThreadLocal의 메모리 사용량도 제한적이었습니다. 그런데 가상 스레드는 수십만 개까지 생성될 수 있고, 각 가상 스레드가 ThreadLocal 복사본을 개별적으로 보유합니다. 가상 스레드 100만 개가 각각 1KB짜리 ThreadLocal 데이터를 갖고 있다면, 그것만으로 약 1GB의 메모리를 차지하게 됩니다. JEP 481의 Scoped Values는 이 문제의 대안으로 설계되었습니다.
둘째, 가상 스레드를 풀링하면 안 됩니다. 플랫폼 스레드는 생성 비용이 비싸서 풀링이 필수였지만, 가상 스레드는 생성 비용이 매우 낮습니다. Executors.newFixedThreadPool()에 가상 스레드를 넣어서 재사용하는 것은 가상 스레드의 취지와 맞지 않습니다. 가상 스레드는 작업마다 새로 생성하고, 작업이 끝나면 버리는 것이 올바른 사용법입니다. Executors.newVirtualThreadPerTaskExecutor()가 이 원칙을 따르고 있습니다.
셋째, DB 커넥션 풀이 병목이 될 수 있습니다. 가상 스레드 덕분에 동시에 수만 개의 요청을 처리할 수 있게 되었지만, DB 커넥션 풀(예를 들어 HikariCP)의 최대 커넥션 수는 변하지 않습니다. 가상 스레드 10만 개가 동시에 DB 쿼리를 요청하면, 대부분은 커넥션을 얻기 위해 대기하게 됩니다. 가상 스레드를 도입할 때는 반드시 DB 커넥션 풀 크기, 외부 API 호출 제한 등 하위 자원의 병목 지점을 함께 점검해야 합니다.
30년 여정의 타임라인
자바 동시성의 30년을 한눈에 정리하겠습니다.
| 시기 | Java 버전 | 무엇이 바뀌었나 | 해결한 문제 |
|---|---|---|---|
| 1996 | Java 1.0 | Thread, Runnable, synchronized | 언어 수준 동시성 지원 |
| 2004 | Java 5 | Executor Framework, Future | 스레드 풀로 자원 관리 |
| 2011 | Java 7 | ForkJoinPool, Work-Stealing | CPU 활용률 극대화 |
| 2014 | Java 8 | CompletableFuture, Parallel Stream | 비동기 작업 조합 |
| 2017 | Java 9 | Flow API (Reactive Streams 표준) | 스트림 처리 표준화 |
| 2018~2022 | Java 11~18 | Thread-Local Handshakes, ForkJoinPool 스케줄링 개선 등 | JVM 내부 스레드 처리 성능 최적화 |
| 2022 | Java 19 | 가상 스레드 프리뷰 | 경량 스레드 실험 |
| 2023 | Java 21 | 가상 스레드 정식 (JEP 444) | I/O 바운드 확장성 해결 |
| 2025 | Java 24 | Pinning 문제 해결 (JEP 491) | synchronized 호환성 완성 |
매번 같은 패턴이 반복되었습니다. 기존 방식의 한계가 드러나고, 그 한계를 극복하기 위한 새로운 도구가 등장하고, 새 도구가 또 다른 한계를 보이고, 또 새로운 도구가 나오는 순환이죠. 자바가 "하위 호환성"을 중시하면서도 꾸준히 발전해온 과정이기도 합니다. Java 1.0의 Thread 코드가 Java 21에서도 동작합니다. 기존 것을 버리지 않으면서 더 나은 선택지를 추가해 온 것입니다.
마무리: 적재적소에 맞는 도구를 선택하는 법
현업 개발자들과 이야기해보면, 동시성은 자바에서 가장 어려운 주제 중 하나로 꼽힙니다. Thread를 직접 만들어 쓰던 시절부터 가상 스레드까지, 자바는 "동시에 여러 일을 처리한다"는 같은 문제를 계속 더 나은 방식으로 풀어왔습니다.
중요한 것은, 가상 스레드가 나왔다고 해서 이전 기술들이 쓸모없어진 것은 아니라는 점입니다. 상황에 따라 맞는 도구가 다르거든요.
언제 무엇을 쓸 것인가
| 상황 | 추천 도구 | 이유 |
|---|---|---|
| REST API 서버, 다수의 HTTP 요청 처리 | 가상 스레드 | I/O 대기가 대부분이므로 가상 스레드의 경량성이 효과적입니다 |
| 이미지 처리, 대규모 데이터 연산 | ForkJoinPool / Parallel Stream | CPU를 집중 사용하므로 가상 스레드의 이점이 없습니다 |
| 여러 API를 동시 호출 후 결과 합치기 | CompletableFuture | 비동기 작업 흐름을 세밀하게 제어할 수 있습니다 |
| Spring Boot 서비스 신규 구축 (Java 21+) | 가상 스레드 | 설정 한 줄로 도입 가능하고, 기존 코드 스타일을 유지할 수 있습니다 |
| ThreadLocal을 많이 쓰는 기존 서비스 마이그레이션 | 신중한 검토 필요 | ThreadLocal로 인한 메모리 과다 점유 가능성을 먼저 점검해야 합니다 |
"내 Spring Boot 서비스에서 가상 스레드를 지금 도입해도 되는가?"라는 질문을 자주 받습니다. Java 21 이상이고, DB 커넥션 풀 크기를 점검했고, ThreadLocal 의존성을 파악했다면 도입을 권합니다. 특히 I/O 대기 시간이 긴 서비스일수록 효과가 큽니다.
자바 초보자라면 Thread의 기본 개념부터 차근차근 익히되, 새로운 프로젝트를 시작한다면 Java 21 이상의 가상 스레드를 먼저 검토해 보길 권합니다. Executors.newVirtualThreadPerTaskExecutor() 한 줄이면 시작할 수 있습니다.
자바의 동시성 이야기는 아직 끝나지 않았습니다. Structured Concurrency(JEP 480), Scoped Values(JEP 481) 같은 새로운 기능들이 프리뷰 단계에 있습니다. 다음 30년은 어떤 모습일지, 지켜볼 가치가 있습니다.
참고 자료
- JEP 444: Virtual Threads - OpenJDK
- JEP 425: Virtual Threads (Preview) - OpenJDK
- JEP 491: Synchronize Virtual Threads without Pinning - OpenJDK
- Virtual Threads - Oracle Java SE 21 Core Libraries Guide
- ForkJoinPool API - Java SE 8
- Flow API - Java SE 9
- The History of Parallel Processing in Java - Sven Ruppert
- How Many Threads Can a Java VM Support? - Baeldung
- Measuring Context Switching and Memory Overheads - Eli Bendersky
- Are threads consuming too much Java memory? - Oracle Java Magazine
- The Basis of Virtual Threads: Continuations - Foojay.io
- Guide to Work Stealing in Java - Baeldung
- Guide To CompletableFuture - Baeldung
- Java Virtual Threads vs. Reactive Programming - Medium
- Introduction to Reactive Programming - Project Reactor
- Java Virtual Threads In Production: Scaling Spring Boot Without WebFlux






댓글
댓글을 작성하려면 이 필요합니다.