Java 21의 Virtual Thread와 Kotlin Coroutine은 모두 "적은 OS 스레드로 대량의 동시 작업을 처리한다"는 같은 목표를 향한다. 하지만 접근 방식은 근본적으로 다르다. 이 글에서는 두 기술의 설계 철학, 동작 원리, 성능 특성, 그리고 실무에서의 선택 기준을 비교한다.


1. 왜 이 비교가 필요한가

전통적인 스레드 vs 코루틴 비교 글은 많다. 하지만 대부분 Platform Thread(OS 스레드)와 코루틴을 비교하는데, 이건 사실 공정하지 않다. Platform Thread는 무거운 게 당연하고, 코루틴이 가벼운 것도 당연하다.

진짜 의미 있는 비교는 같은 경량 동시성 계층끼리의 비교다. Java 21부터 정식 도입된 Virtual Thread는 JVM 레벨에서 경량 스레드를 제공하며, Kotlin Coroutine과 동일한 문제 공간을 타겟한다.

 
[전통적 비교]  Platform Thread vs Coroutine  → 무게급이 다름
[의미 있는 비교] Virtual Thread vs Coroutine  → 같은 문제, 다른 해법

2. 핵심 차이: 설계 철학

Virtual Thread — "기존 코드를 바꾸지 마라"

Virtual Thread의 설계 철학은 **투명성(transparency)**이다. 개발자는 기존에 작성한 블로킹 코드를 그대로 유지하면서, 스레드 풀만 교체하면 경량 동시성의 이점을 얻는다.

 
 
java
// Before: Platform Thread 풀
ExecutorService executor = Executors.newFixedThreadPool(200);

// After: Virtual Thread — 코드 한 줄 변경
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();

Spring Boot에서는 설정 하나로 전환된다:

 
 
yaml
# application.yml
spring:
  threads:
    virtual:
      enabled: true

이것만으로 톰캣의 요청 처리 스레드가 전부 Virtual Thread로 바뀐다.

Kotlin Coroutine — "비동기를 명시적으로 표현하라"

Coroutine의 설계 철학은 **구조적 동시성(Structured Concurrency)**이다. 비동기 작업의 시작, 범위, 취소, 예외 전파를 코드 구조에 명시적으로 드러낸다.

 
 
kotlin
// 비동기 작업의 범위가 코드 구조에 드러남
coroutineScope {
    val user = async { userService.findById(id) }      // 비동기 시작
    val orders = async { orderService.findByUser(id) }  // 비동기 시작
    
    UserDetail(user.await(), orders.await())  // 둘 다 완료될 때까지 대기
}
// coroutineScope를 벗어나면 모든 자식 코루틴이 정리됨

이 구조에서는 하나가 실패하면 나머지도 자동 취소된다. Virtual Thread에서 이걸 구현하려면 StructuredTaskScope를 별도로 사용해야 한다.


3. 동작 원리 비교

스케줄링 방식

항목                                          Virtual Thread                                                 Kotlin Coroutine
스케줄링 주체 JVM (ForkJoinPool) 코루틴 디스패처
캐리어 Platform Thread (OS 스레드) Platform Thread (OS 스레드)
전환 시점 블로킹 I/O 호출 시 자동 suspend 함수 호출 시
개발자 개입 불필요 (투명) 명시적 (suspend, launch, async)
스택 구조 Stackful (자체 스택 보유) Stackless (Continuation 객체)

블로킹 I/O 처리

이것이 두 기술의 가장 큰 실무적 차이다.

Virtual Thread: 블로킹 코드를 그대로 써도 된다. JVM이 Thread.sleep(), Socket.read(), JDBC 호출 등을 만나면 자동으로 Virtual Thread를 캐리어 스레드에서 분리한다.

 
 
java
// Virtual Thread에서 이 코드는 캐리어 스레드를 블로킹하지 않음
try (var conn = dataSource.getConnection()) {
    var rs = conn.prepareStatement("SELECT * FROM users WHERE id = ?")
                 .executeQuery();  // JVM이 자동으로 언마운트
    // ...
}

