1. 가상 스레드를 실전에서 만드는 방법
이제 이론에서 실전으로 넘어갈 시간입니다! 전통적인 방식으로 스레드를 만드는 방법은 이미 알고 계시죠:
Thread t = new Thread(() -> System.out.println("Hello from thread!"));
t.start();
혹은 더 간단히:
new Thread(() -> System.out.println("Hi!")).start();
이제 Java 21부터는 새로운 방식이 생겼습니다:
Thread.startVirtualThread(() -> System.out.println("Hello from virtual thread!"));
또는 더 명시적으로:
Thread t = Thread.ofVirtual().start(() -> System.out.println("Hello from virtual thread!"));
무슨 차이가 있을까요?
- Thread.ofVirtual().start(...)는 운영체제가 아니라 JVM이 관리하는 가상 스레드(Virtual Thread)를 생성합니다.
- Thread.ofPlatform().start(...) (또는 new Thread(...))는 예전과 같은 전통적인 스레드입니다.
왜 중요할까요?
가상 스레드는 OutOfMemoryError 걱정 없이 수만 개도 만들 수 있습니다. 이제 백만 개의 요청을 처리하고 싶어져도 Java는 이렇게 말할 겁니다: “문제없어, 더 가져와!”
2. 가상 스레드 생성 구문
기본 예제:
public class VirtualThreadDemo {
public static void main(String[] args) {
Thread thread = Thread.ofVirtual().start(() -> {
System.out.println("가상 스레드에서 인사합니다! 스레드: " + Thread.currentThread());
});
// 스레드가 끝나기를 기다림(메인 메서드가 먼저 종료되지 않도록)
try {
thread.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
무슨 일이 일어나는 걸까요?
- Thread.ofVirtual().start(...)로 가상 스레드를 생성합니다.
- 스레드 내부에서는 단순히 메시지를 출력합니다.
- 마지막에 thread.join()을 호출해 메인 스레드가 가상 스레드가 끝날 때까지 기다리게 합니다(그렇지 않으면 프로그램이 스레드가 출력하기 전에 종료될 수 있습니다).
유의할 점:
가상 스레드는 겉보기와 동작은 거의 일반 스레드와 같지만, 내부에서는 JVM의 마법이 작동합니다!
3. 가상 스레드를 대량 생성하기: Loom의 위력을 실전에서
이제 일반 스레드로는 위험하거나 사실상 불가능했던 일을 시도해 봅시다. 10_000개의 가상 스레드를 만들고 각 스레드가 자신의 번호를 출력하게 하겠습니다.
public class VirtualThreadMassive {
public static void main(String[] args) throws InterruptedException {
int N = 10_000;
Thread[] threads = new Thread[N];
for (int i = 0; i < N; i++) {
int threadNum = i;
threads[i] = Thread.ofVirtual().start(() -> {
System.out.println("가상 스레드 #" + threadNum + " 실행 중!");
});
}
// 모든 스레드가 종료될 때까지 대기
for (Thread t : threads) {
t.join();
}
System.out.println("모든 가상 스레드가 완료되었습니다!");
}
}
- 일반 스레드(new Thread(...))로는 이런 코드는 높은 확률로 OutOfMemoryError와 함께 프로그램을 ‘다운’시킬 것입니다.
- 가상 스레드에게는 이런 패턴이 표준 동작입니다! JVM은 수천, 수만 개의 스레드를 가볍게 처리합니다.
참고로 10_000이 많아 보인다면 100_000이나 심지어 1_000_000도 시도해 보세요. 현대적인 머신에서는 스레드가 단순 작업을 하거나 입출력을 기다린다면 JVM이 충분히 감당합니다.
4. Runnable과 람다: 가상 스레드에 코드를 전달하는 방법
가상 스레드는 일반 스레드와 동일하게 인터페이스 Runnable로 작업을 받습니다. 따라서 람다식, 메서드 참조, 그리고 Runnable을 구현한 어떤 객체도 전달할 수 있습니다.
람다 예제:
Thread.ofVirtual().start(() -> System.out.println("가상 스레드의 람다!"));
메서드 참조 예제:
public class TaskRunner {
public static void main(String[] args) {
Thread.ofVirtual().start(TaskRunner::doWork);
}
static void doWork() {
System.out.println("가상 스레드에서 작업 중: " + Thread.currentThread());
}
}
익명 클래스 예제:
Thread.ofVirtual().start(new Runnable() {
@Override
public void run() {
System.out.println("가상 스레드에서 익명 클래스!");
}
});
정리:
일반 스레드에서 작동하던 모든 것이 가상 스레드에서도 그대로 작동합니다 — 다만 이제는 ‘가볍고 빠르게’ 가능합니다.
5. ExecutorService와의 비교: 기존 방식 vs 새로운 방식
기존 ExecutorService
ExecutorService executor = Executors.newFixedThreadPool(10);
for (int i = 0; i < 100; i++) {
int taskNum = i;
executor.submit(() -> {
System.out.println("작업 #" + taskNum + " 실행 중");
});
}
executor.shutdown();
문제점:
작업이 너무 많은데 스레드가 적으면 작업은 큐에서 기다립니다. 반대로 스레드가 너무 많으면 자원이 부족해 프로그램이 버벅거리거나 중단될 수 있습니다.
새로운 방법: 가상 스레드 기반 Executor
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
for (int i = 0; i < 100_000; i++) {
int taskNum = i;
executor.submit(() -> {
System.out.println("가상 작업 #" + taskNum);
});
}
executor.shutdown();
무슨 일이 벌어지나요?
- 각 작업마다 별도의 가상 스레드가 생성됩니다.
- JVM이 스케줄링을 자동으로 관리하여 시스템을 과부하시키지 않습니다.
- 풀 크기를 제한할 필요가 없습니다 — 가상 스레드는 ‘거의 공짜’입니다.
가상 스레드 기반 Executor는 언제 사용하면 좋을까요?
- 작업이 매우 많고 풀 크기를 고민하고 싶지 않을 때
- 작업들이 서로 독립적이며 병렬 실행이 가능할 때
- 간단하게 쓰고 싶을 때: 스레드를 직접 관리할 필요가 없습니다.
6. 실용 팁: 언제 어떤 방식을 쓸까
Thread.ofVirtual().start()를 직접 사용할 때?
- 특정한 고유 작업을 위해 개별 스레드를 만들 때(예: 테스트, 데모, 간단한 실험)
- 스레드 수가 많지 않고 수동으로 직접 관리하고 싶을 때
Executors.newVirtualThreadPerTaskExecutor()를 사용할 때?
- 작업을 대량으로 실행해야 할 때(예: 많은 요청, 파일, 네트워크 연결 처리)
- 작업이 서로 독립적이고 조율이 필요하지 않을 때
- 이미 ExecutorService를 사용하는 기존 아키텍처(예: 웹 서버, 작업 처리기 등)에 가상 스레드를 통합하고 싶을 때
팁:
확신이 서지 않는다면 가상 스레드 기반 Executor부터 시작하세요. 가장 범용적이고 최신의 방식입니다.
7. 가상 스레드에서의 예외 처리
가상 스레드는 try-catch 관점에서 일반 스레드와 동일합니다. Runnable 내부에서 예외가 발생해도 JVM 전체가 망가지는 것이 아니라 해당 스레드만 오류와 함께 종료됩니다.
예제:
Thread t = Thread.ofVirtual().start(() -> {
throw new RuntimeException("문제가 발생했습니다!");
});
try {
t.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("메인 스레드는 계속 실행됩니다.");
ExecutorService에서:
submit으로 작업을 제출하면 결과는 Future를 통해 얻을 수 있고, 예외는 get() 호출 시 전파됩니다:
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
Future<?> f = executor.submit(() -> {
throw new RuntimeException("가상 작업에서 오류가 발생했습니다");
});
try {
f.get();
} catch (ExecutionException e) {
System.out.println("가상 스레드에서 발생한 오류를 포착: " + e.getCause());
}
executor.shutdown();
8. 가상 스레드 생성 시 흔한 실수
실수 1: 가상 스레드와 플랫폼 스레드를 혼동함. new Thread(...)나 Thread.ofPlatform()으로 만든 스레드는 아닙니다 가상 스레드가. 진짜 Virtual Threads는 Thread.ofVirtual().start(...) 또는 Executors의 메서드들이 제공합니다.
실수 2: 무거운 계산이 빨라질 것이라고 기대함. 가상 스레드는 CPU-bound 작업을 빠르게 만들지 않습니다. 백만 개의 스레드가 각각 원주율을 백만 자리까지 계산한다면, JVM은 계산을 ‘가속’할 수 없고 단지 스레드 간 전환만 늘어납니다.
실수 3: 각 스레드마다 리소스(예: 데이터베이스)를 붙잡아 둠. 가상 스레드를 백만 개 만들고 각각이 데이터베이스에 별도의 연결을 요구한다면, 데이터베이스가 버티지 못합니다. 가상 스레드는 주로 시간의 대부분을 대기(I/O)에 쓰고, 제한된 외부 리소스를 많이 점유하지 않는 작업에 적합합니다.
실수 4: 중요할 때 스레드 종료를 기다리지 않음. 메인 스레드가 가상 스레드보다 먼저 끝나면 결과를 기다리지 못한 채 프로그램이 종료될 수 있습니다. join()이나 shutdown(), awaitTermination()과 함께 ExecutorService를 사용하세요.
실수 5: 가상 스레드와 호환되지 않는 오래된 라이브러리를 사용함. 일부 서드파티 라이브러리는 OS 레벨에서 스레드를 블로킹하거나 네이티브 동기화를 사용해 가상 스레드의 효율을 떨어뜨릴 수 있습니다. 반드시 호환성을 확인하세요.
GO TO FULL VERSION