Java 21에 도입된 Virtual Thread는 자바 동시성 처리 프로그래밍의 새로운 패러다임을 제시합니다.
Virtual Thread는 기존의 무거운 운영체제 스레드와 달리, JVM 내부에서 관리되는 경량 스레드입니다.
이를 통해 적은 자원으로 많은 수의 동시 task를 효과적으로 처리할 수 있게 되었습니다.
과거에는 동시 처리를 위해 스레드 풀을 사용했습니다.
스레드 풀은 제한된 수의 운영체제 스레드(플랫폼 스레드)를 미리 생성해 두고 재사용함으로써
스레드 생성의 오버헤드를 줄입니다.
하지만 운영체제 스레드 자체의 컨텍스트 스위칭 비용은 여전히 크며,
블로킹 작업으로 인해 스레드 풀의 모든 스레드가 사용 중인 경우,
새로운 task는 스레드가 사용 가능해질 때까지 대기해야 합니다. 이는 동시성을 제한하는 요인이 됩니다.
Virtual Thread는 이런 문제를 해결합니다.
Virtual Thread는 적은 수의 운영체제 스레드 위에서 동작하는 경량 스레드입니다.
따라서 많은 수의 Virtual Thread를 생성하더라도, 실제 운영체제 스레드의 수는 적을 수 있습니다.
이는 시스템 자원을 효율적으로 사용할 수 있게 해줍니다.
또한 Virtual Thread 간의 컨텍스트 스위칭은 운영체제 커널을 거치지 않고 JVM 내부에서 처리되므로,
그 비용이 매우 적습니다. 이는 높은 수준의 동시성을 가능케 합니다.
Virtual Thread는 블로킹 작업에 대해서 효과적입니다.
블로킹 작업이 있는 Virtual Thread는 실행을 양보하고(unmount)
다른 Virtual Thread가 실행을 계속할 수 있습니다.
이를 통해 시스템 자원을 최대한 활용할 수 있습니다.
이러한 특징들로 인해 Virtual Thread는 특히 웹 서버와 같이 많은 동시 요청을 처리해야 하는 시스템에 매우 유용할 것으로 기대됩니다. 적은 자원으로 높은 처리량과 낮은 지연 시간을 달성할 수 있기 때문입니다.
Virtual Thread의 도입으로 자바 개발자들은 보다 효율적이고 확장 가능한 동시성 처리 애플리케이션을 쉽게 개발할 수 있게 되었습니다. 이는 자바가 고처리량 I/O 바운드 애플리케이션 분야에서 더욱 경쟁력을 갖추게 되었음을 의미합니다.
가상 스레드 원리

전통적인 스레드 사용방식
- Java Thread (JVM 내부 객체) -> JNI로 호출 -> OS Thread (커널 수준 스레드) 1:1 맵핑
- 운영체제(OS) 수준에서 관리되는 무거운 스레드, 고정된 메모리 스택(약 1MB) 할당됨
- Thread 또는 ExecutorService 로 직접 생성하거나, 스레드 풀에서 재사용
- 스레드가 I/O(예: DB, API 호출) 대기 상태가 되면 OS 스레드가 블로킹됨, 따라서 동시 요청이 많을수록 스레드가 부족해짐
- 이를 해결하려고 Thread Pool, 비동기 I/O (CompletableFuture 등) 이 등장했으나 컨텍스트 스위칭 비용은 여전히 높다
JNI (Java Native Interface): 자바 코드가 운영체제(OS)의 네이티브 코드(C/C++)에 접근할 수 있게 해주는 다리 역할 즉, 자바가 파일, 네트워크, 스레드, 시스템콜 같은 OS 레벨 기능을 직접 쓸 수 있도록 JVM ↔ OS 사이에 놓인 통역기 역할


가상 스레드 사용방식
- I/O 대기(예: DB, API 호출)가 발생하면, JVM이 해당 Virtual Thread를 자동으로 중단(unmount)
→ OS Thread(Carrier)는 즉시 다른 Virtual Thread를 실행 - I/O 완료 시, JVM이 Virtual Thread를 다시 재개(mount)
→ OS Thread를 효율적으로 공유함으로써 CPU 낭비가 없음 - Virtual Thread는 필요할 때만 스택을 확장하는 구조로, 스레드당 수 KB 수준의 메모리만 사용
- JVM이 자체적으로 스케줄링하므로 OS 수준의 컨텍스트 스위칭이나 스케줄링 부하가 줄어듦
다른 동시성 처리 기술과 비교

웹플럭스 (Webflux) 간단 설명

