인생을 코딩하다.

ThreadPool과 Virtual Thread의 성능 및 메모리 사용 비교 본문

Java

ThreadPool과 Virtual Thread의 성능 및 메모리 사용 비교

Hyung1 2025. 12. 27. 20:44
728x90
반응형

이미지 출처 : https://blog.stackademic.com/virtual-threads-in-java-21-how-enterprise-scale-to-millions-of-concurrent-connections-without-26601f820614

목차

  • 1. 배경
  • 2. 테스트 환경
  • 3. 테스트 결과
  • 4. 마치며..

1. 배경

최근 차세대 마이그레이션을 진행하는 업무를 맡아 수행하게 되었습니다. 이번 마이그레이션에서는 지금까지의 JDK 버전 흐름과 지원 현황을 종합적으로 검토한 결과, 여러 기술적·운영상의 이유로 JDK 25를 도입하는 것이 가장 적합하다고 판단하여 이를 채택하였습니다.
 
JDK 21부터 정식으로 도입된 Virtual Thread는 동기식 코드 스타일을 유지하면서도 JVM 경량 스레드를 사용하여 적은 리소스로도 높은 동시성을 효율적으로 처리할 수 있는 실행 모델을 제공합니다. 저는 이러한 특징들로 인해 Virtual Thread를 적극적으로 도입하여 아래와 같은 장점들을 경험할 수 있었습니다.

  • Spring MVC 기반 동기·블로킹 서버의 스레드 풀 구조를 Virtual Thread 기반으로 전환하여, 고부하 상황에서도 안정적인 처리와 자원 효율 개선을 달성하였습니다.
  • WebFlux(+Coroutine) 기반 서버를 Spring MVC + Virtual Thread 모델로 전환하여, 기존 성능을 유지하면서 복잡도와 유지보수 비용, 개발자 러닝 커브를 낮추고 생산성을 향상시켰습니다.

물론 Virtual Thread를 도입하면서 장점만 있었던 것은 아니었고, 몇 가지 고려해야 단점도 있었습니다.
 
이러한 경험을 바탕으로, 이번 글에서는 ThreadPool 기반 모델과 Virtual Thread 모델의 성능을 비교해보고자 합니다. (본문에 등장하는 코드는 이해를 돕기 위해 일부 재구성되었습니다.)

2. 테스트 환경

2-1. 서버 구성

Callee Server (외부 API 서버)

  • Endpoint: http://localhost:8001/api/test/slow
  • 응답 시간: 3초 (데이터 분석용 조회 API)
  • Tomcat Worker Thread: 200개

Caller Server (테스트 실행 서버)

  • 총 요청 수: 1,000개
  • JVM Heap Memory: 1GB
  • CPU Core: 10개

2-2. Configuration 설정

/**
 * 이 설정은 "부하 실험 / 동작 비교"를 위한 값이며,
 * 운영 환경에서는 서비스 특성, 트래픽 패턴, 외부 API SLA에 맞게 반드시 재조정되어야 합니다.
 *
 * ---------------------------------------------------------------------
 * Timeout 설정 기준
 *
 * [readTimeout]
 * - 외부 API 응답 지연을 기준으로 설정
 * - 평균 응답 3초 + 네트워크 지연/스케줄링 여유를 고려해 5초로 설정
 * 
 * ※ connectionRequestTimeout 은 의도적으로 사용하지 않음
 *   - 커넥션 풀 대기 시간 제한은 "풀 병목" 실험이 되어버림
 *   - 본 실험에서는 병목 지점을 callee(외부 서버)로 두기 위해 제거
 *
 * ---------------------------------------------------------------------
 * Connection Pool 설정
 *
 * [maxTotal / maxPerRoute = 1000]
 * - caller 쪽 커넥션 풀이 병목이 되지 않도록 충분히 크게 설정
 * 
 * [connectTimeout = 500ms]
 * - TCP 연결 수립 실패를 빠르게 감지하기 위한 값, DNS 문제, 서버 다운 등 네트워크 장애를 빠르게 드러내기 위함
 *
 * [validateAfterInactivity = 5s]
 * - 일정 시간 이상 유휴 상태였던 커넥션 재사용 전 유효성 검사, 오래된 커넥션 재사용으로 인한 예외 방지 목적
 *
 * [evictIdleConnections = 60s]
 * - 60초 이상 사용되지 않은 커넥션을 정리, 리소스 회수 목적 (실험 결과에는 큰 영향 없음)
 */
