Virtual Thread vs Goroutine : 경량 스레드의 비교

dodo·2025년 11월 14일

Go

목록 보기
1/1

Java의 Virtual Thread와 Go의 Goroutine은 모두 M:N 매핑 모델을 사용하는 경량 스레드입니다. 두 기술 모두 수백만 개의 동시 작업을 효율적으로 처리할 수 있지만, 구현 방식과 철학에는 큰 차이가 있다고 합니다.


들어가며

전통적인 OS 스레드는 생성 비용이 높고 메모리를 많이 사용하기 때문에, 대규모 동시성을 요구하는 현대 애플리케이션에는 적합하지 않습니다. 이러한 문제를 해결하기 위해 Java는 Virtual Thread를, Go는 Goroutine을 도입했습니다.

이 글에서는 두 기술의 동작 원리를 깊이 있게 분석하고, 실제 성능 테스트 결과를 통해 비교해보겠습니다.


Java Virtual Thread

개요

Virtual Thread는 JDK 21에서 정식 도입된 경량 스레드로, 기존 Java 코드와의 완벽한 호환성을 제공합니다.

Virtual Thread는 전통적인 Platform Thread와 달리 M:N 매핑 모델을 사용합니다. 즉, 많은 수의 Virtual Thread가 소수의 OS Thread(Carrier Thread)에서 실행됩니다.

기존 Java Platform Thread

  • OS단의 커널 스레드와 1:1 매핑
  • Java의 유저 스레드 생성 → JNI(Java Native Interface)를 통해 커널 영역을 호출 → OS가 커널 스레드를 생성 → 매핑

Context Switch

컨텍스트 스위칭은 CPU를 한 스레드에서 다른 스레드로 전환하기 위해, 현재 실행 중인 스레드의 상태(Context)를 저장하고, 다음 실행할 스레드의 저장된 상태를 복원하여 해당 스레드가 CPU 코어의 점유권을 획득하고 실행을 재개하는 과정입니다.

즉, CPU 유휴 상태를 만들지 않기 위해, 현재 CPU 점유를 유지할 수 없는 스레드 대신, 즉시 실행 가능한 다른 스레드가 CPU를 점유하도록 전환하는 것입니다.

기존 Java platform Thread의 경우 해당 Context Switch는 OS단에서만 일어납니다.

Java platform Thread의 한계

요청량이 급격하게 증가하는 경우 서버 환경에서 더 많은 Thread를 요구하게 됩니다.

  1. 커널 스레드는 각각 독립적인 Stack을 위해 상당한 양의 RAM을 차지하기 때문에 개수의 한계가 있습니다.
  2. 모든 유저 스레드가 비싼 커널 스레드를 반드시 1:1로 점유해야 하므로, OS의 커널 스레드 생성 한계가 곧바로 유저 스레드 생성 한계가 됩니다.
  3. 스레드의 수가 많아질수록, 비용이 높은 OS단의 Context Switch의 횟수가 많아집니다.

Virtual Thread

용어

JNI (Java Native Interface)

  • Java 코드가 네이티브 코드와 상호작용할 수 있게 해주는 인터페이스
  • JVM → OS: Java 스레드가 OS 커널 작업이 필요할 때 JNI를 통해 시스템 콜로 변환

OS 스케줄러

  • 커널 내부의 실제 스레드를 담당
  • 모든 시스템 프로세스와 스레드에 공평하게 CPU 시간을 배분
  • 병렬성(Parallelism) 제공

JVM ForkJoinPool 스케줄러

  • JVM 내부의 경량 스레드를 담당
  • Carrier Thread(실제 스레드)를 워커로 사용하여 Virtual Thread를 스케줄링
  • 동시성(Concurrency) 제공

기존 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

  • 실제로 작업을 수행하는 OS 레벨의 Platform Thread
  • 각 Carrier Thread는 자체 Work Queue를 보유

ForkJoinPool

  • Virtual Thread의 스케줄러 역할
  • Carrier Thread 풀을 관리하고 Virtual Thread를 적절히 배분

Continuation

  • Blocking된 Virtual Thread의 실행 상태를 저장하는 객체
  • Virtual Thread의 실제 작업 내용(Runnable)과 스택 상태를 포함

Park()

  • Virtual Thread가 I/O 대기, Lock 등으로 Blocking될 때 호출
  • 현재 실행 상태를 Continuation 객체에 저장
  • Carrier Thread에서 언마운트되어 힙 영역으로 이동

Unpark()

  • Blocking 작업이 완료되면 호출
  • 힙 영역의 Continuation 객체를 Work Steal Queue로 푸시
  • Carrier Thread가 다시 처리할 수 있는 상태로 전환

