Java의 Virtual Thread와 Go의 Goroutine은 모두 M:N 매핑 모델을 사용하는 경량 스레드입니다. 두 기술 모두 수백만 개의 동시 작업을 효율적으로 처리할 수 있지만, 구현 방식과 철학에는 큰 차이가 있다고 합니다.
전통적인 OS 스레드는 생성 비용이 높고 메모리를 많이 사용하기 때문에, 대규모 동시성을 요구하는 현대 애플리케이션에는 적합하지 않습니다. 이러한 문제를 해결하기 위해 Java는 Virtual Thread를, Go는 Goroutine을 도입했습니다.
이 글에서는 두 기술의 동작 원리를 깊이 있게 분석하고, 실제 성능 테스트 결과를 통해 비교해보겠습니다.
Virtual Thread는 JDK 21에서 정식 도입된 경량 스레드로, 기존 Java 코드와의 완벽한 호환성을 제공합니다.
Virtual Thread는 전통적인 Platform Thread와 달리 M:N 매핑 모델을 사용합니다. 즉, 많은 수의 Virtual Thread가 소수의 OS Thread(Carrier Thread)에서 실행됩니다.

Context Switch
컨텍스트 스위칭은 CPU를 한 스레드에서 다른 스레드로 전환하기 위해, 현재 실행 중인 스레드의 상태(Context)를 저장하고, 다음 실행할 스레드의 저장된 상태를 복원하여 해당 스레드가 CPU 코어의 점유권을 획득하고 실행을 재개하는 과정입니다.
즉, CPU 유휴 상태를 만들지 않기 위해, 현재 CPU 점유를 유지할 수 없는 스레드 대신, 즉시 실행 가능한 다른 스레드가 CPU를 점유하도록 전환하는 것입니다.
기존 Java platform Thread의 경우 해당 Context Switch는 OS단에서만 일어납니다.
Java platform Thread의 한계
요청량이 급격하게 증가하는 경우 서버 환경에서 더 많은 Thread를 요구하게 됩니다.

용어
JNI (Java Native Interface)
OS 스케줄러
JVM ForkJoinPool 스케줄러
기존 OS 스레드와 1:1 매핑된 Java Platform Thread와는 달리, Virtual Thread는 JVM이 직접 관리하는 가벼운 경량 쓰레드입니다.
Platform Thread와 N:M으로 매핑된 Virtual Thread를 만들고, 이를 JVM 내부의 ForkJoinPool이 할당 및 스케줄링하여 작동시킵니다.
핵심은, 가상 스레드가 I/O 등의 작업으로 블록될 때 OS 커널 스레드를 블록시키지 않는다는 점입니다. 대신, JVM 스케줄러가 해당 가상 스레드를 캐리어 스레드에서 즉시 분리(Unmount)하고, 그 캐리어 스레드는 다른 가상 스레드를 실행할 수 있게 됩니다.
즉, 비용이 높은 OS단의 컨텍스트 스위치가 일어나는 대신, JVM 내부에서 일어나는 매우 저렴한 스케줄링(마운트/언마운트)을 통해 높은 동시성을 달성합니다.
Virtual Thread의 목표는 I/O, sleep 등 Blocking 상황이 발생해도 OS 커널 스레드를 Block시키지 않고 CPU 효율을 극대화 하는 것입니다.

Carrier Thread
ForkJoinPool
Continuation
Park()
Unpark()
Mount()
ForkJoinPool과 WorkStealQueue를 통해 Carrier Thread들이 1초도 쉬지 않고 일하도록 합니다.
java.lang 패키지의 BaseVirtualThread 추상클래스