@ConfigurationProperties(prefix = "clients.test")
data class TestClientProperties(
    val baseUrl: String = "http://localhost:8001",
    val timeProperties: TimeoutProperties = TimeoutProperties(),
    val connectionPool: ConnectionPoolProperties = ConnectionPoolProperties()
) {
    data class TimeoutProperties(
//        val connectionRequest: Duration = Duration.ofMillis(1000),
        val readTimeOut: Duration = Duration.ofSeconds(5),
    )

    data class ConnectionPoolProperties(
        val maxTotal: Int = 1000,
        val maxPerRoute: Int = 1000,
        val connectTimeout: Duration = Duration.ofMillis(500),
        val validateAfterInactivity: Duration = Duration.ofSeconds(5),
        val evictIdleConnections: Duration = Duration.ofSeconds(60)
    )
}

@Configuration
@PropertySource(
    value = ["classpath:clients-\${spring.profiles.active}.yaml"],
    factory = YamlPropertySourceFactory::class
)
@EnableConfigurationProperties(TestClientProperties::class)
@ImportHttpServices(group = CLIENT_GROUP, basePackageClasses = [TestClient::class])
class TestClientConfiguration(
    private val properties: TestClientProperties
) {

    @Bean
    fun testHttpServiceGroupConfigurer(): RestClientHttpServiceGroupConfigurer {
        val poolProps = properties.connectionPool
        val timeProps = properties.timeProperties

        val connectionConfig = ConnectionConfig.custom()
            .setConnectTimeout(Timeout.ofMilliseconds(poolProps.connectTimeout.toMillis()))
            .setValidateAfterInactivity(Timeout.ofMilliseconds(poolProps.validateAfterInactivity.toMillis()))
            .build()

        val connectionManager = PoolingHttpClientConnectionManager().apply {
            setDefaultConnectionConfig(connectionConfig)
            maxTotal = poolProps.maxTotal
            defaultMaxPerRoute = poolProps.maxPerRoute
        }

        val httpClient = HttpClients.custom()
            .setConnectionManager(connectionManager)
            .evictIdleConnections(TimeValue.ofMilliseconds(poolProps.evictIdleConnections.toMillis()))
            .build()

        val requestFactory = HttpComponentsClientHttpRequestFactory(httpClient).apply {
            setReadTimeout(timeProps.readTimeOut.toMillis().toInt())
            timeProps.connectionRequestTimeout?.let {
                setConnectionRequestTimeout(it.toMillis().toInt())
            }
        }

        return RestClientHttpServiceGroupConfigurer { groups ->
            groups.filterByName(CLIENT_GROUP)
                .forEachClient { _, clientBuilder ->
                    clientBuilder
                        .baseUrl(properties.baseUrl)
                        .requestFactory(requestFactory)
                }
        }
    }

    companion object {
        private const val CLIENT_GROUP = "test"
    }
}

2-2. 테스트 시나리오

  • 총 요청 수: 1000
  • 외부 API 호출 시간: 약 3초
  • 측정 지표:
    • 전체 처리 시간
    • 성공 / 실패 요청 수
    • JVM Thread 상태 변화
