Platform Thread vs Virtual Thread vs Coroutine - 10,000 태스크 벤치마크

JDK 25 Platform Thread, Virtual Thread, Kotlin Coroutine의 I/O Bound, CPU Bound, High Concurrency 시나리오별 성능을 100회 반복 벤치마크로 비교한다.

왜 비교하는가

서버 애플리케이션에서 동시성 모델 선택은 처리량과 응답 시간을 결정짓는 핵심 설계 요소다. 전통적인 Platform Thread 기반 스레드 풀은 오랫동안 표준이었지만, JDK 21에서 Virtual Thread가 정식 도입되고, Kotlin Coroutine이 JVM 생태계에서 존재감을 키우면서 선택지가 넓어졌다.

이전에 JDK 11에서 21로 마이그레이션하고, 이어서 JDK 21에서 25로 올리면서 Virtual Thread를 도입했다. 결제 시스템을 SQS 비동기에서 동기 API로 전환하는 과정에서 동기 코드의 동시성 확보가 관심사가 되었고, 멀티채널 알림 서버처럼 대량 동시 발송이 필요한 서비스에서는 동시성 모델 선택이 곧 처리량을 결정짓는다.

문제는 "어떤 모델이 더 좋은가?"에 대한 답이 워크로드에 따라 완전히 달라진다는 것이다. I/O 대기가 많은 서비스, 연산 집약적인 배치 처리, 수만 개의 동시 요청을 처리해야 하는 경우 각각 최적의 모델이 다르다.

직접 벤치마크를 만들어서 10,000개 태스크 × 100회 반복 조건으로 세 모델을 비교했다. 모니터링은 이전에 구축한 LGTM 스택의 Prometheus + Grafana 조합을 활용했다.

세 가지 동시성 모델

Platform Thread - 전통적 스레드 풀

OS 커널 스레드와 1:1로 매핑되는 전통적 방식이다. Executors.newFixedThreadPool(N)으로 스레드 풀을 만들고, 풀 크기만큼만 동시에 실행된다. 스레드 풀 크기: CPU 코어 수 (Runtime.getRuntime().availableProcessors()) 장점: 예측 가능한 자원 사용, 안정적 한계: I/O 대기 시 스레드가 블로킹되어 풀 크기만큼만 병렬 처리 가능

Virtual Thread - JDK 21+의 경량 스레드

JVM이 관리하는 경량 스레드다. OS 스레드 위에 N:M 매핑으로 동작하며, I/O 블로킹 시 자동으로 carrier thread에서 unmount된다. 코드는 동기식으로 작성하되 런타임이 비동기 최적화를 처리한다. 코드 변경: executor만 교체, 나머지 코드 동일 장점: 동기 코드 스타일 유지, I/O 블로킹에서 스레드 점유 없음 참고: JDK 24(JEP 491)에서 synchronized 블록 내 pinning 문제 해소

Kotlin Coroutine - 언어 레벨 비동기

컴파일러가 suspend 함수를 상태 머신(Continuation)으로 변환하는 방식이다. 스레드보다 가벼운 코루틴 객체를 힙에 생성하고, Dispatcher가 적절한 스레드 풀에 스케줄링한다. I/O 작업: Dispatchers.IO (언바운드 스레드 풀) + delay() (비블로킹) CPU 작업: Dispatchers.Default (코어 수 고정 풀) + 블로킹 연산 동시성: launch + delay() - 코루틴 스케줄러가 직접 컨텍스트 스위칭

벤치마크 설계

테스트 환경

| 항목 | 값 | ||--| | JDK | Amazon Corretto 25 | | Kotlin | 2.3.0 | | Coroutines | 1.10.2 | | Spring Boot | 4.0.4 | | 모니터링 | Prometheus + Grafana | | 실행 횟수 | 100회 (평균값 사용) |

시나리오 & 파라미터

| 시나리오 | 태스크 수 | 설명 | ||-|| | I/O Bound | 10,000 | 각 태스크가 50ms sleep (외부 API 호출 시뮬레이션) | | CPU Bound | 10,000 | 각 태스크가 100,000회 반복 연산 (sum += i i) | | High Concurrency | 10,000 | 각 태스크가 1ms sleep (다량의 경량 요청 시뮬레이션) |

시나리오별 테스트 코드

I/O Bound - 외부 호출 시뮬레이션

Thread.sleep(50ms)로 네트워크 I/O 대기를 시뮬레이션한다. Coroutine만 delay()를 사용하고 나머지는 동일한 블로킹 호출이다.

| 모델 | Executor / Dispatcher | I/O 시뮬레이션 | ||-|| | Platform Thread | FixedThreadPool(cores) | Thread.sleep(50) | | Virtual Thread | newVirtualThreadPerTaskExecutor() | Thread.sleep(50) | | Coroutine | Dispatchers.IO + async | delay(50) (비블로킹) |

CPU Bound - 순수 연산

sum += i i를 100,000회 반복하는 CPU 집약적 연산이다. 세 모델 모두 동일한 연산 로직을 수행한다.

| 모델 | Executor / Dispatcher | 연산 | ||-|| | Platform Thread | FixedThreadPool(cores) | simulateCpu(100_000) | | Virtual Thread | newVirtualThreadPerTaskExecutor() | simulateCpu(100_000) | | Coroutine | Dispatchers.Default + async | simulateCpu(100_000) |

High Concurrency - 경량 요청 폭주

Thread.sleep(1) 또는 delay(1)로 아주 짧은 I/O를 가진 대량 요청을 시뮬레이션한다. 컨텍스트 스위칭 오버헤드가 성능을 좌우하는 시나리오다.

| 모델 | Executor / Dispatcher | 대기 방식 | ||-|-| | Platform Thread | FixedThreadPool(cores) | Thread.sleep(1) | | Virtual Thread | newVirtualThreadPerTaskExecutor() | Thread.sleep(1) | | Coroutine | 기본 dispatcher + launch | delay(1) (비블로킹) |

측정 방식

각 태스크는 System.nanoTime()으로 개별 지연 시간을 측정하고, Collections.synchronizedList()에 수집한다. 메모리는 Runtime.freeMemory() 차이로, 처리량은 taskCount * 1000 / totalMs로 계산한다.

공통 인터페이스