CodeGym /행동 /JAVA 25 SELF /Virtual Threads의 확장성과 성능

Virtual Threads의 확장성과 성능

JAVA 25 SELF
레벨 57, 레슨 2
사용 가능

1. 확장성

일반 스레드는 왜 잘 확장되지 않을까?

전통적인 스레드(Thread)는 자체 스택(보통 1–2 MB)과 상태 구조를 가진 운영체제의 엔터티입니다. 예를 들어 10 000개의 일반 스레드를 만들려 하면 종종 OutOfMemoryError가 발생합니다. 그래서 기존 서버에서는 제한된 스레드 풀을 사용합니다.

가상 스레드: 확장성의 마법

가상 스레드(Java 21+)는 JVM이 관리하는 ‘가벼운’ 스레드입니다. 스택은 힙에 저장되며 동적으로 커졌다 줄어들 수 있습니다. 스레드가 I/O에서 블록되면 JVM이 그 스레드를 ‘동결’하고 다른 작업을 계속 실행합니다.

JVM 내부에는 소수의 “캐리어 스레드”(carrier threads)라는 플랫폼 스레드 풀이 있고, 그 위에서 가상 스레드가 순차적으로 실행됩니다. 덕분에 100 000+ 작업을 메모리 걱정 없이 만들 수 있습니다. 어떤 가상 스레드를 언제 실행할지는 JVM이 스스로 스케줄링합니다.

데모: 100_000개 가상 스레드 vs 1_000개 플랫폼 스레드

예: 1000개의 일반 스레드 생성