@RestController
class LoadTestController(
    private val testClient: TestClient
) {
    /**
     * 1. 스레드풀 Executor 호출 (플랫폼 스레드 100개)
     * - 고정 스레드풀 100개로 1000개 요청 처리
     */
    @GetMapping(
        value = ["/api/test/threadpool"],
        produces = [MediaType.APPLICATION_JSON_VALUE]
    )
    fun testThreadPool() {
        val executor = Executors.newFixedThreadPool(THREAD_POOL_SIZE)
        val successCount = AtomicInteger(0)
        val failCount = AtomicInteger(0)

        val startTime = System.currentTimeMillis()
        log.info("=== ThreadPool Test Started ($THREAD_POOL_SIZE threads) ===")

        val futures = (1..TOTAL_REQUESTS).map { index ->
            CompletableFuture.runAsync({
                try {
                    testClient.slow()
                    successCount.incrementAndGet()
                    if (index % 100 == 0) {
                        log.info("ThreadPool progress: $index/$TOTAL_REQUESTS")
                    }
                } catch (e: Exception) {
                    failCount.incrementAndGet()
                }
            }, executor)
        }

        CompletableFuture.allOf(*futures.toTypedArray()).join()

        executor.shutdown()

        val duration = System.currentTimeMillis() - startTime
        log.info("=== ThreadPool Test Completed in ${duration}ms (${duration / 1000.0}s) ===")
        log.info("=== Success: ${successCount.get()}, Fail: ${failCount.get()} ===")
    }

    /**
     * 2. 가상 스레드 호출 (제한 없음)
     * - Virtual Thread Executor 사용 (무제한)
     * - 1000개 요청 동시 처리
     * - 주의: 외부 API가 감당 못하면 타임아웃 발생 가능
     */
    @GetMapping(
        value = ["/api/test/virtual"],
        produces = [MediaType.APPLICATION_JSON_VALUE]
    )
    fun testVirtualThread() {
        val startTime = System.currentTimeMillis()
        val successCount = AtomicInteger(0)
        val failCount = AtomicInteger(0)
        log.info("=== Virtual Thread Test Started (Unlimited) ===")

        val virtualThreadExecutor = Executors.newVirtualThreadPerTaskExecutor()

        val futures = (1..TOTAL_REQUESTS).map { index ->
            CompletableFuture.runAsync({
                try {
                    testClient.slow()
                    successCount.incrementAndGet()
                    if (index % 100 == 0) {
                        log.info("Virtual Thread progress: $index/$TOTAL_REQUESTS")
                    }
                } catch (e: Exception) {
                    failCount.incrementAndGet()
                }
            }, virtualThreadExecutor)
        }

        CompletableFuture.allOf(*futures.toTypedArray()).join()

        virtualThreadExecutor.close()

        val duration = System.currentTimeMillis() - startTime
        log.info("=== Virtual Thread Test Completed in ${duration}ms (${duration / 1000.0}s) ===")
        log.info("=== Success: ${successCount.get()}, Fail: ${failCount.get()} ===")
    }

    /**
     * 3. 가상 스레드 호출 (Semaphore로 동시 실행 제한)
     * - Virtual Thread Executor 사용
     * - 1000개 요청 생성하되, 동시 실행은 100개로 제한
     */
    @GetMapping(
        value = ["/api/test/virtual-limited"],
        produces = [MediaType.APPLICATION_JSON_VALUE]
    )
    fun testVirtualThreadLimited() {
        val startTime = System.currentTimeMillis()
        val successCount = AtomicInteger(0)
        val failCount = AtomicInteger(0)
        log.info("=== Virtual Thread Test Started (Limited to $SEMAPHORE_PERMITS) ===")

        val virtualThreadExecutor = Executors.newVirtualThreadPerTaskExecutor()
        val semaphore = Semaphore(SEMAPHORE_PERMITS)

        val futures = (1..TOTAL_REQUESTS).map { index ->
            CompletableFuture.runAsync({
                try {
                    semaphore.acquire()  // 허가 받을 때까지 대기
                    try {
                        testClient.slow()
                        successCount.incrementAndGet()
                        if (index % 100 == 0) {
                            log.info("Virtual Thread (Limited) progress: $index/$TOTAL_REQUESTS")
                        }
                    } finally {
                        semaphore.release()
                    }
                } catch (e: Exception) {
                    failCount.incrementAndGet()
                }
            }, virtualThreadExecutor)
        }

        CompletableFuture.allOf(*futures.toTypedArray()).join()

        virtualThreadExecutor.close()

        val duration = System.currentTimeMillis() - startTime
        log.info("=== Virtual Thread (Limited) Test Completed in ${duration}ms (${duration / 1000.0}s) ===")
        log.info("=== Success: ${successCount.get()}, Fail: ${failCount.get()} ===")
    }

    companion object {
        private val log = LoggerFactory.getLogger(LoadTestController::class.java)
        private const val TOTAL_REQUESTS = 1000
        private const val THREAD_POOL_SIZE = 100
        private const val SEMAPHORE_PERMITS = 100
    }
}