스레드를 블로킹하지 않고, 이벤트 기반으로 요청을 처리하는 비동기 웹 프레임워크
| 구분 | 전통적인 Spring MVC | Spring WebFlux |
| 실행 모델 | 스레드당 요청 1개 (Blocking I/O) | 하나의 스레드가 여러 요청을 비동기로 처리 (Non-blocking I/O) |
| 기반 기술 | Servlet (Tomcat 등) | Reactor (Reactive Streams) |
| 프로그래밍 방식 | 동기식 (return User) | 리액티브식 (return Mono<User>) |
| 대표 타입 | List, ResponseEntity | Mono, Flux |
| 사용 사례 | CPU 중심 로직 | I/O 중심 로직 (DB, API 호출 등) |
- 요청이 들어오면 스레드가 바로 작업을 수행하지 않고, 이벤트 루프(Event Loop) 에 등록함
- DB, 외부 API 같은 I/O 대기 작업이 끝날 때만 콜백을 통해 결과를 전달
- 따라서 스레드가 블로킹되지 않고, 동시에 수천 개 요청을 처리 가능
- 기존의 Spring MVC 컨트롤러 코드
@GetMapping("/user")
public User getUser() {
return userService.getUser(); // I/O 동안 스레드 블로킹
}
- Webflux 컨트롤러 코드
@GetMapping("/user")
public Mono<User> getUser() {
return userService.getUserMono(); // I/O 동안 스레드는 다른 요청 처리 가능
}
⚠️ 단점
- 코드 복잡도 증가 (Mono, Flux, 체인 호출 등)
- 러닝커브가 높고 디버깅, 트랜잭션 처리 어려움
/**
* 클릭 데이터에 필요한 데이터를 Set 후 업로더로 메시지 발행한다.
**/
public Mono<Void> addClickData(ServerHttpRequest serverHttpRequest, ClickDataDto rowClickData) {
// 발송일 기준 60일이 지나면 클릭데이터를 수집하지 않는다.
return Mono.just(rowClickData)
.filter(clickData -> clickData.getSendDe().plusDays(60).isAfter(DateUtils.LocalDateNow()))
.doOnNext(clickDataSeq ->{
// 메시지 발행 전 필요 데이터 Set
clickDataSeq.setClickId(UUID.randomUUID().toString());
clickDataSeq.setIpAddress(UserUtils.getClientIp(serverHttpRequest));
clickDataSeq.setClickDt(DateUtils.LocalDateTimeNow().withNano(0));
// Uploader 로 클릭 데이터 Publish
publishMessageService.publishMessage(TOPIC_CLICK_UPLOADER, TextUtils.convertObjectToJson(clickDataSeq));
})
.onErrorContinue((error, clickDataSeq) -> {
log.info("[ON-ERROR_CONTINUE] clickDataSeq : {} / Cause : {}", TextUtils.convertObjectToJson(clickDataSeq), error.getMessage());
// Uploader 로 클릭 데이터 Publish
publishMessageService.publishMessage(TOPIC_CLICK_UPLOADER, TextUtils.convertObjectToJson(clickDataSeq));
})
.switchIfEmpty(Mono.empty())
.then();
}
⚠️ 주의할 점
- 이벤트 루프가 절대 blocking 되지 않도록 스케줄러 설정을 해야 함
- WebFlux는 이벤트루프 로 수천 개 요청을 처리합니다.
- 그런데 이 이벤트 루프가 Thread.sleep(), JDBC 호출, RestTemplate 같은 블로킹 코드를 만나면 그 동안 다른 요청을 처리하지 못하고 멈춥니다.
- 사용하는 라이브러리도 모두 reactive 라이브러리를 사용해야 함
- 모든 호출 경로가 end-to-end 비동기로 구성되어야 함
- 모든 호출 경로가 end-to-end 비동기로 구성되어야 함
코루틴 (coroutine) 간략 설명
스레드 위에서 실행되는 비동기 함수이며, 스레드를 블로킹하지 않고 suspend/resume을 통해
수천 개 동시 실행을 가능하게 하는 사용자 수준 스케줄링 모델 즉 관리 주체가 Kotlin Runtime(Coroutine Dispatcher)
스레드와의 관계
- 코루틴은 스레드 위에서 스케줄링되는 가벼운 작업 단위 (가상 스레드보다 가벼움)
- 한 스레드에서 수천 개 코루틴을 실행 가능
- I/O 대기 시 스레드 반납 → 다른 코루틴이 실행
- OS 스레드 수는 고정되어 있고, 코루틴 수는 수십만 개까지 확장 가능
📊 성능 비교
테스트 Spec
- 동시 요청 수 : 400건으로 부하 테스트 진행
- blocking I/O delay : 100ms (0.1초) 가정
결과

그라파나 지표



