가상 스레드

가상 스레드

이번 시간에는 가상 스레드(Virtual Thread)의 개념과 기존 스레드 모델과의 차이를 중심으로, Java 동시성 구조가 어떻게 확장되고 있는지를 살펴보겠습니다. 가상 스레드는 JVM 수준에서 관리되는 경량 실행 단위로, 많은 수의 동시 작업을 보다 낮은 비용으로 처리할 수 있게 해주며 특히 블로킹 I/O 중심의 애플리케이션에서 큰 이점을 제공합니다. 본 글에서는 스레드와 가상 스레드의 기본 개념을 비교한 뒤, 가상 스레드의 동작 원리와 스케줄링 방식, 기존 동시성 모델과의 관계를 정리하고, 장점과 활용 사례, 그리고 도입 시 고려해야 할 한계와 성능 관점까지 순서대로 정리해보겠습니다.

Uploaded image

스레드와 가상 스레드의 차이

스레드의 기본 개념

  • 운영체제 (OS) 커널에서 관리하는 실행 단위를 뜻합니다.
  • 각 스레드는 고유한 스택과 레지스터를 가지며, CPU 시간을 할당받아 코드를 실행합니다.
  • OS 스레드는 블로킹 I/O 작업 시 해당 스레드가 대기 상태에 들어가 CPU Resource를 낭비할 수 있습니다.
  • 생성 및 관리 비용이 비교적 높다는 단점이 있습니다.

가상 스레드의 기본 개념

  • JVM (Java Virtual Machine) 수준에서 관리되는 경량 스레드를 뜻합니다.
  • OS 스레드와 1:1 매핑이 아닌, 여러 가상 스레드가 하나의 OS 스레드에서 실행될 수 있습니다.
  • 블로킹 I/O 작업 시 다른 가상 스레드가 실행될 수 있어 CPU Resource 낭비를 줄일 수 있습니다.
  • 생성 및 관리 비용이 비교적 낮은 장점이 있습니다.

가상 스레드의 동작 원리와 스케줄링

  • 가상 스레드는 JVM 또는 사용자 수준(User-level)에서 관리되는 경량 스레드입니다.
  • 여러 가상 스레드가 소수의 커널(플랫폼) 스레드 위에서 협력적으로 실행됩니다 (M:N 매핑).
  • Java의 경우, ForkJoinPool 등 내부 스케줄러가 가상 스레드를 관리하며, 컨텍스트 스위칭 비용이 매우 작고 스레드 생성/소멸 비용이 거의 없습니다.
  • 블로킹 I/O 작업이 발생하면 해당 가상 스레드는 대기 상태로 전환되고, 다른 가상 스레드가 즉시 실행될 수 있습니다.

가상 스레드와 기존 동시성 모델 비교

  • OS 스레드(1:1 모델): 각 사용자 스레드가 하나의 커널 스레드에 매핑. 생성/관리 비용이 높고, 대규모 동시성에 한계.
  • 유저 스레드(N:1 모델): 여러 사용자 스레드가 하나의 커널 스레드에 매핑. 커널 개입이 적으나, 병렬성 제한.
  • 가상 스레드(M:N 모델): 여러 가상 스레드가 소수의 커널 스레드에 매핑. 높은 동시성과 효율성 제공.
  • Go 루틴(Goroutine), Python asyncio, Node.js 이벤트 루프 등과 유사한 개념이나, Java 가상 스레드는 기존 스레드 API와 완전히 호환됨.

가상 스레드의 장점

  • 향상된 성능: 대규모 동시성 처리 시스템에서 더 높은 처리량과 응답성을 제공합니다.
  • 간결하고 명확한 코드: 블로킹 코드를 사용하면서도 비동기 프로그래밍의 장점을 누릴 수 있습니다.
  • 높은 자원 효율성: CPU 및 메모리 자원을 효율적으로 활용하여 시스템 비용을 절감할 수 있습니다.
  • 스레드 생성/소멸 비용이 매우 낮음: 수십만~수백만 개의 스레드도 부담 없이 생성 가능.