3. 테스트 결과

방식 소요 시간 성공 실패 성공률
ThreadPool(100) 30s 1,000 0 100%
Virtual Thread (무제한) 5s 200 800 20%
Virtual Thread + Semaphore(100) 30s 1,000 0 100%

 
Virtual Thread(무제한) 방식에서는 성공률이 20%에 그쳤습니다. 이는 외부 서버가 동시에 처리할 수 있는 톰캣 워커 수를 200으로 가정했기 때문입니다. 1,000개의 요청이 동시에 전달되면서, 실제로 처리 가능한 200개만 정상 처리되고 나머지 요청은 대기 상태에 들어가 응답 지연이 발생했습니다. 이 중 상당수가 readTimeout(5s)을 초과하면서 실패로 처리되었습니다.
 
외부 서버의 톰캣 워커 수를 400으로 늘려 다시 테스트하면 성공률은 약 40%까지 증가합니다. 이 결과를 통해, 가상 스레드는 병렬 실행을 매우 쉽게 만들어 주지만 동시 실행에 대한 제한을 제공하지는 않으며, 외부 API의 처리 한계를 초과할 경우 대량의 요청이 동시에 지연되고 타임아웃으로 실패할 수 있음을 확인할 수 있었습니다.
 
즉, 가상 스레드는 “더 많은 작업을 동시에 시작할 수 있게 해주는 도구”일 뿐, 외부 시스템의 처리 용량을 자동으로 고려하거나 보호해 주지는 않기 때문에 유량 제어가 필요하다는 것을 느꼈습니다.

3-1. 그럼 어떻게 해결할 수 있을까요?

유량 제어를 적용하는 방법에는 여러 가지가 있을 수 있습니다. 다만 개인적으로는, 자바 진영에서도 이미 이러한 문제를 인지하고 있었을 것이고, 공식적으로 권장하는 방식이 존재할 것이라 생각해 관련 문서를 찾아보았습니다.

https://docs.oracle.com/en/java/javase/21/core/virtual-threads.html#GUID-E695A4C5-D335-4FA4-B886-FEB88C73F23E

 
조사해보니, Java 공식 문서에서도 가상 스레드 환경에서의 동시성 제어 방식으로 Semaphore 사용을 예시로 안내하고 있었습니다. 즉, 가상 스레드는 실행 비용이 매우 낮기 때문에 무제한으로 생성할 수 있지만, 외부 리소스 접근 시에는 별도의 동시성 제한 장치를 두는 것이 필요하다는 점을 명확히 하고 있습니다.
 
이에 따라 본 실험에서도 세마포어를 이용해 동시 실행 수를 제한하는 방식으로 유량 제어를 적용하였습니다. 실제 구현 관점에서도 세마포어는 다음과 같은 장점이 있습니다.

  • 구현이 단순하고 직관적임
  • 동시 실행 개수를 명확하게 제한할 수 있음
  • 가상 스레드와 함께 사용해도 부담이 적음
  • 별도의 프레임워크 의존 없이 JDK 기본 기능으로 사용 가능

이러한 이유로, 가상 스레드를 사용하는 환경에서 외부 API 호출에 대한 유량 제어가 필요하다면 세마포어를 사용하는 방식이 가장 현실적이고 적절한 선택이라고 판단했습니다. 동일한 질문을 AI에게 문의했을 때 역시 세마포어 기반의 제어 방식을 권장하는 답변을 확인할 수 있었으며, 이는 공식 문서의 방향성과도 일치하였었습니다.
 
정리하면, 가상 스레드는 동시 실행을 쉽게 만들어 주지만, 외부 시스템의 처리 한계를 자동으로 보호해 주지는 않습니다. 따라서 실제 서비스 환경에서는 세마포어와 같은 명시적인 유량 제어 수단을 함께 사용해야함을 알게되었습니다. 이후 세마포어로 유량 제어하면서 다시 요청했을 경우 성공률이 100%인 것을 확인하였습니다.

