[자바] 20. 가상 스레드 : Project Loom의 정체 (Java 21)

자바 버전 여행 20편 - Project Loom의 정체

작성자

카테고리:

* 이 게시물은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.

(자바 버전 여행 ⑳)

자바 21에서 가장 큰 사건을 하나만 꼽으라면 가상 스레드다. 오래 개발된 Project Loom의 결과물로, “스레드는 비싸다”는 오랜 상식을 뒤집는다. 이번 편은 그게 뭔지, 왜 나왔는지를 잡는다.

플랫폼 스레드는 왜 비쌌나

지금까지 자바의 Thread 하나는 OS 커널 스레드 하나와 1:1로 묶였다. 스택으로 수백 KB~1MB 메모리를 먹고, 만드는 것도 무겁다. 그래서 수천 개를 띄우면 메모리가 바닥나, 어쩔 수 없이 스레드 풀로 몇십 개를 아껴 돌려썼다. 요청이 DB 응답을 기다리는 동안 그 귀한 스레드가 그냥 놀며 묶여 있었다.

숫자로 보면 감이 온다. 리눅스 x64 기준 자바 스레드의 기본 스택 예약이 1MB(-Xss 값)라, 1만 개면 스택 예약만 10GB 규모다. 실제로는 쓴 만큼만 물리 메모리에 잡히지만, 그 전에 커널 스레드 생성 비용과 OS의 스레드 수 제한(ulimit -u)이 발목을 잡는다. “스레드는 아껴 써야 한다”는 상식이 여기서 나왔다.

가상 스레드 — JVM이 굴리는 경량 스레드

가상 스레드는 OS가 아니라 JVM이 관리하는 아주 가벼운 스레드다. 수천 개의 가상 스레드가 소수의 진짜 스레드(캐리어 스레드) 위에 얹혀 돈다. 어떤 가상 스레드가 I/O로 대기에 들어가면, JVM은 그 순간 캐리어에서 내려놓고 다른 가상 스레드를 올린다. 기다리는 동안 진짜 스레드를 붙잡지 않는 것이다.

graph TD
  V1[가상 스레드 1] --> C[캐리어 스레드
몇 개뿐] V2[가상 스레드 2] --> C V3[가상 스레드 ...수만] --> C C --> OS[OS 커널 스레드]

이 구조가 Project Loom이 몇 년을 들여 만든 핵심이고, 자바 21에서 JEP 444로 정식 기능이 됐다. 미리보기 플래그 없이 그냥 쓴다. 둘을 나란히 놓으면 이렇다.

플랫폼 스레드가상 스레드
관리 주체OS 커널JVM (Project Loom)
생성 비용무겁다 (커널 호출)객체 하나 만드는 수준
스택기본 약 1MB 고정 예약힙에서 작게 시작, 필요한 만큼 성장
현실적인 개수수천 개수십만~수백만 개
블로킹하면커널 스레드가 통째로 묶임캐리어에서 내려오고, 캐리어는 다른 일

수치로 보는 차이 — 스레드 10만 개 만들기

표만 보면 심심하니 재 보자. 같은 코드에서 플랫폼/가상만 바꿔 가며 스레드 10만 개를 만든다. 파일 하나면 되고, JDK 21이면 컴파일 없이 바로 돈다.

// ThreadRace.java — 실행: java ThreadRace.java virtual (또는 platform)
import java.util.concurrent.CountDownLatch;

public class ThreadRace {
    public static void main(String[] args) throws Exception {
        int n = 100_000;
        boolean virtual = args.length > 0 && args[0].equals("virtual");

        var latch = new CountDownLatch(n);
        var builder = virtual ? Thread.ofVirtual() : Thread.ofPlatform();

        long t = System.currentTimeMillis();
        for (int i = 0; i < n; i++) {
            builder.start(() -> {
                try { Thread.sleep(10_000); } catch (InterruptedException e) { }
                latch.countDown();
            });
        }
        System.out.println((virtual ? "가상" : "플랫폼") + " 스레드 " + n
                + "개 생성: " + (System.currentTimeMillis() - t) + " ms");
        latch.await();
    }
}

내 환경(리눅스 x64, JDK 21, 기본 옵션)의 대표적인 결과.

$ java ThreadRace.java virtual
가상 스레드 100000개 생성: 300~600 ms   (힙은 몇백 MB 안쪽)

$ java ThreadRace.java platform
Exception in thread "main" java.lang.OutOfMemoryError:
  unable to create native thread: possibly out of memory
  or process/resource limits reached

가상 스레드 10만 개는 잠든 채로도 힙 안에서 얌전히 버틴다. 스택이 힙에서 필요한 만큼만 자라서다. 플랫폼 스레드는 완주조차 못 한다. 스레드마다 커널 자원을 쓰다 보니 수만 개 언저리에서 OS 한계에 먼저 부딪힌다.