Kotlin Coroutine: 반드시 suspend 함수 또는 withContext(Dispatchers.IO)로 감싸야 한다. 일반 블로킹 호출을 코루틴 안에서 그냥 쓰면 스레드를 점유한다.

 
 
kotlin
// ❌ 잘못된 사용: 코루틴 안에서 블로킹 JDBC 호출
suspend fun getUser(id: Long): User {
    val conn = dataSource.connection  // 스레드 블로킹!
    // ...
}

// ✅ 올바른 사용: Dispatchers.IO로 전환
suspend fun getUser(id: Long): User = withContext(Dispatchers.IO) {
    val conn = dataSource.connection  // IO 디스패처 스레드에서 실행
    // ...
}

// ✅ 가장 좋은 방법: R2DBC 같은 리액티브 드라이버 사용
suspend fun getUser(id: Long): User {
    return r2dbcTemplate.selectOne(query, User::class.java).awaitSingle()
}

4. 메모리와 성능

메모리 사용량

항목                                                         Virtual Thread                                   Kotlin Coroutine
초기 메모리 ~수 KB (동적 스택) ~수백 바이트 (Continuation 객체)
스택 성장 필요 시 자동 확장 힙에 상태 저장 (스택 없음)
100만 개 생성 시 ~수 GB ~수백 MB

Virtual Thread는 stackful 방식이라 자체 스택을 가지지만, 초기 크기가 작고 필요할 때만 늘어난다. Coroutine은 stackless 방식으로 스택 자체가 없어서 메모리 효율이 더 높다.

컨텍스트 스위칭 비용

둘 다 OS 레벨 컨텍스트 스위칭은 발생하지 않는다. 다만:

  • Virtual Thread: JVM의 ForkJoinPool이 work-stealing 방식으로 스케줄링. 마운트/언마운트 시 스택 프레임을 힙에 저장/복원하는 비용이 있음.
  • Coroutine: CPS(Continuation Passing Style) 변환으로 상태 머신이 생성됨. suspend 지점마다 상태를 저장하는 Continuation 객체가 힙에 할당됨.

실측 벤치마크에서는 대부분의 I/O-bound 워크로드에서 유의미한 차이가 없다. CPU-bound 작업에서는 둘 다 적합하지 않으며, Platform Thread + 병렬 스트림이 더 나은 선택이다.


5. Pinning: Virtual Thread의 아킬레스건

Virtual Thread 도입 시 반드시 알아야 할 제약이 pinning이다. 다음 상황에서 Virtual Thread가 캐리어 스레드에 고정되어, 경량성의 이점이 사라진다:

  1. synchronized 블록/메서드 내부에서 블로킹 호출
  2. JNI(네이티브 코드) 실행 중
java
// ❌ Pinning 발생: synchronized + 블로킹 I/O
synchronized (lock) {
    socket.read(buffer);  // 캐리어 스레드가 고정됨
}

// ✅ Pinning 해결: ReentrantLock 사용
private final ReentrantLock lock = new ReentrantLock();

lock.lock();
try {
    socket.read(buffer);  // Virtual Thread가 정상적으로 언마운트됨
} finally {
    lock.unlock();
}

Pinning 탐지 방법:

 
bash
# JVM 옵션으로 pinning 이벤트 추적
java -Djdk.tracePinnedThreads=short -jar app.jar

# JFR(Java Flight Recorder)로 상세 분석
java -XX:StartFlightRecording=filename=recording.jfr,settings=profile -jar app.jar

Kotlin Coroutine에는 이런 제약이 없다. 코루틴은 suspend 지점에서만 전환되므로, synchronized 블록이 문제가 되지 않는다(단, 코루틴 안에서 synchronized를 쓰는 건 별도의 이유로 권장되지 않는다 — Mutex를 대신 사용).


6. 에코시스템과 라이브러리 지원

Virtual Thread의 장점: 기존 Java 에코시스템 그대로 사용

 
 
java
// JDBC — 그대로 사용
var conn = DriverManager.getConnection(url);

// HttpClient — 그대로 사용  
var client = HttpClient.newHttpClient();
var response = client.send(request, BodyHandlers.ofString());

// Spring Data JPA — 그대로 사용
var user = userRepository.findById(id);