기존 Thread 클래스를 상속받아 설계되었기 때문에, 기존 Java 코드 수정 없이 Virtual Thread를 도입할 수 있습니다. TaskExecutor만 교체하면 애플리케이션 전체에 적용 가능합니다.
주의사항
Virtual Thread는 I/O Bound 작업에 최적화되어 있습니다. CPU Bound 작업(인-디코딩, 복잡한 계산 등)은 I/O 대기가 발생하지 않아 Carrier Thread를 놓아주지 않고 독점하게 됩니다.
이로 인해, ForkJoinPool의 핵심 이점(Blocking시 Thread반납)이 사라지고, JVM 스케쥴링 오버헤드만 더해져서 기존의 Java Platform Thread보다 성능이 떨어질 수 있습니다.
Goroutine은 Go 언어의 핵심 기능으로, 언어 차원에서 동시성을 지원합니다. go 키워드 하나로 수백만 개의 경량 스레드를 생성할 수 있습니다.
Goroutine 역시 M:N 매핑 모델을 사용하며, 2KB의 매우 작은 스택으로 시작합니다.

Go의 Goroutine은 GMP 모델이라는 독특한 스케줄링 아키텍처를 사용합니다.
G (Goroutine)
M (Machine)
P (Processor)
Descheduling
Rescheduling
Work Stealing
| 특징 | Go Goroutine (GMP Model) | Java Virtual Thread (ForkJoinPool) |
|---|---|---|
| 분산 큐 (Local) | Local Run Queue (LRQ): Processor (P)마다 존재 | Work Steal Queue: Carrier Thread마다 존재 |
| 중앙 큐 (Global) | Global Run Queue (GRQ): 새로운/디스케줄된 Goroutine의 1차 대기 장소 | 명시적인 글로벌 큐 없음 |
| 부하 분산 방식 | Work Stealing + GRQ를 비상 저장소로 활용 | 전적으로 Work Stealing에 의존 |
Go의 Global Run Queue (GRQ) 역할
- 오버플로우 처리: 너무 많은 Goroutine이 한 번에 생성되어 모든 LRQ에 자리가 없을 때 GRQ에 저장
- 재스케줄링: I/O 작업에서 돌아온 Goroutine을 특정 P의 LRQ에 넣기 어려울 때 GRQ로 이동
- 우선 탐색: P는 자신의 LRQ가 비었을 때, Work Stealing 전에 먼저 GRQ를 확인
Java Virtual Thread의 큐 구조
Virtual Thread는 ForkJoinPool 내에서만 작동하도록 설계되었기 때문에, Go와 같은 별도의 글로벌 큐를 필요로 하지 않습니다.
이것이 두 기술의 가장 근본적인 차이점입니다.
핵심 포인트
JVM은 스택 메모리가 OS 스택 영역에 존재하기 때문에 제약이 많지만, Go 런타임은 스택 메모리의 소유권을 OS로부터 완전히 가져와 유저 힙에 두었기 때문에 자유롭고 단순한 동시성 모델 구축이 가능합니다.
Java Virtual Thread의 스택 관리
- Carrier Thread의 OS 커널 스택을 빌려서 사용
- 프로세스는 힙 영역은 스레드끼리 공유하지만 스택 영역은 스레드 독립적
- 기존 Carrier Thread의 스택과 섞여 있기 때문에 분리해서 캡처해야 함
- 이를 위해 Continuation 객체가 스택 상태를 캡처하여 힙에 저장
- Blocking 시 스택을 Carrier Thread에 반납하고 힙으로 분리
Go Goroutine의 스택 관리
- Goroutine은 2KB의 작은 스택으로 시작하며, 필요시 Go 런타임이 자동으로 확장
- 스택 메모리가 유저 레벨 힙에 할당됨
- Goroutine 객체 자체가 스택 메모리를 직접 소유하고 관리
- Blocking 상태에서도 별도로 상태 정보를 옮길 필요 없음
- Java처럼 OS 스택에서 분리하여 별도 객체로 힙에 캡처할 필요가 없음
| 특징 | Java (Virtual Thread) | Go (Goroutine) |
|---|---|---|
| 스택 메모리 위치 | Carrier Thread의 OS 커널 스택을 빌려 씀 | 유저 영역의 힙(Heap)에 할당됨 |
| 소유권 | OS 커널 소유 (JVM의 제약) | Go 런타임 소유 (Go의 자유) |
| 결과 | 제약이 많아 동적 확장이 어렵고, I/O 시 상태를 Continuation 객체로 캡처해야 함 | 자유로워 동적 확장/축소가 가능하고, G 객체가 스택을 보존하므로 단순한 동시성 모델 구축 가능 |
Virtual Thread는 스택 데이터를 Continuation 객체로 힙 영역에 보관하면서 OS 스레드가 필요 시 OS 스레드의 스택 영역에 복사하여 사용
Goroutine은 스택 데이터가 물리적으로 힙 영역에 존재하기 때문에 OS 스레드의 스택 영역은 스케줄 관리용으로만 사용되고 필요 시 힙 영역의 Goroutine 스택으로 전환하여 바로 사용
| 비교 항목 | JVM 가상 스레드 (ForkJoinPool) | Go 고루틴 (G-M-P) |
|---|---|---|
| 작업자 (OS 스레드) | Carrier Thread | M (Machine) |
| 작업 | Virtual Thread | G (Goroutine) |
| 로컬 큐 소유자 | Carrier Thread가 소유 | P (Processor)가 소유 |
| OS 스레드가 블로킹될 때 | OS 스레드와 큐가 함께 멈춤 | OS 스레드만 멈추고 P(와 큐)는 다른 OS 스레드에게 이전됨 |
중간 계층인 Processor는 M(OS 스레드)이 멈추더라도 스케줄링(G 실행)은 멈추지 않도록 하며, 이는 Go가 OS 스레드 블로킹에 매우 강력하게 대처할 수 있게 하는 핵심 설계입니다.
전체 스레드 모델 비교
| 특징 | Java Platform Thread | Go Goroutine | Java Virtual Thread |
|---|---|---|---|
| 매핑 모델 | 1:1 (OS 스레드) | M:N (Go 런타임) | M:N (JVM) |
| 스케줄링 주체 | OS 커널 (Kernel) | Go 런타임 (User-space) | JVM 런타임 (User-space) |
| 스택 메모리 | 큼 (고정 크기, 예: 1MB+) | 매우 작음 (시작 시 2KB) | 매우 작음 (힙 메모리 활용) |
| 스택 관리 | OS 관리 (고정) | 동적 확장 스택 (필요시 복사/확장) | 힙 기반 스택 (Continuation, 청크 단위) |
| 생성/관리 비용 | 높음 | 매우 낮음 | 매우 낮음 |
| 컨텍스트 스위칭 | 비쌈 (커널 모드 전환 필요) | 매우 저렴 (함수 호출 수준) | 매우 저렴 (메모리 포인터 교체) |
| 생성 가능 개수 | 적음 (수천 개) | 많음 (수백만 개) | 많음 (수백만 개) |
| 블로킹 처리 | 스레드 전체가 중단됨 | Goroutine만 중단 (OS 스레드 반환) | Virtual Thread만 중단 (OS 스레드 반환) |
| 핵심 특징 | OS 자원 직접 활용 | 언어/컴파일러 차원 지원 | 기존 Java 코드와 완벽 호환 |
| 주 사용처 | CPU 집약적 작업, 레거시 | I/O 집약적, 대규모 동시성 | I/O 집약적, 기존 Java 앱 동시성 개선 |
| 항목 | Java (Virtual Thread) | Go (Goroutine) |
|---|---|---|
| 핵심 시너지 | 기존 생태계와의 완벽한 호환성 | 언어 내장 기능(채널, 도구)과의 유기성 |
| 주요 대상 | 기존 Java/Spring 기반 시스템 | 신규 마이크로서비스, 인프라 도구 |
| 배포 | JVM 필요 (무거움) | 단일 바이너리 (매우 가벼움) |
| 동시성 패턴 | 메모리 공유 (Lock, synchronized) | 메시지 전달 (Channel, select) |
실제 부하 테스트를 통해 세 가지 스레드 모델을 비교해보겠습니다.
로컬 환경에서 Java platform Thread, Java Virtual Thread, Go Goroutine을 활용한 코드를 하단 공통 엔드포인트와 기능을 포함하도록 작성한 후 포트를 다르게하여 실행하고 테스트했습니다.
공통 엔드포인트
| 엔드포인트 | 역할 | 핵심 로직 | 목적 |
|---|---|---|---|
/api/heavy?duration=N | 블로킹 작업 모방 | Thread.sleep(500)ms | 스레드/Goroutine을 장시간 점유하여 스레드 풀 고갈 여부와 I/O 대기 효율성 테스트 |
/api/light | 빠른 로직 처리 모방 | 지연 없이 즉시 응답 | 서버의 기본 처리 속도와 CPU 활용 능력 테스트 |
테스트 시나리오
부하 테스트는 k6를 사용하여 다음 단계로 진행했습니다:
측정 지표
package com.test;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.web.bind.annotation.*;
@SpringBootApplication
@RestController
public class ThreadServerApplication {
public static void main(String[] args) {
SpringApplication.run(ThreadServerApplication.class, args);
}
@GetMapping("/api/heavy")
public Response heavyTask(@RequestParam(defaultValue = "100") int duration) {
try {
Thread.sleep(duration);
return new Response("Platform Thread", duration, Thread.currentThread().getName());
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException(e);
}
}
@GetMapping("/api/light")
public Response lightTask() {
return new Response("Platform Thread", 0, Thread.currentThread().getName());
}
record Response(String type, int duration, String threadInfo) {}
}
package com.test;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.web.embedded.tomcat.TomcatProtocolHandlerCustomizer;
import org.springframework.context.annotation.Bean;
import org.springframework.web.bind.annotation.*;
import java.util.concurrent.*;
@SpringBootApplication
@RestController
public class VirtualThreadServerApplication {
public static void main(String[] args) {
[SpringApplication.run](http://SpringApplication.run)(VirtualThreadServerApplication.class, args);
}
@Bean
public TomcatProtocolHandlerCustomizer<?> protocolHandlerVirtualThreadExecutorCustomizer() {
return protocolHandler -> {
protocolHandler.setExecutor(Executors.newVirtualThreadPerTaskExecutor());
};
}
@GetMapping("/api/heavy")
public Response heavyTask(@RequestParam(defaultValue = "100") int duration) {
try {
Thread.sleep(duration);
return new Response("Virtual Thread", duration, Thread.currentThread().toString());
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException(e);
}
}
@GetMapping("/api/light")
public Response lightTask() {
return new Response("Virtual Thread", 0, Thread.currentThread().toString());
}
record Response(String type, int duration, String threadInfo) {}
}
package main
import (
"encoding/json"
"fmt"
"log"
"net/http"
"runtime"
"strconv"
"time"
)
type Response struct {
Type string `json:"type"`
Duration int `json:"duration"`
ThreadInfo string `json:"threadInfo"`
}
func heavyHandler(w http.ResponseWriter, r *http.Request) {
durationStr := r.URL.Query().Get("duration")
duration := 100
if durationStr != "" {
if d, err := strconv.Atoi(durationStr); err == nil {
duration = d
}
}
time.Sleep(time.Duration(duration) * time.Millisecond)
resp := Response{
Type: "Goroutine",
Duration: duration,
ThreadInfo: fmt.Sprintf("NumGoroutine: %d, NumCPU: %d", runtime.NumGoroutine(), runtime.NumCPU()),
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
}
func lightHandler(w http.ResponseWriter, r *http.Request) {
resp := Response{
Type: "Goroutine",
Duration: 0,
ThreadInfo: fmt.Sprintf("NumGoroutine: %d, NumCPU: %d", runtime.NumGoroutine(), runtime.NumCPU()),
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
}
func healthHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]string{"status": "UP"})
}
func main() {
runtime.GOMAXPROCS(runtime.NumCPU())
http.HandleFunc("/api/heavy", heavyHandler)
http.HandleFunc("/api/light", lightHandler)
http.HandleFunc("/health", healthHandler)
log.Println("Go Goroutine server starting on :8083")
log.Fatal(http.ListenAndServe(":8083", nil))
}
====================================================================================================
성능 비교 리포트
====================================================================================================
테스트 구성:
- 총 소요시간: 540초 (9분)
- 부하 증가: 100 VUs (1분) -> 300 VUs (2분) -> 500 VUs (3분) -> 300 VUs (2분) -> 0 VUs (1분)
- 작업 부하: Heavy (500ms 대기) + Light 요청 혼합
- 반복 간 대기: 100ms
====================================================================================================
[1] 처리량 성능
----------------------------------------------------------------------------------------------------
서버 총 요청수 RPS 반복횟수 상대 성능
----------------------------------------------------------------------------------------------------
Java Platform Thread 352,720 653.05 176,360 68.1%
Java Virtual Thread 514,192 951.56 257,096 99.2%
Go Goroutine 518,036 959.28 259,018 100.0%
[2] 응답 시간 (ms)
----------------------------------------------------------------------------------------------------
서버 최소 평균 중간값 P90 P95 최대
----------------------------------------------------------------------------------------------------
Java Platform Thread 0.11 534.05 506.56 1004.72 1085.32 1244.86
Java Virtual Thread 0.09 254.27 288.11 508.00 510.23 569.13
Go Goroutine 0.05 251.11 261.58 501.67 502.42 537.55
[3] 안정성
----------------------------------------------------------------------------------------------------
서버 에러율 수신 데이터 송신 데이터
----------------------------------------------------------------------------------------------------
Java Platform Thread 0% 68.15 MB 28.76 MB
Java Virtual Thread 0% 126.07 MB 41.93 MB
Go Goroutine 0% 93.24 MB 42.24 MB
[4] 성능 순위
----------------------------------------------------------------------------------------------------
[4-1] 처리량 (RPS):
1위 Go Goroutine: 959.28 req/s
2위 Java Virtual Thread: 951.56 req/s
3위 Java Platform Thread: 653.05 req/s
[4-2] 평균 응답시간:
1위 Go Goroutine: 251.11 ms
2위 Java Virtual Thread: 254.27 ms
3위 Java Platform Thread: 534.05 ms
[4-3] P95 응답시간:
1위 Go Goroutine: 502.42 ms
2위 Java Virtual Thread: 510.23 ms
3위 Java Platform Thread: 1085.32 ms
[4-4] 안정성 (최대 응답시간):
1위 Go Goroutine: 537.55 ms
2위 Java Virtual Thread: 569.13 ms
3위 Java Platform Thread: 1244.86 ms
[5] 주요 분석
----------------------------------------------------------------------------------------------------
* Go Goroutine이(가) 가장 높은 처리량을 달성했습니다: 959.28 req/s
(가장 느린 서버 대비 46.9% 빠름)
* Go Goroutine이(가) 가장 낮은 평균 응답시간을 기록했습니다: 251.11 ms
(가장 느린 서버 대비 112.7% 우수)
* Go Goroutine은 Java Virtual Thread 대비 처리량이 0.8% 빠릅니다
* Java Virtual Thread는 Go 대비 평균 응답시간이 1.3% 높습니다
* Java Virtual Thread는 Platform Thread보다 45.7% 빠릅니다
* 모든 서버가 부하 상황에서 0% 에러율을 유지했습니다
====================================================================================================
주요 결과 요약
Go Goroutine과 Java Virtual Thread는 거의 동등한 성능을 보였습니다 (차이 1% 미만)
두 경량 스레드 모델 모두 Platform Thread보다 약 45% 빠른 처리량을 달성
모든 서버가 500명의 동시 사용자 부하에서도 0% 에러율 유지
Virtual Thread는 Platform Thread 대비 응답 시간을 절반 수준으로 단축
1. 처리량 (RPS)
2. 평균 응답 시간
3. P95 응답 시간
핵심 인사이트
Java Spring으로 프로젝트를 진행했었을 때 성능 개선을 위해서 이것저것 시도해봤었는데 근본적으로 OS쓰레드를 가상쓰레드로 바꾸는 작업을 했으면 성능이 바로 좋아지지 않았을까..
Go 런타임의 스택 메모리를 힙영역에 두고 OS 제약 없이 관리하는 방식이 넘 신기하다. 역시 언어개발자는 신이야
https://techblog.woowahan.com/15398/
https://jangbageum.tistory.com/100
https://go.dev/blog/waza-talk