왜 비교하는가 서버 애플리케이션에서 동시성 모델 선택은 처리량과 응답 시간을 결정짓는 핵심 설계 요소다. 전통적인 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로 계산한다. 공통 인터페이스 세 모델 모두 동일한 인터페이스를 구현하여 공정한 비교를 보장한다. 각 태스크는 System.nanoTime()으로 개별 지연 시간을 측정하고, Collections.synchronizedList()에 수집한다. 메모리는 실행 전후 Runtime.freeMemory() 차이로 계산한다. 메트릭 수집 Spring Boot Actuator + Micrometer로 Prometheus에 메트릭을 전송하고, Grafana 대시보드로 시각화한다. 수집 메트릭: benchmark.throughput, benchmark.duration.ms, benchmark.latency.avg, benchmark.latency.p95, benchmark.memory.mb - 각각 approach, scenario, tasks 태그로 구분된다. 결과 분석 종합 요약 100회 반복 평균 기준, 핵심 지표를 한눈에 보면 이렇다. | 시나리오 | 지표 | Platform Thread | Virtual Thread | Coroutine | |||:-:|:-:|:-:| | I/O Bound | 처리량 | 250 ops/s | 144K ops/s | 82K ops/s | | | 총 소요시간 | 40s | 70ms | 123ms | | CPU Bound | 처리량 | 389K ops/s | 388K ops/s | 218K ops/s | | | 총 소요시간 | 26.5ms | 26ms | 46.5ms | | High Concurrency | 처리량 | 9.9K ops/s | 389K ops/s | 1.33M ops/s | | | 총 소요시간 | 1,013ms | 26.2ms | 7.6ms | I/O Bound - 외부 API 호출이 많은 서비스 10,000개 태스크 × 50ms sleep 이 시나리오는 DB 쿼리, HTTP 호출, 파일 읽기 등 I/O 대기가 지배적인 워크로드를 시뮬레이션한다. Platform Thread가 압도적으로 느린 이유: 스레드 풀 크기가 CPU 코어 수(약 12개)로 고정되어 있어, 10,000개 태스크가 12개씩 순차 처리된다. 각 태스크가 50ms를 블로킹하므로 총 시간은 10,000 × 50ms / 12 ≈ 41.7초에 수렴한다. Virtual Thread가 가장 빠른 이유: newVirtualThreadPerTaskExecutor()가 10,000개의 Virtual Thread를 한 번에 생성한다. Thread.sleep(50)이 호출되면 Virtual Thread는 carrier thread에서 unmount되어 OS 스레드를 반환한다. 10,000개 태스크가 사실상 동시에 50ms를 대기하므로 총 시간 ≈ 50ms + 스케줄링 오버헤드. Coroutine이 VT보다 살짝 느린 이유: Dispatchers.IO의 스레드 풀 관리 오버헤드와 코루틴 스케줄링 비용이 추가된다. 하지만 Platform Thread의 250 ops/s 대비 82,000 ops/s로 처리량 차이가 크다. 개별 태스크의 P95 지연 시간은 세 모델 모두 50ms 부근으로 비슷하다. 각 태스크 입장에서는 50ms sleep이 지배적이기 때문이다. 차이는 동시 처리 가능 수에서 나타난다. CPU Bound - 연산 집약적 배치 10,000개 태스크 × 100,000회 반복 연산 순수 연산만 수행하는 워크로드다. I/O 대기가 없으므로 동시성 모델의 효율보다 CPU 코어 수가 성능을 결정한다. Platform Thread와 Virtual Thread는 성능이 동일하다. CPU 연산은 스레드가 블로킹되지 않으므로, 결국 물리 코어 수만큼만 병렬로 실행된다. Platform Thread: FixedThreadPool(cores) → 코어 수만큼 동시 실행 Virtual Thread: 내부적으로 carrier thread에 스케줄