3-2. 매트릭 지표 확인

빨간선 점선 기준으로 ThreadPool(100) → Virtual Thread(무제한) → Virtual Thread + Semaphore(100) 세 가지 방식의 지표 상태 변화를 비교한 결과입니다. (가운데 중간에 비어있는 부분은 서버를 잠시 종료시켜서 비어보이는 것이니 무시하셔도 됩니다.)

3-2-1. Thread

우선 톰캣 워커, HTTP 클라이언트, JVM 내부 스레드 등으로 인해 우선 테스트 시작 전부터 항상 약 100개 내외의 스레드가 기본적으로 존재했었습니다. 이 기준을 감안하여, 기본 100개를 제외한 증가분 중심으로 해석하였습니다. 
 

# ThreadPool(100)

ThreadPool 구간에서는 전체 live 스레드가 약 190~200 수준까지 증가하였습니다. 기본 스레드가 약 100개 존재하므로, 실제로 테스트로 인해 추가된 스레드는 약 +100개 수준입니다. 그래프는 아래와 같이 해석할 수 있을 것 같습니다.

  • 요청은 최대 100개까지만 동시에 실행됨
  • 그 이상은 큐에서 대기
  • 스레드 수 증감이 매우 안정적

즉, ThreadPool 자체가 자연스러운 유량 제어 장치 역할을 수행하고 있는 것을 확인하였습니다.
 

# Virtual Thread (무제한)

Virtual Thread 구간에서는 live thread 수가 약 110개 수준으로 유지되었으며, 이는 ThreadPool(100) 방식 대비 약 1/10 수준인 것을 확인하였습니다. 이는 JVM이 논리 CPU 개수만큼의 Carrier Thread만 유지하고, 그 위에서 다수의 Virtual Thread를 스케줄링하기 때문입니다. 그 결과 적은 수의 스레드로도 동일한 요청 처리가 가능했습니다.
 
하지만 이 구간에서는 아래와 같은 문제가 있었습니다.

  • 요청 1,000개가 거의 동시에 실행됨
  • 가상 스레드가 대량 생성됨
  • 외부 서버(톰캣 200) 처리 한계를 초과
  • 다수 요청이 대기 상태로 밀림
  • 응답 지연 누적으로 readTimeout 발생
  • 결과적으로 성공률 20%

즉, 스레드 수가 늘지 않았다고 해서 부하가 적은 것은 아니였습니다. 가상 스레드는 커널 스레드를 늘리지 않을 뿐, 논리적인 동시 실행 자체는 무제한으로 발생하는 것을 확인할 수 있었습니다. 따라서 유량 제어가 필요함을 느꼈습니다.


# Virtual Thread + Semaphore(100)

Virtual Thread (무제한) 방식과 동일하게 live thread 수가 유지되었습니다. 하지만 중요한 차이는 아래와 같습니다.

  • 동시에 실행되는 작업 수가 100으로 제한됨
  • 초과 요청은 가상 스레드 상태로 park 되어 대기
  • 외부 API 처리 한계를 넘지 않음
  • read timeout 발생 없음
  • 모든 요청 성공

즉, 가상 스레드의 가벼움 + 명시적 유량 제어가 결합된 구조로 모든 요청을 성공적으로 처리할 수 있었습니다.

3-2-2. CPU Usage

CPU Bound 작업이 아닌 I/O Bound 작업이라 CPU Usage는 세 방식 모두 비슷하였습니다.

3-2-3. JVM Heap

Virtual Thread는 OS 스레드를 직접 늘리는 구조가 아니라, 많은 수의 가상 스레드 객체를 생성하여 실행합니다. 가상 스레드는 실행이 중단되거나 I/O 대기 상태에 들어가면, 스택을 네이티브 스택에 유지하지 않고 힙에 저장되는 continuation 형태로 보관합니다. 이때 스택은 하나의 큰 연속 메모리가 아니라, 여러 개의 stack chunk(청크 스택) 단위로 분할되어 힙에 저장됩니다. 이후 다시 실행될 때는 이 stack chunk들을 복원하여 이어서 실행합니다.
 