실제 코드 예시 및 활용 사례

가상 스레드는 기존 Thread API와 호환되면서도, 블로킹 I/O 중심 작업을 대규모로 처리할 때 비용을 크게 줄이는 것이 핵심입니다.
아래 예시는 “단순 생성 → 대량 작업 처리 → I/O 형태 예시 → 기존 방식 비교 → 주의점” 순서로 구성했습니다.

가장 기본적인 가상 스레드 생성


// JDK 21
public class VirtualThreadBasic {
    public static void main(String[] args) {
        Thread vt = Thread.startVirtualThread(() -> {
            System.out.println("Hello from virtual thread!");
        });

        // startVirtualThread는 이미 시작된 Thread를 반환한다.
        try {
            vt.join();
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }
}

Thread.startVirtualThread(...)는 가장 간단한 형태이며, 학습/데모에 적합합니다.

이름/속성 지정하여 생성하기


public class VirtualThreadNamed {
    public static void main(String[] args) throws InterruptedException {
        Thread vt = Thread.ofVirtual()
                .name("vt-worker-1")
                .start(() -> {
                    System.out.println(Thread.currentThread());
                });

        vt.join();
    }
}

디버깅이나 로그 추적을 위해 이름을 지정하는 습관은 실제 서비스에서도 유용합니다.

가상 스레드 전용 Executor

대부분의 서버 코드는 "요청마다 작업을 던지는" 형태이므로 newVirtualThreadPerTaskExecutor()가장 실용적인 진입점이 됩니다.


import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.stream.IntStream;

public class VirtualThreadExecutor {
    public static void main(String[] args) throws Exception {
        try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {

            IntStream.range(0, 10).forEach(i -> {
                executor.submit(() -> {
                    // 블로킹 작업이 있어도 가상 스레드는 부담이 적다.
                    Thread.sleep(200);
                    System.out.println("task " + i + " on " + Thread.currentThread());
                    return i;
                });
            });

            // close()가 호출되면 제출된 작업들이 종료될 때까지 대기한다.
        }
    }
}

이 패턴은 웹 서버, 배치 I/O, 외부 API 호출 병렬 처리에 특히 적합합니다.

"대규모 동시성" 감각을 보여주는 예시

아래는 가상 스레드의 강점을 직관적으로 보여주는 예시 코드입니다.


import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class VirtualThreadMassive {
    public static void main(String[] args) {
        int tasks = 100_000;

        long start = System.currentTimeMillis();

        try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
            for (int i = 0; i < tasks; i++) {
                executor.submit(() -> {
                    // I/O를 가정한 블로킹
                    Thread.sleep(10);
                    return null;
                });
            }
        } // try-with-resources 종료 시점에 작업 완료까지 대기

        long end = System.currentTimeMillis();
        System.out.println("done: " + tasks + " tasks in " + (end - start) + " ms");
    }
}

플랫폼 스레드로 같은 패턴을 구성하면 스레드 수 제한/메모리/스케줄링 부담 때문에 현실적으로 어렵거나 비용이 커질 수 있습니다.

플랫폼 스레드 방식과의 비교

동일한 "요청당 스레드" 구조를 플랫폼 스레드로 구현하면 다음과 같은 형태가 됩니다.


import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class PlatformThreadExecutorExample {
    public static void main(String[] args) {
        // 플랫폼 스레드는 일반적으로 제한된 스레드 풀을 사용한다.
        try (ExecutorService executor = Executors.newFixedThreadPool(100)) {
            for (int i = 0; i < 1000; i++) {
                executor.submit(() -> {
                    Thread.sleep(200);
                    return null;
                });
            }
        }
    }
}
  • 플랫폼 스레드 기반에서는 풀 크기 튜닝이 중요합니다.
  • 반면 가상 스레드는 "작업당 스레드" 모델을 보다 단순하게 유지할 수 있습니다.