Mount()

  • 논리적 작업 단위(Virtual Thread)를 물리적 실행 단위(Carrier Thread)에 연결

ForkJoinPool과 WorkStealQueue를 통해 Carrier Thread들이 1초도 쉬지 않고 일하도록 합니다.

  1. 실행 - Mount() : Carrier thread는 mount()를 통해 큐에서 Virtual Thread를 가져와서 CPU 코어에 mount()하여 실행
  2. 블로킹 - Park() : Blocking 상황에서는 OS 스레드를 블록시키는 대신 park()가 호출. 이 때 Virtual Thread의 스택 정보는 continuation 객체로 캡슐화되어 Heap 메모리로 이동. 또한, Unmount한 Carrier Thread는 즉시 자신의 큐에서 작업을 가져오거나 작업을 Steal
  3. 재개 - Unpark() : Virtual thread의 blocking 작업(I/O 등)이 완료되면 JVM은 Heap에 저장된 continuation을 다시 ForkJoinPool의 Work Steal Queue 중 하나로 밀어넣음 → 실행 가능 상태
  4. 효율 극대화 - Work Steal 매커니즘 : Carrier Thread의 유후 상태를 방지하기 위해 자신의 큐가 비어 할 일이 없어지만 다른 Work Steal Queue에서 작업을 훔쳐와서 실행

코드 호환성

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보다 성능이 떨어질 수 있습니다.


Go Goroutine

개요

Goroutine은 Go 언어의 핵심 기능으로, 언어 차원에서 동시성을 지원합니다. go 키워드 하나로 수백만 개의 경량 스레드를 생성할 수 있습니다.

Goroutine 역시 M:N 매핑 모델을 사용하며, 2KB의 매우 작은 스택으로 시작합니다.

GMP 모델

Go의 Goroutine은 GMP 모델이라는 독특한 스케줄링 아키텍처를 사용합니다.

G (Goroutine)

  • Goroutine의 실제 작업 내용과 자체 스택 정보를 보유
  • Virtual Thread의 Continuation과 유사

M (Machine)

  • 실제로 작업을 수행하는 OS 스레드
  • P와 연결되어 P의 큐에 있는 G를 실행
  • Virtual Thread의 Carrier Thread와  유사. BUT, Virtual Thread와 달리 Machine에 큐가 연결되어 있지 않음

P (Processor)

  • M과 G를 연결하는 논리적 프로세서(스케줄러)
  • 각 P는 로컬 실행 큐(LRQ)를 보유
  • P의 개수는 기본적으로 CPU 코어 수와 동일
  • Virtual Thread의 ForkJoinPool 스케줄링 로직과 유사. BUT, Virtual Thread에는 없는 중간 계층

스케줄링 메커니즘

Descheduling

  • Goroutine이 Blocking 상태에 진입할 때 발생
  • M(Machine)에서 즉시 분리되고, M은 다른 G를 가져와 계속 실행
  • Virtual Thread의 Park() 메커니즘과 동일

Rescheduling

  • 대기하던 작업이 완료되면 발생
  • 해당 G는 실행 가능 상태가 되어 P의 로컬 큐 또는 글로벌 큐로 푸시
  • Virtual Thread의 Unpark() 메커니즘과 동일

Work Stealing

  • 특정 P의 로컬 큐가 비면, 연결된 M이 다른 바쁜 P의 로컬 큐에서 G의 절반을 가져옴
  • 효율적인 부하 분산을 통해 CPU 활용도를 최대화
  • Virtual Thread의 Work Stealing 방식과 동일한 목적

핵심 차이점 분석

1. 큐 구조의 차이

특징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) 역할

  1. 오버플로우 처리: 너무 많은 Goroutine이 한 번에 생성되어 모든 LRQ에 자리가 없을 때 GRQ에 저장
  2. 재스케줄링: I/O 작업에서 돌아온 Goroutine을 특정 P의 LRQ에 넣기 어려울 때 GRQ로 이동
  3. 우선 탐색: P는 자신의 LRQ가 비었을 때, Work Stealing 전에 먼저 GRQ를 확인

Java Virtual Thread의 큐 구조

Virtual Thread는 ForkJoinPool 내에서만 작동하도록 설계되었기 때문에, Go와 같은 별도의 글로벌 큐를 필요로 하지 않습니다.

  • 모든 Carrier Thread는 자신만의 Deque 형태의 로컬 Work Steal Queue를 보유
  • 실행 가능 상태가 된 Continuation은 즉시 Work Steal Queue로 푸시
  • 전적으로 Work Stealing 메커니즘을 통해 부하 분산