- 테스트 결과 요약
|
기준
|
요청수
|
평균 응답시간 (ms)
|
최대 응답시간(ms)
|
요청 처리량(ms)
|
비고
|
| Completable Future |
400
|
15510
|
203,940
|
1.1/sec
|
🟥 매우 느림 (스레드 블로킹) |
| Coroutine |
400
|
242
|
447
|
80.5/sec
|
🟩 매우 빠름 (논블로킹) |
| Virtual Thread |
400
|
275
|
426
|
70.6/sec
|
🟩 비슷하게 빠름 |
| WebFlux |
400
|
111
|
200
|
179.1/sec
|
🟩 최고 성능 |
- 각 방식별 해석
- 🟥 CompletableFuture
- 평균 응답시간: 15.5초 (15510ms)
- 요청 처리량: 1.1/sec
- 문제 원인:
- ForkJoinPool의 워커 스레드 수가 CPU 코어 수로 제한됨.
- 각 작업(supplyAsync)이 I/O 블로킹(Thread.sleep or 실제 API 대기)으로 스레드를 점유
- → 스레드가 묶여 새로운 요청을 처리할 스레드가 없음
- 결국 비동기로 보이지만 내부는 사실상 동기 순차 처리
- 🧩 결론: ForkJoinPool + 블로킹 I/O는 최악의 조합
- 🟨 Coroutine (Kotlin suspend fun)
- 평균 응답시간: 242ms
- 요청 처리량: 80.5/sec
- 거의 완전 논블로킹으로 동작
- I/O 발생 시 코루틴이 suspend → 스레드 반환 → 재개 시 resume→ 스레드 점유 최소화
- WebFlux보다는 약간 느림:
- 코루틴 자체의 스케줄링 오버헤드나 Dispatcher 구성이 영향을 줄 수 있음
- 🧩 즉: 비동기 I/O 최적화에 매우 효율적이며, Virtual Thread와 비슷한 수준
- 🟩 Virtual Thread (JDK 21~)
- 평균 응답시간: 275ms
- 요청 처리량: 70.6/sec
- 거의 Coroutine 수준의 성능
- 각 요청당 Virtual Thread 1개 생성되지만, I/O 시 언마운트(unmount)되어 OS 스레드를 반환 → 다른 Virtual Thread로 재활용. 따라서 실제 OS Thread 수는 제한적이지만,수천 개 동시 요청 처리 가능
- 🧩 결론: Coroutine과 구조적으로 비슷한 성능, JVM 레벨에서 suspend/resume 처리
- 🟩 WebFlux (Reactor Netty)
- 평균 응답시간: 111ms
- 요청 처리량: 179/sec → 가장 빠름
- 완전 논블로킹 I/O (EventLoop 기반)
- Reactor Scheduler가 Netty의 EventLoop 위에서 동작 → 소수의 스레드로 수천 커넥션 동시 처리
- CPU 효율 극대화, Context switching 최소
- 🧩 결론: pure non-blocking I/O의 끝판왕, CPU 바운드 작업만 아니면 가장 효율적
- 🟥 CompletableFuture
가상 스레드 사용 방법
1. application.yml 설정 : Tomcat의 요청 처리 스레드가 모두 가상 스레드로 전환됨
spring:
threads:
virtual:
enabled: true
2. @Async와 가상 스레드
@Configuration
@EnableAsync
class AsyncConfig {
@Bean(TaskExecutionAutoConfiguration.APPLICATION_TASK_EXECUTOR_BEAN_NAME)
fun asyncTaskExecutor(): AsyncTaskExecutor {
val executor = TaskExecutorAdapter(Executors.newVirtualThreadPerTaskExecutor())
executor.setTaskDecorator { runnable -> runnable }
return executor
}
}
3. 직접 가상 스레드 사용
@Service
class MyService {
fun processWithVirtualThread() {
Thread.startVirtualThread {
// 비동기 작업
println("Running on: ${Thread.currentThread()}")
}
}
// 또는 StructuredTaskScope 사용 (Java 21)
fun parallelTasks() {
StructuredTaskScope.ShutdownOnFailure().use { scope ->
val task1 = scope.fork { fetchData1() }
val task2 = scope.fork { fetchData2() }
scope.join()
scope.throwIfFailed()
val result1 = task1.get()
val result2 = task2.get()
}
}
}
'개발 > Java & Kotlin' 카테고리의 다른 글
| 내가 보려고 쓰는 Java와 Kotlin 차이 정리 #3 (0) | 2024.10.25 |
|---|---|
| 내가 보려고 쓰는 Java와 Kotlin 차이 정리 #2 (1) | 2024.10.16 |
| 내가 보려고 쓰는 Java와 Kotlin 차이 정리 #1 (0) | 2024.10.10 |
| JAVA에서 volatile란 무엇인가!? (0) | 2021.03.31 |
| OPEN JDK 1.8 윈도우 설치 방법 (0) | 2020.06.16 |