// 1000개의 일반 스레드를 만들려는 시도
List<Thread> threads = new ArrayList<>();
for (int i = 0; i < 1000; i++) {
    Thread t = new Thread(() -> {
        try {
            Thread.sleep(10_000);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    });
    threads.add(t);
    t.start();
}
System.out.println("생성된 스레드 수: " + threads.size());

결과: 대부분의 시스템에서는 1 000–2 000개 정도까지는 생성되지만, 그 이상에서는 메모리 문제와 지연이 발생합니다.

예: 100 000개의 가상 스레드 생성

// 100_000개의 가상 스레드를 생성
List<Thread> vThreads = new ArrayList<>();
for (int i = 0; i < 100_000; i++) {
    Thread t = Thread.ofVirtual().start(() -> {
        try {
            Thread.sleep(10_000);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    });
    vThreads.add(t);
}
System.out.println("생성된 가상 스레드: " + vThreads.size());

결과: 프로그램은 중단이나 큰 지연 없이 100 000개의 가상 스레드를 무난히 생성합니다. 필요한 메모리도 대폭 줄어듭니다.

시각적 비교

스레드 유형 최대 스레드 수(대략) 메모리 사용량 시작 시간
일반 (Thread) 1 000 – 10 000 높음 오래 걸림
가상 100 000 – 1 000 000+ 낮음 즉시

사실: 가상 스레드는 복잡한 풀 없이도 ‘작업당 하나의 스레드’ 방식으로 코드를 작성할 수 있게 해 주며, 시스템 과부하 위험을 줄여 줍니다.

2. 성능: 가상 스레드가 빛나는 곳

입출력에 가로막히는 작업 (I/O-bound)

가상 스레드는 네트워크 요청, 파일 I/O, DB 작업에 매우 적합합니다. 연산이 블록되면 가상 스레드는 ‘캐리어’를 비워 주고, JVM은 다른 작업을 실행합니다. 동시 대기 상황이 많을수록 처리량이 향상됩니다.

예: 동시 10 000개의 HTTP 요청 시뮬레이션

import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;

HttpClient client = HttpClient.newHttpClient();
List<Thread> threads = new ArrayList<>();

for (int i = 0; i < 10_000; i++) {
    Thread t = Thread.ofVirtual().start(() -> {
        try {
            HttpRequest request = HttpRequest.newBuilder()
                .uri(URI.create("https://example.com"))
                .build();
            HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
            System.out.println("응답: " + response.statusCode());
        } catch (Exception e) {
            System.out.println("오류: " + e.getMessage());
        }
    });
    threads.add(t);
}

// 모든 스레드가 종료될 때까지 대기
for (Thread t : threads) {
    t.join();
}

결과: 모든 10 000개 요청이 병렬로 처리되며, 프로그램이 죽지 않고 코드도 단순함을 유지합니다.

CPU-bound: 가상 스레드는 계산을 빠르게 하지 않는다

작업이 CPU를 많이 사용한다면 가상 스레드가 속도를 올려 주지 않습니다. 코어 수는 정해져 있으므로, 이 경우에는 코어 수에 맞춘 고정 크기 풀을 사용해 불필요한 경쟁을 줄이세요.

// 각 작업은 큰 범위의 합을 계산
Runnable cpuTask = () -> {
    long sum = 0;
    for (int i = 0; i < 100_000_000; i++) {
        sum += i;
    }
    System.out.println("합계: " + sum);
};

// 계산 작업을 수행하는 가상 스레드 1000개 시작
for (int i = 0; i < 1000; i++) {
    Thread.ofVirtual().start(cpuTask);
}

결과: 스레드들은 CPU를 두고 경쟁하지만 빨라지지 않습니다 — 이것은 Virtual Threads의 영역이 아닙니다.

3. 제약과 특징

동기화와 가상 스레드의 함정

  • 네이티브 락에 주의. synchronized를 사용하면 가상 스레드가 캐리어에 ‘붙잡혀’ 이점이 줄어들 수 있습니다. ReentrantLock, Semaphore 등 java.util.concurrent의 프리미티브를 선호하세요. 가상 스레드에 맞게 최적화되어 있습니다.
  • 구형 라이브러리. 일부 JDBC 드라이버와 네이티브 라이브러리는 아직 Virtual Threads에 최적화되어 있지 않습니다. 블로킹 연산을 꼼꼼히 테스트하세요.

장시간 지속되는 작업에는 부적합

Virtual Threads는 ‘짧은’ 단위의 작업, 예를 들어 요청 처리처럼 한 번 수행하고 끝나는 작업에 이상적입니다. 끝없이 오래 살아 있는 작업(예: 영속적인 계산)을 수백만 개 띄워도 유익하지 않습니다 — 그런 경우에는 플랫폼 스레드를 사용하세요.

4. Best practices: 가상 스레드를 사용할 곳

  • I/O-bound 작업: 네트워크 호출, 파일, DB 등 스레드가 자주 기다리는 모든 곳.
  • 웹 서버: 각 HTTP 요청을 별도의 가상 스레드에서 처리하세요.
  • 통합 테스트: 수천 명의 클라이언트를 빠르게 시뮬레이션하세요.
  • 비동기 처리: 익숙한 ‘블로킹’ 코드를 그대로 작성해도 JVM이 똑똑하게 스케줄링합니다.

권장하지 않는 경우:

  • CPU를 지속적으로 가동하는 작업.
  • 저수준 라이브러리와의 호환성이 절대적으로 중요한 경우(아직 모두가 적응된 것은 아님).

내부적으로는 실행기를 쓰면 편리합니다: Executors.newVirtualThreadPerTaskExecutor() — 고정 풀 없이 ‘작업당 하나의 가상 스레드’.

5. 모니터링과 측정: 가상 스레드를 눈으로 확인하는 법

JVisualVM과 Flight Recorder

JVisualVM은 활성 스레드, 상태, 메모리를 보여 줍니다. Java 21부터는 가상 스레드가 별도로 표시됩니다. Java Flight Recorder(JFR)는 실행의 상세한 ‘블랙박스’를 기록하고 Virtual Threads 통계까지 포함하므로 병목을 찾기에 유용합니다.

코드에서 스레드 수를 확인하는 방법

JVM에서 스레드 수를 확인하는 간단한 방법:

System.out.println("총 스레드 수: " + Thread.activeCount());

그중 가상 스레드가 몇 개인지 계산:

long vCount = Thread.getAllStackTraces().keySet().stream()
    .filter(Thread::isVirtual)
    .count();
System.out.println("가상 스레드 수: " + vCount);

6. 가상 스레드 사용 시 흔한 실수

오류 №1: 무거운 계산에 가상 스레드를 사용함. CPU‑bound 작업으로 수백만 개의 가상 스레드를 돌려도 프로세서가 빨라지지 않습니다. Virtual Threads는 계산의 ‘터보’가 아닙니다.

오류 №2: 오래된 패턴을 맹목적으로 복사함. 가상 스레드의 고정 풀을 만들지 마세요. Executors.newVirtualThreadPerTaskExecutor()를 사용하고 JVM이 자동으로 확장하도록 두세요.

오류 №3: 미지원 라이브러리 사용. Loom에 맞게 적응되지 않은 네이티브 락과 라이브러리는 멈춤과 성능 저하를 유발할 수 있습니다. 호환성을 미리 확인하세요.

오류 №4: 성급한 최적화. 스레드가 몇 개 안 되고 일반적인 멀티스레딩이면, 모든 것을 Virtual Threads로 옮기려 서두르지 마세요. 이 도구는 대규모 I/O와 대기가 많은 곳에서 빛납니다.

오류 №5: 모니터링을 무시함. 백만 개의 작업을 만드는 것은 쉽지만, 모니터링과 예외 처리가 없으면 ‘그럴싸한 벤치마크’만 남고 신뢰할 수 있는 시스템은 아닙니다. JVisualVM과 JFR을 사용하세요.

1
과제
JAVA 25 SELF, 레벨 57, 레슨 2
잠금
가상 도시 인구 모니터링 🏙️
가상 도시 인구 모니터링 🏙️
1
과제
JAVA 25 SELF, 레벨 57, 레슨 2
잠금
클라우드 컴퓨팅 서버 부하 시뮬레이션 ☁️
클라우드 컴퓨팅 서버 부하 시뮬레이션 ☁️
코멘트
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION