JAVA

Java Virtual Thread — 경량 스레드의 시대 (Project Loom 실무 가이드)

IT Lab 2026. 2. 28. 10:00

Java 21 Virtual Thread(Project Loom)로 동시성을 쉽게 확장하는 방법과 플랫폼 스레드와의 차이, 실무 적용 시 주의점을 정리합니다.

도입 (문제 상황)

동시 요청이 조금만 늘어도 스레드 풀이 꽉 차서 지연이 길어지거나, “스레드 수를 올리면 메모리부터 터진다” 같은 상황을 겪으실 때가 있어요. 반대로 비동기(CompletableFuture, 리액티브)로 바꾸자니 코드가 복잡해지고 디버깅이 어려워지는 경우도 많습니다. 이런 딜레마를 줄이기 위해 나온 해답이 Virtual Thread(가상 스레드) 입니다.

핵심 개념 (Java Virtual Thread와 Project Loom이 중요한 이유)

Project Loom은 “블로킹 코드를 그대로 두면서도 대규모 동시성”을 목표로 한 프로젝트이고, Java 21에서 Virtual Thread가 정식 기능으로 들어왔습니다. 핵심은 간단해요.

  • 플랫폼 스레드(Platform Thread): OS 스레드와 1:1로 매핑되는 기존 스레드
  • 가상 스레드(Virtual Thread): JVM이 스케줄링하는 매우 가벼운 스레드(많이 만들어도 부담이 훨씬 적음)

가상 스레드는 실행 중에 실제 OS 스레드(캐리어 스레드) 위에서 돌아가다가, I/O 같은 블로킹 지점에서 멈추면 캐리어 스레드를 점유하지 않고 “언마운트(unmount)” 됩니다. 그래서 같은 하드웨어에서도 “대기(블로킹)가 많은” 서버 작업을 훨씬 더 많은 동시성으로 처리할 수 있어요.
비유하자면, 플랫폼 스레드는 “좌석이 고정된 버스”에 가깝고, 가상 스레드는 “필요할 때만 잠깐 좌석을 쓰고 내리는 승객”에 가깝습니다. 승객이 많아도 버스가 덜 막히는 구조죠.

Java Virtual Thread vs 기존 스레드 차이 한눈에 보기

아래 표는 실무에서 자주 비교하는 포인트만 추렸습니다.

항목 플랫폼 스레드 가상 스레드
스케줄링 주체 OS JVM
생성 비용/메모리 상대적으로 큼 매우 작음(대량 생성 가능)
블로킹 I/O 시 OS 스레드 점유 캐리어 스레드 반환(언마운트)
적합한 작업 CPU 바운드, 긴 연산 I/O 바운드, 요청-응답 서버
디버깅/스택트레이스 익숙함 기본적으로 유사(다만 프레임/툴링 차이 존재 가능)
도입 난이도 기존 방식 “블로킹 코드를 유지”하면서도 효과 큼

동작 흐름(개념) 다이어그램

flowchart LR
  A["Virtual Thread"] --> B["Carrier Thread (OS)"]
  B --> C["Run Java code"]
  C --> D["Blocking I/O"]
  D --> E["Unmount from carrier"]
  E --> F["Carrier runs other tasks"]
  D --> G["I/O ready"]
  G --> H["Mount again"]
  H --> C

가상 스레드는 블로킹 구간에서 캐리어 스레드를 점유하지 않도록 분리(언마운트/마운트)됩니다.

 

Virtual Thread가 블로킹 I/O에서 캐리어 스레드를 반환하고 다시 마운트되는 흐름도

 

코드 예제 (Java 21 Virtual Thread로 서버 작업 흉내 내기)

아래 코드는 플랫폼 스레드 풀가상 스레드로 “블로킹 작업(예: 네트워크 대기)”을 많이 돌렸을 때의 실행 시간을 비교합니다. 복붙해서 Java 21+에서 바로 실행 가능해요.

import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.*;

public class VirtualThreadDemo {

    // 블로킹 I/O를 흉내 내기 위한 작업 (Thread.sleep은 블로킹을 대표하는 간단한 예시)
    static Callable<String> blockingTask(int id, int millis) {
        return () -> {
            Thread.sleep(millis);
            return "task-" + id;
        };
    }

    static Duration run(String name, ExecutorService executor, int tasks, int blockMillis) throws Exception {
        Instant start = Instant.now();

        List<Future<String>> futures = new ArrayList<>(tasks);
        for (int i = 0; i < tasks; i++) {
            futures.add(executor.submit(blockingTask(i, blockMillis)));
        }

        for (Future<String> f : futures) {
            f.get(); // 결과 대기(블로킹)
        }

        Duration d = Duration.between(start, Instant.now());
        System.out.printf("%s took %d ms%n", name, d.toMillis());
        return d;
    }

    public static void main(String[] args) throws Exception {
        int tasks = 10_000;
        int blockMillis = 50;

        // 1) 기존 플랫폼 스레드 풀 (스레드 수 제한이 본질적으로 병목이 되기 쉬움)
        try (ExecutorService platform = Executors.newFixedThreadPool(200)) {
            run("Platform threads (fixed pool=200)", platform, tasks, blockMillis);
        }

        // 2) Virtual Thread per task executor (작업마다 가상 스레드 1개)
        try (ExecutorService virtual = Executors.newVirtualThreadPerTaskExecutor()) {
            run("Virtual threads (per task)", virtual, tasks, blockMillis);
        }
    }
}
  • Executors.newVirtualThreadPerTaskExecutor()작업마다 가상 스레드를 하나씩 생성합니다.
  • “요청당 스레드” 모델을 다시 현실적으로 만들어 주는 게 Loom의 큰 가치입니다. (서블릿/HTTP 핸들러처럼 요청-응답 구조에서 특히 체감이 큽니다)

실무 팁 (Virtual Thread 적용 시 주의점)

💡 실무에서는: “무조건 빠르다”가 아니라 “I/O 바운드에 강하다”로 접근해 보세요

  • 가상 스레드는 대기 시간이 많은 작업에서 강점이 큽니다. DB 호출, 외부 API 호출, 파일 I/O처럼 블로킹이 많은 서버는 효과가 잘 납니다.
  • 반대로 CPU를 오래 태우는 작업(대용량 암호화, 이미지 처리, 복잡한 정렬 등)은 가상 스레드를 많이 만든다고 처리량이 늘지 않습니다. 이 경우는 여전히 코어 수 기반의 제한(예: 고정 스레드 풀, 작업 큐, 배치 분리)이 중요합니다.

💡 실무에서는: “핀ning(pinning)”을 만드는 코드를 특히 조심하세요
가상 스레드가 블로킹 시 캐리어 스레드를 반환하려면, JVM이 안전하게 언마운트할 수 있어야 합니다. 그런데 다음 같은 경우는 캐리어 스레드를 계속 붙잡는(pinning) 상황을 만들 수 있어 성능 이점이 줄어들 수 있어요.

  • synchronized 블록/메서드 안에서 오래 블로킹(예: I/O, sleep, 락 경합)
  • 오래 잡고 있는 모니터 락 + 블로킹 호출 조합

대안은 보통 다음 중 하나입니다.

  • 임계 구역을 줄이고, 블로킹 호출을 락 밖으로 빼기
  • ReentrantLockjava.util.concurrent.locks 계열로 설계를 재검토하기
  • “공유 상태” 자체를 줄이는 방향(불변 객체, 메시지 패싱, 격리)으로 구조 변경

핵심 요약: Virtual Thread는 블로킹 코드 스타일을 유지하면서도 대규모 동시성을 가능하게 합니다.
I/O 바운드 서버에서 특히 효과가 크고, CPU 바운드에는 만능이 아닙니다.
synchronized+블로킹 같은 핀ning 유발 패턴을 점검하고 도입하세요.

다음 글: #33 효과적인 로깅 전략