이 글의 수치는 전부 내 환경(리눅스 x64, JDK 21) 기준 대표값이다. 머신마다 다르니 절대값보다 자릿수 차이를 보면 된다. 플랫폼 쪽이 죽는 지점은 ulimit -u 값에 따라 달라진다.

코드로는 거의 안 바뀐다

놀라운 건, 쓰는 법이 기존 스레드와 거의 같다는 점이다. 콜백 지옥도 새 문법도 없다. “만드는 방법”만 바꾸면 된다.

// 가상 스레드 하나
Thread.startVirtualThread(() -> System.out.println("가벼운 스레드"));

// 작업마다 가상 스레드를 주는 실행기 (권장)
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    for (int i = 0; i < 10_000; i++) {
        executor.submit(() -> {
            Thread.sleep(1000);   // I/O 대기를 흉내
            return null;
        });
    }
}   // 만 개의 '스레드'가 아무렇지 않게 돈다

필요한 import는 java.util.concurrent.Executors 하나뿐이고, 문법도 스레드 풀 쓰던 그 문법이다. Thread.sleep이 던지는 예외만 처리해 주면(람다를 Callable로 받게 return null을 넣은 이유다) 그대로 컴파일된다. 진짜인지 아래에서 시간을 재며 확인한다.

직접 해보기 — 만 개의 스레드 띄워 보기

“스레드는 비싸다”가 어떻게 뒤집히는지 직접 본다.

  1. 가상 스레드 실행기로 1만 개 작업을 던지고, 각 작업은 1초 sleep한다.
  2. 전체가 몇 초 만에 끝나는지 잰다(대략 1초대).
  3. 같은 걸 Executors.newFixedThreadPool(200)으로 바꿔 시간을 비교한다.
// TenThousand.java — 실행: java TenThousand.java (풀 비교: java TenThousand.java pool)
import java.util.concurrent.Executors;

public class TenThousand {
    public static void main(String[] args) throws Exception {
        boolean pool = args.length > 0 && args[0].equals("pool");
        long t = System.currentTimeMillis();
        try (var ex = pool ? Executors.newFixedThreadPool(200)
                           : Executors.newVirtualThreadPerTaskExecutor()) {
            for (int i = 0; i < 10_000; i++) {
                ex.submit(() -> { Thread.sleep(1000); return null; });
            }
        }   // try-with-resources가 모든 작업 완료를 기다림
        System.out.println("걸린 시간(ms): " + (System.currentTimeMillis() - t));
    }
}

실행해 보면 이렇게 나온다.

$ java TenThousand.java
걸린 시간(ms): 1000~1200

$ java TenThousand.java pool      # 플랫폼 스레드 200개 풀
걸린 시간(ms): 약 50000

차이의 이유는 산수다. 풀 쪽은 1초짜리 작업 1만 개를 스레드 200개가 나눠 드니 50바퀴, 대략 50초가 된다. 가상 스레드 쪽은 1만 개가 전부 동시에 잠들었다 깨니, 전체 시간은 1초에 생성·정리 오버헤드 몇백 ms가 붙은 값이다.

여기서 조금씩 바꿔 보자.

  • 숫자를 100_000으로 올려도 메모리가 버티는지 본다. 내 환경에선 여전히 1초대에 끝났다.
  • Thread.ofVirtual().name("vt-", 0).factory()로 스레드 팩토리를 만들어 이름을 붙여 본다. Executors.newThreadPerTaskExecutor(팩토리)에 넘기면 된다.