이 구조 덕분에 가상 스레드는 매우 가볍게 생성·중단될 수 있지만, 동시에 많은 가상 스레드가 존재하면 각 스레드가 보유한 continuation과 stack chunk 객체들이 힙에 함께 존재하게 됩니다. 그 결과, ThreadPool 방식처럼 동시 실행 수가 제한된 구조에 비해 힙 사용량이 상대적으로 더 증가하게 됩니다.
 
즉, 가상 스레드는 OS 스레드 사용량을 줄이는 대신, 힙 기반 구조(continuation + stack chunk)를 활용하는 모델이며, 이로 인해 동시 실행 수가 많아질수록 힙 메모리 사용량이 증가하는 특성을 가집니다.
 
따라서, 그래프에서도 ThreadPool 방식에 비해 Virtual Thread 방식에서 힙 사용량이 더 증가하는 것을 확인할 수 있었습니다. GC 또또한 ThreadPool 방식에 비해 Virtual Thread 방식이 더 증가하였습니다.  따라서 가상 스레드를 사용할 때도 무제한 실행보다는 세마포어 등의 기법으로 동시성을 제어하는 것이 메모리 안정성 측면에서 중요할 것 같다고 생각됩니다.

3-2-4. NativeMemory

ThreadPool 방식은 Virtual Thread와 달리 OS 스레드(= 플랫폼 스레드) 가 직접 생성됩니다. 즉, 지정한 개수만큼(혹은 무제한으로) OS 스레드가 늘어나며, 각 스레드는 고정 크기의 네이티브 스택 메모리를 차지하게 됩니다.
 
반면 Virtual Thread는 OS 스레드가 아니라 JVM 내부 객체로 생성되며, 실제 실행은 소수의 carrier thread 위에서 이루어집니다. 따라서 Virtual Thread의 개수가 늘어나더라도 OS 스레드 수는 거의 증가하지 않고, 대신 힙 메모리 사용량만 소폭 증가하게 됩니다.
 
즉, ThreadPool 방식은 네이티브 메모리 사용량이 증가하고 Virtual Thread 방식은 힙 메모리 사용이 증가하는 구조라고 볼 수 있습니다.

네이티브 메모리란?

네이티브 메모리는 JVM 힙 외부에서 사용되는 메모리 영역으로, 대표적으로 다음을 포함합니다.
- OS 스레드 스택 메모리 (-Xss)
- JNI 메모리
- 코드 캐시(Code Cache)
- 클래스 메타데이터
- 기타 JVM 내부 구조체

이 중 스레드 수 증가와 직접적으로 연결되는 영역이 바로 스레드 스택 메모리입니다

저는 이것 또한 실제 눈으로 확인해보기 위해 실험해보았습니다.
 
네이티브 메모리 사용량을 확인하기 위해 JVM의 Native Memory Tracking 기능을 활성화했습니다.

 tasks.withType<BootRun> {
      jvmArgs = listOf(
          "-XX:NativeMemoryTracking=summary"
      )
  }

 
이후 실행 중인 JVM에 대해 아래 명령어로 스레드 관련 네이티브 메모리 변화를 확인했습니다.

jcmd <PID> VM.native_memory baseline
# 테스트 실행
jcmd <PID> VM.native_memory summary.diff scale=MB | grep -A 3 "Thread"

 
[ThreadPool 방식 (플랫폼 스레드-100개)]

