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)**이다. 개발자는 기존에 작성한 블로킹 코드를 그대로 유지하면서, 스레드 풀만 교체하면 경량 동시성의 이점을 얻는다.
// Before: Platform Thread 풀
ExecutorService executor = Executors.newFixedThreadPool(200);
// After: Virtual Thread — 코드 한 줄 변경
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
Spring Boot에서는 설정 하나로 전환된다:
# application.yml
spring:
threads:
virtual:
enabled: true
이것만으로 톰캣의 요청 처리 스레드가 전부 Virtual Thread로 바뀐다.
Kotlin Coroutine — "비동기를 명시적으로 표현하라"
Coroutine의 설계 철학은 **구조적 동시성(Structured Concurrency)**이다. 비동기 작업의 시작, 범위, 취소, 예외 전파를 코드 구조에 명시적으로 드러낸다.
// 비동기 작업의 범위가 코드 구조에 드러남
coroutineScope {
val user = async { userService.findById(id) } // 비동기 시작
val orders = async { orderService.findByUser(id) } // 비동기 시작
UserDetail(user.await(), orders.await()) // 둘 다 완료될 때까지 대기
}
// coroutineScope를 벗어나면 모든 자식 코루틴이 정리됨
이 구조에서는 하나가 실패하면 나머지도 자동 취소된다. Virtual Thread에서 이걸 구현하려면 StructuredTaskScope를 별도로 사용해야 한다.
3. 동작 원리 비교
스케줄링 방식
| 스케줄링 주체 | 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를 캐리어 스레드에서 분리한다.
// Virtual Thread에서 이 코드는 캐리어 스레드를 블로킹하지 않음
try (var conn = dataSource.getConnection()) {
var rs = conn.prepareStatement("SELECT * FROM users WHERE id = ?")
.executeQuery(); // JVM이 자동으로 언마운트
// ...
}
Kotlin Coroutine: 반드시 suspend 함수 또는 withContext(Dispatchers.IO)로 감싸야 한다. 일반 블로킹 호출을 코루틴 안에서 그냥 쓰면 스레드를 점유한다.
// ❌ 잘못된 사용: 코루틴 안에서 블로킹 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. 메모리와 성능
메모리 사용량
| 초기 메모리 | ~수 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가 캐리어 스레드에 고정되어, 경량성의 이점이 사라진다:
- synchronized 블록/메서드 내부에서 블로킹 호출
- JNI(네이티브 코드) 실행 중
// ❌ 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 탐지 방법:
# 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 에코시스템 그대로 사용
// 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의 장점: 리액티브/비동기 전용 에코시스템
// 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) }
}
| 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 환경이라면, 실제로 두 기술을 조합할 수 있다:
// 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. 정리
| 도입 비용 | 매우 낮음 (설정 변경) | 높음 (코드 리팩터링) |
| 코드 변경 | 거의 없음 | 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+ 기준으로 작성되었습니다.
'Software > Maker(Spring & Python & node)' 카테고리의 다른 글
| [필독] 클로드 만든 회사가 직접 알려주는 'Claude 제대로 쓰는 법 7가지 (0) | 2026.04.06 |
|---|---|
| ChatGPT와는 차원이 다르다! 내 컴퓨터에서 직접 일하는 'Claude Code' 설치부터 실전 활용까지 (0) | 2026.04.06 |
| RAG 정확도는 이제 덜 중요해졌다 — Claude Code가 바꿔버린 구조(feat.GPT, CLAUDE) (0) | 2026.04.06 |
| 어떤 이유로 코루틴을 사용한 작업 처리가 기존 스레드 방식보다 가벼운지 설명해주세요. (0) | 2026.04.06 |
| 명령어 파이프라이닝에 대해서 설명해 주세요. (0) | 2026.04.02 |