Coroutine의 장점: 리액티브/비동기 전용 에코시스템

 
 
kotlin
// Ktor — 코루틴 네이티브 HTTP 프레임워크
val response = client.get("https://api.example.com/users")

// R2DBC — 리액티브 DB 드라이버
val user = r2dbcTemplate.selectOne(query).awaitSingle()

// Flow — 리액티브 스트림
fun streamOrders(): Flow<Order> = flow {
    orderRepository.findAll().collect { emit(it) }
}
에코시스템Virtual ThreadCoroutine
JDBC/JPA ✅ 그대로 사용 ⚠️ Dispatchers.IO 필요
HTTP Client ✅ 블로킹 클라이언트 OK ✅ Ktor/suspend 지원
Spring WebFlux ⚠️ 불필요 (WebMVC + VT가 더 단순) ✅ 자연스러운 통합
리액티브 스트림 ❌ 별도 패턴 필요 ✅ Flow로 자연스럽게
테스트 ✅ 기존 JUnit 그대로 ⚠️ runTest 블록 필요

7. 실무 선택 기준

Virtual Thread를 선택해야 할 때

  • 기존 Java/Spring Boot 프로젝트에 경량 동시성을 도입하고 싶을 때
  • 코드 변경을 최소화하면서 처리량을 높이고 싶을 때
  • JDBC 기반 레거시 코드가 많을 때
  • 팀에 Kotlin/코루틴 경험이 부족할 때
  • Spring Boot 3.2+ 환경에서 설정 한 줄로 전환하고 싶을 때

Kotlin Coroutine을 선택해야 할 때

  • Kotlin 기반 프로젝트이거나 Kotlin 도입이 확정된 경우
  • 구조적 동시성이 중요한 복잡한 비동기 워크플로가 있을 때
  • Flow 기반의 리액티브 스트림 처리가 필요할 때
  • Android 개발 (Virtual Thread는 Android에서 사용 불가)
  • 비동기 로직의 취소, 타임아웃, 에러 전파를 세밀하게 제어해야 할 때

둘 다 쓸 수 있는 경우

Spring Boot + Kotlin 환경이라면, 실제로 두 기술을 조합할 수 있다:

 
 
kotlin
// Spring Boot에서 Virtual Thread + Coroutine 혼합
@RestController
class UserController(private val userService: UserService) {

    // Virtual Thread 위에서 코루틴 실행
    @GetMapping("/users/{id}")
    suspend fun getUser(@PathVariable id: Long): UserDetail {
        return coroutineScope {
            val user = async { userService.findById(id) }      // suspend 함수
            val orders = async { orderService.findByUser(id) }  // suspend 함수
            UserDetail(user.await(), orders.await())
        }
    }
}

이 경우 톰캣 요청 처리는 Virtual Thread가 담당하고, 컨트롤러 내부의 병렬 호출은 Coroutine이 담당하는 구조가 된다.


8. 정리

비교 항목                                           Virtual Thread                                               Kotlin Coroutine
도입 비용 매우 낮음 (설정 변경) 높음 (코드 리팩터링)
코드 변경 거의 없음 suspend/async 전환 필요
메모리 효율 좋음 (수 KB/VT) 더 좋음 (수백 바이트)
블로킹 코드 호환 ✅ 완벽 ❌ 별도 처리 필요
구조적 동시성 제한적 (Preview API) ✅ 네이티브 지원
디버깅 스레드 덤프 지원 전용 도구 필요
제약 사항 Pinning (synchronized, JNI) 색상 함수 문제 (suspend 전파)
학습 곡선 거의 없음 중간~높음
적합한 환경 Java + Spring + JDBC Kotlin + Ktor/Spring WebFlux

Virtual Thread는 "바꾸지 않아도 빨라지는" 실용주의적 해법이고, Kotlin Coroutine은 "비동기를 제대로 설계하는" 원칙주의적 해법이다. 정답은 없다. 프로젝트의 언어, 팀 역량, 기존 코드베이스에 맞춰 선택하면 된다.


이 글은 Java 21+ Virtual Thread(JEP 444)와 Kotlin Coroutines 1.7+ 기준으로 작성되었습니다.

LIST

+ Recent posts