자바 21(Java 21)에서 가장 주목받는 혁신 기능은 단연 **가상 스레드(Virtual Threads)**입니다. 가상 스레드는 전통적인 자바의 멀티스레딩 모델이 가지고 있던 근본적인 성능 병목과 복잡한 비동기 프로그래밍 모델의 한계를 동시에 극복하기 위해 프로젝트 룸(Project Loom)을 통해 도입되었습니다. 가상 스레드의 핵심 도입 배경과 기존 스레드 모델과의 아키텍처적 차이점을 알기 쉽게 분석해 봅니다. 오늘 이 포스트에서는 가상 스레드의 탄생 배경부터 내부 구조, 코드 예제, 그리고 실무 도입 시 고려해야 할 사항들까지 아주 상세하게 다루어 보겠습니다.
1. 기존 자바 스레드 모델의 한계: 플랫폼 스레드 (Platform Threads)
전통적인 자바의 스레드는 운영체제(OS)의 커널 스레드(Kernel Thread)와 1:1로 매핑되는 플랫폼 스레드였습니다. 이 구조는 오랫동안 자바 생태계의 기반이 되어 왔지만, 대규모 동시성을 요구하는 현대적인 웹 애플리케이션에서는 뚜렷한 한계를 드러냈습니다.
1.1. 커널 스레드 매핑과 막대한 비용
자바에서 new Thread()로 생성한 스레드는 OS 커널 스레드로 바로 할당됩니다. 이는 스레드 생성 비용과 메모리 소모가 대단히 크다는 것을 의미합니다. 보통 플랫폼 스레드 하나를 생성할 때마다 약 1MB~2MB의 메모리가 필요합니다. 이 때문에 스레드를 수만 개씩 생성하게 되면 OOM(Out of Memory) 에러가 발생하거나 운영체제의 한계에 부딪히게 됩니다. 커널 스레드의 생성, 스케줄링, 소멸은 전적으로 OS가 관리하므로 스레드가 늘어날수록 시스템콜 오버헤드도 크게 증가합니다.
1.2. Thread-per-request 모델의 병목 현상
Spring Boot의 내장 톰캣(Tomcat) 같은 전통적인 웹 서버는 사용자 요청당 스레드를 하나씩 할당하는 Thread-per-request 모델을 사용합니다. 동시 접속자 수가 수천, 수만 명으로 급증하면 스레드의 최대 개수가 OS 한계에 도달해 가용 스레드가 고갈됩니다. 또한 수많은 스레드 사이에서 발생하는 잦은 컨텍스트 스위칭(Context Switching) 비용으로 인해 CPU 연산 능력이 급격히 떨어지는 병목(Bottleneck)이 발생하게 됩니다. 스레드가 DB 쿼리 결과나 네트워크 응답을 기다리기 위해 블로킹(Blocking) 상태에 들어가면, 그 시간 동안 해당 스레드에 할당된 메모리와 시스템 리소스는 사실상 버려지게 됩니다.
1.3. 비동기 모델의 대두와 부작용
스레드 고갈을 막기 위해 WebFlux 같은 리액티브(Reactive) 프로그래밍이 등장했습니다. 리액티브 프로그래밍은 소수의 스레드만으로도 비동기 논블로킹(Non-blocking) 방식으로 수많은 요청을 처리할 수 있게 해 주었습니다. 그러나 이러한 패러다임 변화는 개발자들에게 높은 피로도를 안겨주었습니다.
- 콜백 지옥(Callback Hell): 코드가 절차적으로 흐르지 않고 연속된 체이닝과 콜백으로 이루어져 가독성이 극도로 떨어집니다.
- 디버깅 난이도: 스레드가 작업을 넘겨주면서 실행되므로, 에러 발생 시 스택 트레이스(Stack Trace)가 유실되거나 추적하기 매우 어려워집니다.
이러한 문제를 해결하기 위해, 동기적인 코드 흐름(Synchronous style)을 유지하면서도 비동기 수준의 처리량(Throughput)을 달성할 수 있는 새로운 대안이 필요해졌고, 그것이 바로 가상 스레드입니다.
2. 가상 스레드(Virtual Thread)의 핵심 원리 및 구조
가상 스레드는 하나의 OS 스레드(플랫폼 스레드) 위에 수백만 개의 자바 스레드를 올릴 수 있도록 구현된 경량 스레드입니다. JVM 내부에서 자체적으로 스케줄링하여 커널 스레드에 대한 의존도를 대폭 낮췄습니다.
2.1. 1:N 매핑 구조와 캐리어 스레드
가상 스레드는 OS 레벨이 아니라 JVM 레벨에서 관리되는 사용자 정의 스레드(User-mode Thread)입니다. 가상 스레드는 논리적인 작업의 단위일 뿐이며, 실제로 작업을 수행할 때는 **캐리어 스레드(Carrier Thread)**라고 불리는 소수의 플랫폼 스레드 위에 올라타서(mount) 작동합니다. 보통 캐리어 스레드의 개수는 CPU 코어 수에 맞추어 생성됩니다. 즉, N(가상 스레드) : M(플랫폼 스레드)의 다대다 매핑 구조를 통해 수백만 개의 가상 스레드가 적은 수의 플랫폼 스레드를 공유하게 됩니다.
2.2. Non-blocking Mount/Unmount 메커니즘
가상 스레드의 가장 강력한 무기는 블로킹(Blocking) 상황에서의 유연한 대처입니다. 가상 스레드가 입출력(I/O) 작업을 만나 블로킹(예: 데이터베이스 조회, API 호출, 파일 읽기)되면, JVM 스케줄러는 즉시 해당 플랫폼 스레드에서 가상 스레드의 상태(Stack)를 힙(Heap) 메모리에 복사해 두고 내려놓습니다(Unmount). 그리고 대기 중인 다른 가상 스레드를 그 플랫폼 스레드에 마운트하여 실행합니다. I/O 작업이 완료되어 응답이 도착하면, JVM은 내려놓았던 가상 스레드를 다시 활성화 가능한 플랫폼 스레드에 마운트하여 멈췄던 지점부터 실행을 재개합니다. 덕분에 하부의 플랫폼 스레드는 노는 시간(Idle time) 없이 끊임없이 연산을 수행할 수 있게 되어 CPU 활용률이 극대화됩니다.
2.3. 메모리 효율성과 컨텍스트 스위칭
가상 스레드는 생성될 때 기존 플랫폼 스레드처럼 고정된 큰 스택 메모리를 할당받지 않습니다. 대신 작업에 필요한 만큼만 동적으로 힙 메모리를 사용하여 수백 바이트에서 수 킬로바이트 수준의 적은 메모리만 소비합니다. 또한 가상 스레드 간의 스위칭은 OS가 개입하지 않고 JVM 내부에서 단순한 포인터 조작과 메모리 복사 정도로 가볍게 이루어지므로, 컨텍스트 스위칭 비용이 사실상 거의 제로에 가깝습니다.
2.4. 비교 테이블
| 비교 항목 | 플랫폼 스레드 (Platform Thread) | 가상 스레드 (Virtual Thread) |
|---|---|---|
| OS 커널 매핑 | 1 : 1 매핑 | M : N 매핑 (JVM 내 스케줄링) |
| 스레드당 메모리 | 약 1 MB ~ 2MB (고정) | 수 KB 이하 (가변적, 힙 메모리 사용) |
| 최대 생성 개수 | 수천 개 수준 (OS 리소스 한계) | 수백만 개 가능 (사실상 무제한) |
| 컨텍스트 스위칭 | OS 커널 단에서 수행 (무거움) | JVM 내 가벼운 스택 프레임 전환 (가벼움) |
| 스케줄러 | OS 스케줄러 (선점형) | JVM 스케줄러 (비선점형, I/O 시 양보) |
3. 실전 자바 21 가상 스레드 코드 활용 예제
자바 21에서는 복잡한 설정 없이 스레드 팩토리나 빌더를 통해 매우 직관적으로 가상 스레드를 생성할 수 있습니다. 기존의 동기식 프로그래밍 모델을 그대로 사용하기 때문에 학습 곡선이 매우 낮습니다.
예제 1: 기본적인 가상 스레드 생성과 실행
가상 스레드는 Thread.ofVirtual() 메서드를 통해 빌더 패턴으로 쉽게 생성할 수 있습니다.
public class VirtualThreadBasicExample {
public static void main(String[] args) throws InterruptedException {
// 방법 1: 이름 없는 가상 스레드 시작
Thread vThread1 = Thread.ofVirtual().start(() -> {
System.out.println("가상 스레드 1 실행 중: " + Thread.currentThread());
});
// 방법 2: 이름을 지정하고 시작하지 않은 상태로 생성 후 start
Thread vThread2 = Thread.ofVirtual().name("vt-1").unstarted(() -> {
System.out.println("가상 스레드 2 실행 중: " + Thread.currentThread());
});
vThread2.start();
// 메인 스레드가 종료되기 전에 가상 스레드들이 완료될 때까지 대기
vThread1.join();
vThread2.join();
}
}
코드 설명:
Thread.ofVirtual()을 사용해 가상 스레드 팩토리를 얻습니다..start(Runnable)을 통해 즉시 실행하거나,.unstarted(Runnable)을 통해 스레드 객체만 생성할 수 있습니다.- 가상 스레드는 기본적으로 데몬(Daemon) 스레드입니다. 따라서 메인 스레드가 종료되면 가상 스레드도 즉시 종료되므로
join()을 호출해 대기해야 합니다.
예제 2: 수만 개의 가상 스레드를 동시에 실행하기
기존 플랫폼 스레드로는 10만 개의 스레드를 생성하면 OOM이 발생하지만, 가상 스레드는 문제없이 처리합니다.
import java.util.concurrent.Executors;
import java.util.concurrent.ExecutorService;
public class VirtualThreadMassiveExample {
public static void main(String[] args) {
long start = System.currentTimeMillis();
// 가상 스레드를 할당하는 ExecutorService 생성
// try-with-resources 블록을 사용하면 블록을 벗어날 때 모든 작업이 완료될 때까지 자동 대기(awaitTermination)합니다.
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 100_000; i++) {
final int taskId = i;
executor.submit(() -> {
try {
// I/O 등 무거운 연산을 시뮬레이션 (1초 대기)
Thread.sleep(1000);
if (taskId % 10000 == 0) {
System.out.println("작업 완료: " + taskId + " / 스레드: " + Thread.currentThread());
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
}
} // 이 지점에서 모든 가상 스레드가 종료될 때까지 대기합니다.
long end = System.currentTimeMillis();
System.out.println("10만 개 가상 스레드 처리 완료 시간: " + (end - start) + "ms");
}
}
코드 설명:
Executors.newVirtualThreadPerTaskExecutor(): 각 태스크마다 새로운 가상 스레드를 할당하는 ExecutorService를 반환합니다. 이전에 쓰던 스레드 풀(Thread Pool)의 개념이 아닙니다. 가상 스레드는 생성 비용이 저렴하므로 풀링(Pooling)할 필요가 전혀 없습니다.- 10만 개의 스레드가 각각 1초씩 sleep(I/O 대기를 시뮬레이션)합니다. 플랫폼 스레드라면 수십 초
수 분이 걸리거나 메모리 에러가 났겠지만, 가상 스레드는 약 12초 내에 모든 작업을 완료합니다. Thread.sleep()이 호출되는 순간, JVM은 캐리어 스레드에서 해당 가상 스레드를 Unmount하고 다른 가상 스레드를 즉시 Mount합니다.
4. 자주 묻는 질문 (FAQ)
Q1. 가상 스레드가 기존의 ThreadPool을 완전히 대체하나요?
네, 대부분의 I/O 바운드 작업에 한해서는 그렇습니다. 가상 스레드는 생성 비용이 극도로 저렴하고 메모리를 적게 소모하기 때문에 더 이상 비싼 스레드를 미리 만들어두고 재사용하는 '스레드 풀' 패턴이 필요하지 않습니다. 매 요청마다 새로운 가상 스레드를 생성(newVirtualThreadPerTaskExecutor())해서 쓰고 버리는 것이 훨씬 효율적입니다. 하지만 DB 커넥션 등 리소스가 제한된 자원에 접근할 때는 가상 스레드 개수만큼 커넥션을 맺을 수 없으므로 여전히 세마포어(Semaphore) 등을 이용한 동시성 제어는 필요합니다.
Q2. CPU 바운드(연산 집약적) 작업에도 가상 스레드가 유리한가요?
아닙니다. 가상 스레드의 핵심 강점은 스레드가 I/O 작업으로 인해 블로킹되었을 때 스레드를 언마운트하여 플랫폼 스레드의 낭비를 막는 데 있습니다. 이미지 렌더링, 암호화, 대규모 데이터 정렬 등 CPU가 쉴 새 없이 일해야 하는 작업(CPU 바운드 작업)에서는 가상 스레드를 쓰더라도 언마운트가 발생하지 않으므로 플랫폼 스레드를 독점하게 됩니다. 이러한 작업에는 여전히 CPU 코어 수에 맞춘 기존의 플랫폼 스레드 풀 또는 병렬 스트림(Parallel Stream)을 사용하는 것이 바람직합니다.
Q3. Pinning(피닝) 현상은 무엇이고 어떻게 해결하나요?
가상 스레드가 특정 상황에서 캐리어 스레드와 분리(Unmount)되지 못하고 고정되어 버리는 현상을 Pinning이라고 합니다. 대표적인 원인은 두 가지입니다.
synchronized블록이나 메서드 내부에서 블로킹 I/O 작업(예: 네트워크 호출, Thread.sleep 등)을 수행할 때.- 네이티브 메서드(JNI)를 실행하는 도중에 블로킹될 때. 피닝이 발생하면 캐리어 스레드가 블로킹되어 전체 애플리케이션의 처리량(Throughput)이 급격히 저하될 수 있습니다. 이를 해결하려면
synchronized대신java.util.concurrent.locks.ReentrantLock을 사용하도록 코드를 리팩토링해야 합니다.
5. 마무리 및 권장사항
자바 21에 정식으로 포함된 가상 스레드는 자바 생태계에 엄청난 지각 변동을 일으키고 있습니다. 기존의 명령형, 동기식 코딩 스타일을 그대로 유지하여 높은 생산성과 디버깅 편의성을 제공하면서도, 리액티브 프로그래밍에 버금가는 대규모 동시성과 성능을 이끌어 낼 수 있기 때문입니다.
실무 도입 시 권장사항:
- 스프링 부트(Spring Boot) 3.2 버전 이상을 사용 중이라면 설정 파일(
application.yml)에spring.threads.virtual.enabled=true한 줄만 추가하여 톰캣 요청 처리에 가상 스레드를 바로 적용할 수 있습니다. - 성능 테스트를 철저히 진행하세요. 가상 스레드는 만병통치약이 아닙니다. I/O 작업이 많은 웹 애플리케이션에서는 엄청난 처리량 상승을 기대할 수 있으나, 시스템 자원(예: DB 커넥션 풀 크기)이 뒷받침되지 않으면 DB 병목으로 장애가 전이될 수 있습니다.
- 코드 베이스에
synchronized가 포함된 구형 라이브러리가 있는지 확인하고, 필요 시ReentrantLock을 사용한 최신 버전으로 업데이트하여 Pinning 현상을 미연에 방지하시기 바랍니다.
가상 스레드를 제대로 이해하고 활용한다면, 최소한의 인프라 비용으로 최대의 트래픽을 감당하는 견고한 자바 애플리케이션을 구축할 수 있을 것입니다.
'개발지식' 카테고리의 다른 글
| How to Parse JSON in Java Using Gson – A Complete Guide (0) | 2025.04.17 |
|---|---|
| Understanding the Difference Between schedule() and scheduleAtFixedRate() in Java Timer (0) | 2025.04.14 |
| How to Implement Multipart File Upload Using Java and JavaScript (0) | 2025.04.13 |
| Mastering Oracle Timestamp and Date Conversion Functions: A Complete Guide (0) | 2025.04.12 |
| Docker Nodejs 설치 명령어 사용방법 (0) | 2024.04.01 |