블로킹 I/O 스타일 코드와의 궁합

가상 스레드의 큰 장점은 비동기 콜백 없이 "읽기 쉬운 동기식 코드"를 유지할 수 있다는 점입니다.


import java.time.Duration;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class VirtualThreadHttpExample {
    public static void main(String[] args) throws Exception {
        HttpClient client = HttpClient.newBuilder()
                .connectTimeout(Duration.ofSeconds(3))
                .build();

        try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
            for (int i = 0; i < 20; i++) {
                int idx = i;
                executor.submit(() -> {
                    HttpRequest req = HttpRequest.newBuilder()
                            .uri(URI.create("https://example.com"))
                            .GET()
                            .build();

                    // 동기식 send 호출(블로킹)도 가상 스레드 환경에서는 부담이 줄어든다.
                    HttpResponse res =
                            client.send(req, HttpResponse.BodyHandlers.ofString());

                    System.out.println(idx + " status=" + res.statusCode());
                    return null;
                });
            }
        }
    }
}

실제 서비스라면 DB 쿼리, 외부 API 호출, 파일/네트워크 I/O에서 이런 직관적인 구조로 높은 동시성을 노릴 수 있습니다.

활용 사례 정리

  • 대규모 서버/웹 서비스
    • 요청마다 가상 스레드를 할당하는 모델로 코드 단순성을 유지하면서 동시성을 확대.
  • 마이크로서비스 간 동기 호출
    • 블로킹 호출 구조를 유지하되 리소스 비용을 완화.
  • I/O 중심 배치 작업
    • 대부분의 외부 시스템/파일/DB를 다루는 병렬 작업에 효과적.

가상 스레드를 사용 시 고려할 점 및 한계

  • 런타임 환경: JDK 19 이상에서 정식 지원 (Project Loom)
  • 네이티브 코드/JNI, 특정 라이브러리와의 호환성 이슈: 일부 네이티브 라이브러리는 가상 스레드와 잘 동작하지 않을 수 있음
  • 스레드 로컬(Thread-local) 변수: 사용 시 주의 필요 (스케줄링 특성상 예기치 않은 동작 가능)
  • CPU 집약적인 작업: 가상 스레드는 블로킹 I/O에 최적화되어 있으며, CPU 바운드 작업에는 큰 이점이 없음
  • 디버깅/모니터링: 기존 도구와의 호환성, 새로운 진단 기법에 대한 학습 필요

성능 및 자원 관리

  • 메모리 사용량: 가상 스레드는 스택 크기가 작아 수십만 개 생성 가능 (기본 256KB, 필요 시 동적 확장)
  • 컨텍스트 스위칭 오버헤드: 커널 스레드 대비 매우 낮음
  • 자원 관리: 효율적이지만, 무분별한 스레드 생성은 오히려 시스템 자원 고갈을 초래할 수 있으므로 주의

마치며

가상 스레드는 JVM 수준에서 관리되는 경량 스레드로, 대규모 동시성 처리 시스템에서 높은 성능과 자원 효율성을 제공합니다. 블로킹 코드를 사용하면서도 비동기 프로그래밍의 장점을 누릴 수 있으며, 간결하고 명확한 코드 작성을 가능하게 합니다.

다만, 런타임 환경이나 디버깅 및 모니터링, CPU 집약적인 작업, 네이티브 라이브러리와의 호환성 등은 신중하게 고려해야 하며, 시스템 특성과 요구사항에 맞춰 도입하는 것이 중요합니다.

Previous Post

CPU 스케줄러

CPU 스케줄링의 기본 개념과 주요 스케줄링 알고리즘, 고급 스케줄링 기법에 대해 이야기합니다.

CPU 스케줄러

Next Post

TRELLIS: 대규모 3D 생성 AI 모델

TRELLIS의 개념과 구조, 사용법 등을 이야기합니다.

TRELLIS: 대규모 3D 생성 AI 모델
scroll to top