Thread (reserved=216MB +100MB, committed=13MB +5MB)
       (threads #198 +100)
       (stack: reserved=215MB +100MB, committed=12MB +5MB)
  • OS 스레드 증가: +100
  • 네이티브 스택 증가: 약 +100MB
  • 스레드 1개당 약 1MB 사용

ThreadPool 방식에서는 스레드 수 증가만큼 네이티브 메모리가 선형적으로 증가함을 확인할 수 있었습니다.
 

[Virtual Thread + Semaphore(100) 방식]

Thread (reserved=125MB +10MB, committed=8MB +1MB)
       (threads #108 +10)
       (stack: reserved=125MB +10MB, committed=8MB +1MB)
  • OS 스레드 증가: 약 +10 (캐리어 스레드)
  • 네이티브 스택 증가: 약 +10MB
  • 가상 스레드 수 증가와 무관하게 네이티브 메모리는 거의 증가하지 않음

Virtual Thread 방식에서는 네이티브 메모리가 거의 증가하지 않았습니다. 이는 가상 스레드가 OS 스레드가 아닌 JVM 객체로 관리되며, 실제 실행은 CPU 개수 수준의 캐리어 스레드에서 이루어지기 때문입니다.
 
즉, 위 차이를 통해 다음을 확인할 수 있었습니다.

  • ThreadPool 방식은 플랫폼 스레드 수만큼 네이티브 메모리가 증가한다.(+100MB)
  • Virtual Thread는 캐리어 스레드 수만큼만 네이티브 메모리가 증가한다.(+10MB)
  • 따라서 대량의 동시 요청 환경에서는 Virtual Thread가 훨씬 메모리 효율적이다

정리하자면, ThreadPool은 스레드 수만큼 네이티브 메모리를 소모하지만, Virtual Thread는 캐리어 스레드 수만큼만 네이티브 메모리를 사용하는 것을 알 수 있었습니다.

3-2-5. Duration

 
Duration 같은 경우 ThreadPool 방식보다는 Virtual Thread 방식이 좀 더 길었던 것을 확인할 수 있었습니다.
 
우선 Virtual Thread + Semaphore 방식은 ThreadPool과 동일한 수준의 동시 실행 개수를 유지하지만, 작업을 처리하는 방식에는 구조적인 차이가 있습니다. ThreadPool은 내부적으로 BlockingQueue 기반의 작업 큐를 사용하여 요청을 정렬한 뒤, 가용한 스레드가 순차적으로 작업을 가져가 처리합니다. 이 때문에 실행 순서가 비교적 안정적이며, 작업이 배치 형태로 처리되는 특성을 가집니다.
 
반면, Virtual Thread + Semaphore 방식은 별도의 작업 큐를 두지 않고, 각 요청이 세마포어의 permit을 직접 획득하기 위해 경쟁하는 구조입니다. 이 과정에서는 요청 간 실행 순서가 보장되지 않으며, permit이 반환되는 시점에 어떤 가상 스레드가 먼저 실행 권한을 얻느냐는 스케줄링 타이밍에 따라 달라집니다. 그 결과 일부 요청은 비교적 빠르게 처리되는 반면, 일부 요청은 permit 획득이 뒤로 밀리면서 실행 시점이 늦어질 수 있습니다.
 
이러한 특성 때문에 요청 완료 순서가 뒤섞이고, 마지막 요청들이 늦게 종료되면서 전체 처리 시간이 ThreadPool 방식보다 약간 길어지는 현상이 발생하지 않았을까? 추측됩니다. 이는 성능 저하라기보다는, 블로킹 큐 기반 처리와 경쟁 기반 처리라는 스케줄링 방식의 차이에서 비롯된 자연스러운 현상일 것으로 보입니다. 따라서 대량의 트래픽을 Virtual Thread + Semaphore 방식으로 처리할 경우 ThreadPool 방식에 비해 Duration이 좀 더 길어질 수 있다는 점도 고려해봐야하지 않을까 싶습니다.

4. 마치며..

실제로 Virtual Thread를 도입해보며 느꼈던 장단점과, 실험을 통해 확인한 성능 · 메모리 특성을 정리해보았습니다. 특히 네이티브 메모리 사용량을 기준으로 비교해보니, 왜 Virtual Thread가 “가볍다”고 이야기되는지 체감할 수 있었습니다.
 
단순히 이론이 아니라, 실제 수치로 확인해보니 인상적이었습니다. 모든 상황에서 무조건 Virtual Thread를 써야 하는 것은 아니지만, I/O 비중이 높은 서버라면 충분히 검토해볼 만한 선택지라고 느꼈습니다. 이 글이 도입을 고민하시는 분들께 작은 참고가 되었으면 합니다.

728x90
반응형
Comments