1. 클래식 스레드: 어떻게 동작하고 무엇이 문제인가
먼저 Java의 일반적인 스레드(플랫폼 스레드 또는 native 스레드라고도 부름)가 어떻게 동작하는지부터 떠올려 봅시다. 바로 new Thread(...)로 만드는 그 스레드입니다.
여러분이 new Thread(() -> { ... }).start();를 호출하면, JVM은 단순히 코드 조각을 실행하는 것이 아닙니다. 운영 체제에 진짜 실행 스레드를 만들어 달라고 요청하죠. OS는 그 스레드에 대해 별도의 스택(보통 몇 메가바이트)을 할당하고 기타 내부 자원을 예약합니다.
이런 스레드는 자신의 작업이 수행되는 동안 살아 있고, 그 시간 내내 운영 체제의 스레드 테이블에 자리를 차지합니다. 스레드가 많아질수록 스택에 쓰이는 메모리도 커지고 OS 부하도 커집니다. 그래서 동시에 동작하는 스레드가 아주 많아지면 애플리케이션이 “숨 가빠” 하기 시작하죠 — 시스템이 스레드 전환에 지나치게 많은 시간을 쓰게 됩니다.
예시: 클래식 스레드
Thread thread = new Thread(() -> {
System.out.println("스레드에서 인사합니다!");
});
thread.start();
간단해 보이죠? 하지만 한두 개가 아니라, 예를 들어 만 개의 스레드를 만들어 보세요 — 프로그램이 금세 숨이 차기 시작할 겁니다. 메모리가 바닥나거나, 시스템이 스레드 한도에 도달했다고 말할 수도 있죠. 이는 Java의 오류가 아니라 아키텍처의 자연스러운 결과입니다. 스레드는 무겁고 비용이 비싼 리소스입니다.
왜 그럴까요? 각 스레드는 자체 스택(보통 1–2MB)을 받고, 운영 체제가 관리하는 여러 내부 구조체도 따라옵니다. 게다가 운영 체제는 수만 개의 스레드가 넘어오면 좋아하지 않습니다 — 한계가 있고, 그 한계는 꽤 엄격할 수 있습니다.
설령 메모리가 충분하더라도 또 다른 문제가 생깁니다 — 컨텍스트 스위칭입니다. 스레드가 너무 많으면, 시스템은 그들 사이를 오가며 상태를 저장/복원하느라 바쁩니다. 이는 시간을 잡아먹고 성능을 갉아먹기에, “대량 병렬화”의 이점을 내기 어렵습니다.
문제: “요청당 스레드(thread‑per‑request)”
예전 서버 애플리케이션(예: Tomcat, Jetty)에서는 “thread‑per‑request” 모델을 자주 썼습니다. 들어오는 사용자 요청마다 별도 스레드를 하나씩 할당하는 방식이죠. 편리하긴 하지만 사용자가 10_000명이라면 10_000개의 스레드가 필요합니다! 서버는 버거워지고, 속도보다 메모리와의 싸움이 됩니다.
결론:
클래식 스레드는 적은 수의 병렬 작업에는 좋지만, 수만~수십만 단위로는 확장되지 않습니다.
2. 가상 스레드(Virtual Threads)란?
여기서 오늘의 주인공 — 가상 스레드가 등장합니다. 이것은 단순한 “또 하나의 스레드”가 아니라 전혀 다른 아키텍처적 아이디어입니다.
가상 스레드는 운영 체제가 아니라 JVM이 관리하는 스레드입니다. 전적으로 Java 내부에 구현되어, 메모리 부풀림이나 성능 저하 없이 엄청난 수(수만, 수십만)로 만들어질 수 있습니다.
요약:
- Platform Thread (플랫폼 스레드): 운영 체제의 스레드와 1:1로 대응되는 일반 스레드.
- Virtual Thread (가상 스레드): OS가 아니라 JVM이 관리하는 경량 스레드.
동작 원리
가상 스레드는 운영 체제가 아니라 JVM 내부에 존재하는 “가벼운” 스레드입니다. 이들은 platform threads라고 불리는 소수의 실제 스레드 위에서 동작합니다. 마치 JVM이 지휘자처럼 제한된 수의 연주자(실제 스레드)에게 다양한 파트(가상 스레드)를 배분해 연주를 이끄는 모습이라 상상할 수 있습니다.
아키텍처 개략:
+-------------------+ +-------------------+
| Virtual Thread 1 |---\ | Platform Thread |
| Virtual Thread 2 |---->====> | (Carrier Thread) |
| Virtual Thread 3 |---/ +-------------------+
... (운영 체제)
Carrier Thread는 JVM이 다수의 가상 스레드를 실행하는 일반 OS 스레드입니다. 어떤 가상 스레드가 갑자기 블로킹된다면 — 예를 들어 디스크나 네트워크 데이터를 기다린다면 — JVM은 그 가상 스레드를 “동결”해 carrier thread를 다른 작업에 쓰도록 해줍니다.
왜 혁신적인가?
이제 익숙한 순차 코드 — 끝없는 콜백, CompletableFuture, 지옥의 thenApply 체인 없이 — 를 그대로 쓰면서도 애플리케이션을 수천 개의 동시 작업으로 확장할 수 있기 때문입니다.
가상 스레드는 메모리를 수십 KB만 차지하고(일반 스레드는 수 MB), 생성도 거의 즉시 이뤄집니다. 그래서 수천 개를 마음껏 만들고 없애도 운영 체제가 그 무게로 주저앉지 않습니다. 덕분에 Java에서의 병렬 프로그래밍이 마침내 가볍고 자연스러워집니다.
3. 가상 스레드의 장점
확장성
가상 스레드를 사용하면 수만, 수십만 개의 병렬 작업을 사치스럽게도 마음껏 실행할 수 있습니다. 예컨대 각 네트워크 요청을 별도의 스레드로 처리하면서도 “서버가 터질까” 걱정하지 않아도 됩니다.
데모: 100,000개의 가상 스레드
for (int i = 0; i < 100_000; i++) {
Thread.ofVirtual().start(() -> {
// 여기에는 어떤 로직이든 들어갈 수 있습니다
try {
Thread.sleep(1000); // 작업 시뮬레이션
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
}
System.out.println("모든 스레드가 시작되었습니다!");
이 코드는 보통 노트북에서도 무리 없이 실행됩니다!
같은 일을 일반 스레드로 해보면 OutOfMemoryError를 보거나, 컴퓨터가 “먹통”이 되는 경험을 하게 될 겁니다.
프로그래밍의 단순성
가상 스레드는 익숙한 “블로킹” 코드를 그대로 쓸 수 있게 해 줍니다. 콜백과 비동기 호출로 “스파게티”를 만들 필요가 없습니다. 예를 들어 Thread.sleep, InputStream.read, Socket.accept를 마음 편히 사용해도 JVM이 carrier thread 전체를 막지 않도록 알아서 처리합니다.
가독성과 유지보수성 향상
복잡한 콜백 구조나 CompletableFuture 대신 직관적인 순차 코드를 작성합니다. 이는 버그를 줄이고 유지보수를 쉽게 만듭니다.
바퀴를 다시 발명할 필요 없음
과거에는 수천 개의 요청을 병렬로 처리하려면 비동기 프레임워크, 리액티브 라이브러리(Netty, Vert.x, Project Reactor)처럼 특별한 프로그래밍 스타일이 필요했습니다. 이제는 굳이 그럴 필요 없이도 확장성을 얻을 수 있습니다.
4. 아키텍처: 가상 스레드는 내부에서 어떻게 동작하는가
carrier thread로의 매핑
JVM은 소수의 실제 스레드(carrier threads) 풀을 만듭니다 — 보통 CPU 코어 수와 같습니다. 모든 가상 스레드는 이 carrier threads 위에서 “버스를 타듯” 움직입니다.
- 가상 스레드가 블로킹되면(예: 네트워크 응답 대기), JVM은 해당 스레드를 carrier thread에서 “내리고” 대기열에 둡니다.
- 스레드가 다시 실행 가능해지면, JVM은 빈 carrier thread에 그 스레드를 “태워” 이어 실행합니다.
비유:
택시가 4대(carrier threads)뿐인데 손님이 10,000명(virtual threads)이라고 상상해 보세요. 한 손님이 목적지에 도착해 내리면 택시는 곧바로 다음 손님을 태웁니다. 택시는 놀지 않고, 승객 수가 많다고 해서 택시가 부서지지도 않습니다.
스케줄링과 전환
JVM은 지금 어떤 가상 스레드를 실행할지 스스로 결정합니다. 스레드가 I/O에서 블로킹되면, 다른 스레드가 계속 일할 수 있도록 방해하지 않습니다.
5. 가상 스레드의 제한과 주의사항
가상이라고 해서 모두 좋은 것은 아니다
긴 계산 작업에는 부적합: CPU를 계속 점유하는 작업(heavy CPU‑bound)이라면 가상 스레드가 성능을 올려주지 않습니다. 어차피 carrier threads의 수는 코어 수에 의해 제한됩니다.
일부 락은 비효율적임: 오래된 동기화 방식(예: 네이티브 뮤텍스를 사용하는 객체에 대한 synchronized)은 JVM이 가상 스레드를 “동결”하지 못하게 만들 수 있습니다. 이런 경우 carrier thread도 함께 기다리게 되어 확장성이 떨어집니다.
모든 라이브러리가 가상 스레드 친화적인 것은 아님: 네이티브 호출을 하거나 특수한 락을 사용하는 라이브러리는 가상 스레드에서 예상과 다른 동작을 보일 수 있습니다.
예시: Virtual Threads를 쓰지 말아야 할 때
무한 루프를 돌며 숫자를 계산하는 작업이라면, 가상 스레드로 얻는 이점은 없습니다. 결국 코어 수의 한계에 부딪힙니다.
Thread.ofVirtual().start(() -> {
while (true) {
// 무한히 계산
}
});
결과:
한 carrier thread가 이 가상 스레드에 묶이고, 다른 작업들은 자신의 차례를 기다리게 됩니다.
6. 비교: Platform Thread vs Virtual Thread
| 특성 | Platform Thread(일반) | Virtual Thread(가상) |
|---|---|---|
| 관리 주체 | 운영 체제 | JVM |
| 스레드당 메모리 | 수 MB | 수십 KB |
| 스레드 수 | 보통 < 10_000 | 수천, 수십만 |
| 생성 비용 | 비쌈 | 저렴 |
| 확장성 | 제한적 | 거의 무제한 |
| 적합한 용도 | 장시간 작업, CPU‑bound | 짧은 I/O‑bound 작업 |
| 스레드 전환 | OS | JVM |
| 호환성 | 100% | 대부분 그렇지만 예외가 있음 |
7. 예시: Virtual Threads 도입 전/후 서버
이전(Platform Threads)
ServerSocket serverSocket = new ServerSocket(8080);
while (true) {
Socket client = serverSocket.accept();
new Thread(() -> handleClient(client)).start();
}
문제:
5,000개 연결 즈음부터 서버가 버벅이기 시작합니다.
이후(Virtual Threads, Java 21+)
ServerSocket serverSocket = new ServerSocket(8080);
while (true) {
Socket client = serverSocket.accept();
Thread.ofVirtual().start(() -> handleClient(client));
}
마법:
이제 수만 개의 연결을 처리하면서도 스레드 한도를 신경 쓰지 않아도 됩니다!
9. 가상 스레드로 전환할 때 흔한 실수
오류 1: 계산 작업에서의 가속을 기대함. 가상 스레드는 CPU를 꽉 채우는 작업을 빠르게 만들지 않습니다. 그런 작업은 여전히 코어 수의 한계에 묶입니다.
오류 2: 구식 블로킹 동기화 사용. 오래된 락(예: 네이티브로 “잠길” 수 있는 객체에 대한 synchronized)을 사용하면, 가상 스레드를 carrier thread에서 내리지 못해 장점을 잃을 수 있습니다.
오류 3: 서드파티 라이브러리의 동작을 간과함. 일부 서드파티 라이브러리는 가상 스레드 준비가 되어 있지 않을 수 있습니다(예: JNI 사용, 네이티브 락 사용).
오류 4: ‘마법 같은’ 성능 향상을 기대함. 가상 스레드는 만능이 아닙니다. 모든 것을 빠르게 만들지 않고, I/O‑bound 작업에 대해 병렬성을 싸고 편하게 만들어 줄 뿐입니다.
GO TO FULL VERSION