한 걸음 더 — Thread.currentThread()를 찍어, 가상 스레드가 어떤 캐리어 위에서 도는지 관찰해 보자. 내 환경에선 VirtualThread[#21]/runnable@ForkJoinPool-1-worker-1처럼 찍히는데, 끝의 worker-1이 캐리어다. 캐리어는 기본적으로 CPU 코어 수만큼의 ForkJoinPool 워커이고, 여러 번 찍으면 워커 번호가 바뀌는 것도 보인다.

✅ 확인 — 플랫폼 스레드와 가상 스레드의 차이(무엇이 관리하고, 대기 때 어떻게 되는지)를 설명할 수 있으면 통과다.

블로킹이 더는 죄가 아니다

핵심 전환은 여기다. 예전엔 블로킹 I/O(DB 응답 대기 등)가 값비싼 커널 스레드를 붙잡고 있어서, 이를 피하려 비동기·리액티브로 복잡하게 짰다. 가상 스레드는 블로킹되는 순간 JVM이 그 스레드를 캐리어에서 떼어내(unmount) 다른 일을 시킨다. 덕분에 ‘요청 하나에 스레드 하나(thread-per-request)’라는 단순한 옛 모델을 그대로 쓰면서도 수십만 동시성이 난다 — 비동기의 복잡함 없이.

코드로는 이렇게

// 하나 띄우기
Thread.startVirtualThread(() -> handle(req));

// 작업마다 새 가상 스레드 (풀이 아니다!)
try (var exec = Executors.newVirtualThreadPerTaskExecutor()) {
    for (var task : tasks) exec.submit(task);
}

실전 시나리오 — 외부 API 20번을 병렬로

뼈대만 보면 와닿지 않으니 현실 시나리오 하나. 외부 API를 20번 호출해 결과를 모으는, 게이트웨이나 배치에서 흔한 일이다. 응답이 각각 1초쯤 걸린다 치면 순차로는 20초. 가상 스레드로 뿌리면 어떻게 되나.

// FanOut.java — 실행: java FanOut.java
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.Collections;
import java.util.concurrent.Executors;

public class FanOut {
    static final HttpClient client = HttpClient.newHttpClient();

    public static void main(String[] args) throws Exception {
        // 1초 걸리는 테스트 API 20건
        var urls = Collections.nCopies(20, "https://httpbin.org/delay/1");

        long t = System.currentTimeMillis();
        try (var exec = Executors.newVirtualThreadPerTaskExecutor()) {
            var futures = urls.stream()
                    .map(u -> exec.submit(() -> fetch(u)))
                    .toList();
            for (var f : futures) f.get();   // 전부 끝날 때까지
        }
        System.out.println("걸린 시간(ms): " + (System.currentTimeMillis() - t));
    }

    static int fetch(String url) throws Exception {
        var req = HttpRequest.newBuilder(URI.create(url)).build();
        var res = client.send(req, HttpResponse.BodyHandlers.ofString());  // 블로킹!
        return res.statusCode();
    }
}
걸린 시간(ms): 1200~2000   (순차로 돌리면 20초+)

20건이 동시에 나가고, 각 스레드가 send()에서 블로킹하는 동안 JVM이 캐리어에서 내려놓는다. 그래서 전체 시간이 합계가 아니라 가장 느린 한 건에 수렴한다. 포인트는 코드 어디에도 콜백이나 리액티브 체인이 없다는 것. 평범한 블로킹 코드를 가상 스레드에 하나씩 태웠을 뿐이다.

스프링 부트 3.2 이상이면 spring.threads.virtual.enabled=true 한 줄로 톰캣 요청 처리가 가상 스레드를 탄다. 컨트롤러 코드는 그대로 두고 thread-per-request 모델의 수용량만 올라간다.

언제 이득인가 — I/O 바운드

가상 스레드가 빛나는 건 I/O 대기가 많은 작업이다 — 웹 서버, API 게이트웨이, DB 호출이 잦은 서비스. 반대로 CPU만 빡세게 쓰는 계산은 가상 스레드로 이득이 없다. 어차피 코어 수가 한계이기 때문이다. 판단 기준은 단순하다: ‘이 작업은 기다리는 시간이 많은가‘.

주의점 — 풀링하지 마라, 그리고 pinning

두 가지 함정이 있다. ① 가상 스레드는 싸니 풀링하지 않는다 — 작업마다 새로 만든다(위의 newVirtualThreadPerTaskExecutor). ② synchronized 블록 안에서 블로킹하면 캐리어에 고정(pinning)돼 이점이 준다 → 가상 스레드가 많은 코드에선 ReentrantLock을 권한다(자바 21 기준이며, 이 pinning 문제는 이후 JDK에서 개선됐다). 이 함정들은 다음 편(실전)에서 깊이 다룬다.

개념을 잡았으니, 다음 편은 현실이다. 가상 스레드를 언제 쓰고 언제 조심해야 하는지 — 특히 성능을 갉아먹는 pinning 함정을 다룬다.

자주 묻는 질문 (FAQ)

가상 스레드가 기존 스레드와 뭐가 다른가요?

OS 스레드와 1:1인 플랫폼 스레드와 달리 JVM이 관리하는 경량 스레드입니다. 수만 개를 띄워도 가볍습니다.

코드를 많이 바꿔야 하나요?

거의 안 바꿉니다. 스레드를 ‘만드는 방법’만 바꾸면 되고 기존 블로킹 코드를 그대로 씁니다.

Project Loom과 가상 스레드는 무슨 관계인가요?

Project Loom은 자바에 경량 동시성을 넣으려고 오래 진행된 OpenJDK 프로젝트 이름이고, 가상 스레드는 그 첫 정식 결과물입니다. Java 21에서 JEP 444로 표준 기능이 됐습니다.

가상 스레드는 몇 개까지 만들 수 있나요?

힙 메모리가 허용하는 한 수십만에서 수백만 개까지 가능합니다. 스택이 힙에서 필요한 만큼만 자라서, 플랫폼 스레드처럼 개수 제한에 일찍 부딪히지 않습니다.

코멘트

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다