2. 스택 메모리 관리의 차이

이것이 두 기술의 가장 근본적인 차이점입니다.

핵심 포인트
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 스택에서 분리하여 별도 객체로 힙에 캡처할 필요가 없음

3. Continuation 객체의 필요 여부

특징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 스택으로 전환하여 바로 사용

4. 로컬 큐 소유자

비교 항목JVM 가상 스레드 (ForkJoinPool)Go 고루틴 (G-M-P)
작업자 (OS 스레드)Carrier ThreadM (Machine)
작업Virtual ThreadG (Goroutine)
로컬 큐 소유자Carrier Thread가 소유P (Processor)가 소유
OS 스레드가 블로킹될 때OS 스레드와 큐가 함께 멈춤OS 스레드만 멈추고 P(와 큐)는 다른 OS 스레드에게 이전됨

중간 계층인 Processor는 M(OS 스레드)이 멈추더라도 스케줄링(G 실행)은 멈추지 않도록 하며, 이는 Go가 OS 스레드 블로킹에 매우 강력하게 대처할 수 있게 하는 핵심 설계입니다.


종합 비교표

전체 스레드 모델 비교

특징Java Platform ThreadGo GoroutineJava 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 앱 동시성 개선

Virtual Thread vs Goroutine 비교

항목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를 사용하여 다음 단계로 진행했습니다:

  1. 1분 동안 100명까지 증가
  2. 2분 동안 300명까지 증가
  3. 3분 동안 500명 유지 (피크)
  4. 2분 동안 300명으로 감소
  5. 1분 동안 0명으로 종료
  • 각 VU(가상 사용자)가 heavy(500ms) + light 요청을 동시에 병렬로 보냄
  • 두 요청 모두 ****응답 받을 때까지 대기
  • 응답 받으면 100ms sleep
  • 다시 처음부터 반복

측정 지표

  • RPS (Requests Per Second): 초당 처리량
  • 응답 시간: P50, P95, P99
  • 에러율: 실패한 요청 비율 (실패 기준: 200대가 아닌 응답 or 5s 이상의 응답시간
  • 동시 처리: 최대 동시 사용자

구현 코드

  • Java Platform Thread (8081 포트)
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) {}
}
  • Java Virtual Thread (8082 포트)
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) {}
}
  • Go Goroutine (8083 포트)
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 GoroutineJava Virtual Thread는 거의 동등한 성능을 보였습니다 (차이 1% 미만)
두 경량 스레드 모델 모두 Platform Thread보다 약 45% 빠른 처리량을 달성
모든 서버가 500명의 동시 사용자 부하에서도 0% 에러율 유지
Virtual Thread는 Platform Thread 대비 응답 시간을 절반 수준으로 단축

1. 처리량 (RPS)

  • Go Goroutine: 959.28 req/s (1위)
  • Java Virtual Thread: 951.56 req/s (2위, 0.8% 차이)
  • Java Platform Thread: 653.05 req/s

2. 평균 응답 시간

  • Go Goroutine: 251.11 ms (1위)
  • Java Virtual Thread: 254.27 ms (2위, 1.3% 차이)
  • Java Platform Thread: 534.05 ms

3. P95 응답 시간

  • Go Goroutine: 502.42 ms
  • Java Virtual Thread: 510.23 ms
  • Java Platform Thread: 1085.32 ms

핵심 인사이트

  1. 경량 스레드의 압도적 우위: Virtual Thread와 Goroutine 모두 I/O Bound 작업에서 Platform Thread를 크게 앞섰습니다.
  2. 거의 동등한 성능: Go와 Java의 경량 스레드 구현은 실제 성능면에서 거의 차이가 없습니다. 선택은 생태계와 팀의 경험에 따라 결정하면 됩니다.
  3. 안정성: 고부하 상황에서도 세 모델 모두 에러 없이 안정적으로 동작했습니다.

개인적 소감

Java Spring으로 프로젝트를 진행했었을 때 성능 개선을 위해서 이것저것 시도해봤었는데 근본적으로 OS쓰레드를 가상쓰레드로 바꾸는 작업을 했으면 성능이 바로 좋아지지 않았을까..

Go 런타임의 스택 메모리를 힙영역에 두고 OS 제약 없이 관리하는 방식이 넘 신기하다. 역시 언어개발자는 신이야

참고 자료

https://techblog.woowahan.com/15398/
https://jangbageum.tistory.com/100
https://go.dev/blog/waza-talk

profile
클라우드 데이터 플랫폼 주니어 개발자 도도입니다!

0개의 댓글