CompletableFuture, Virtual Thread, @Async - 병렬 호출
CompletableFuture
기본 개념
CompletableFuture는 Java 8에서 도입된 비동기 프로그래밍 API입니다. 기존 Future는 결과를 get()으로 블로킹해서 가져와야 했지만, CompletableFuture는 콜백 체이닝으로 비동기 흐름을 구성할 수 있습니다.
supplyAsync - 비동기 작업 시작
1
2
3
4
// 별도 스레드에서 실행하고, 결과를 CompletableFuture로 받는다
CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
return callExternalApi(); // 이 코드는 다른 스레드에서 실행됨
});
supplyAsync는 기본적으로 ForkJoinPool.commonPool()을 사용합니다. 별도 Executor를 지정할 수도 있습니다.
1
2
3
4
5
6
ExecutorService executor = Executors.newFixedThreadPool(10);
CompletableFuture<String> future = CompletableFuture.supplyAsync(
() -> callExternalApi(),
executor // 사용할 스레드 풀 지정
);
allOf - 여러 작업을 동시에 실행하고 모두 완료될 때까지 대기
1
2
3
4
5
6
7
8
9
10
11
12
CompletableFuture<List<Drug>> easyFuture =
CompletableFuture.supplyAsync(() -> searchEasyDrug(name));
CompletableFuture<List<Drug>> pillFuture =
CompletableFuture.supplyAsync(() -> searchPillIdentification(name));
// 두 작업이 모두 완료될 때까지 대기
CompletableFuture.allOf(easyFuture, pillFuture).join();
// 결과 꺼내기
List<Drug> easyResults = easyFuture.join();
List<Drug> pillResults = pillFuture.join();
핵심은 두 API 호출이 동시에 시작된다는 것입니다. 순차 호출이면 1초 + 1초 = 2초이지만, 병렬이면 max(1초, 1초) = 1초입니다.
join vs get
둘 다 결과를 가져오는 메서드이지만 차이가 있습니다.
| 메서드 | checked 예외 | unchecked 예외 |
|---|---|---|
get() | ExecutionException, InterruptedException 처리 필수 | - |
join() | - | CompletionException으로 래핑 |
join()은 checked 예외를 강제하지 않으므로 스트림이나 람다 안에서 쓰기 편합니다.
1
2
3
4
5
6
7
8
9
// get() - try-catch 필수
try {
String result = future.get();
} catch (ExecutionException | InterruptedException e) {
// ...
}
// join() - unchecked 예외
String result = future.join(); // CompletionException이 발생할 수 있음
exceptionally - 예외 발생 시 대체값 반환
1
2
3
4
5
6
CompletableFuture<List<Drug>> future = CompletableFuture
.supplyAsync(() -> searchEasyDrug(name))
.exceptionally(ex -> {
log.warn("약 검색 실패, 빈 리스트 반환: {}", ex.getMessage());
return Collections.emptyList(); // 에러 시 빈 리스트로 대체
});
외부 API 호출에서 특히 유용합니다. 하나의 API가 실패해도 다른 API 결과는 정상적으로 반환할 수 있습니다.
exceptionally vs handle의 차이:
1
2
3
4
5
6
7
8
// exceptionally: 에러일 때만 실행
.exceptionally(ex -> fallbackValue)
// handle: 성공/실패 모두 실행
.handle((result, ex) -> {
if (ex != null) return fallbackValue;
return transform(result); // 성공 시 변환도 가능
})
Java 21 Virtual Thread
Platform Thread vs Virtual Thread
기존 Java 스레드(Platform Thread)는 OS 스레드와 1:1로 매핑됩니다. Java의 유저 스레드를 만들면 JNI를 통해 커널 영역을 호출하여 OS가 커널 스레드를 생성하고 매핑합니다. OS 스레드는 비싸서 보통 수백 개가 한계입니다.
1
2
3
4
5
[Platform Thread 모델]
Java Thread 1 ←→ OS Thread 1 (1:1 매핑, 비쌈)
Java Thread 2 ←→ OS Thread 2
...
Java Thread 200 ←→ OS Thread 200 ← 여기가 한계
스레드 1개당 약 1MB 사이즈라고 가정하면, 4GB 메모리 환경에서 최대 4,000개가 한계입니다. 스레드가 많아지면 컨텍스트 스위칭 비용도 기하급수적으로 늘어납니다.
Virtual Thread는 JVM이 관리하는 경량 스레드로, 플랫폼 스레드 위에 N:M으로 매핑됩니다.
1
2
3
4
5
6
7
8
9
[Virtual Thread 모델]
Virtual Thread 1 ─┐
Virtual Thread 2 ├→ Carrier Thread 1 (Platform Thread)
Virtual Thread 3 ─┘
Virtual Thread 4 ─┐
Virtual Thread 5 ├→ Carrier Thread 2 (Platform Thread)
Virtual Thread 6 ─┘
...
Virtual Thread 10000 → 실제 OS Thread는 수십 개만 사용
| 항목 | Platform Thread | Virtual Thread |
|---|---|---|
| Stack 사이즈 | ~2MB | ~10KB |
| 생성 시간 | ~1ms | ~1us |
| 컨텍스트 스위칭 | ~100us (커널 영역) | ~10us (JVM 영역) |
Virtual Thread는 JVM에 의해 생성되므로 시스템 콜이 필요 없고, 메모리 크기가 Platform Thread의 1% 수준입니다.
Carrier Thread와 동작 원리
Carrier Thread는 Virtual Thread를 실제로 실행하는 플랫폼 스레드입니다. Virtual Thread는 carrier thread 위에 “올라타서” 실행됩니다.
Virtual Thread의 기본 스케줄러는 ForkJoinPool을 사용합니다. 스케줄러가 carrier thread pool을 관리하고, Virtual Thread의 작업 분배를 담당합니다.
1
2
3
4
5
[Virtual Thread 내부 구조]
VirtualThread
├── carrierThread: 실제 작업을 수행하는 platform thread
├── scheduler: ForkJoinPool (carrier thread pool 관리, 작업 스케줄링)
└── runContinuation: 실제 작업 내용 (Runnable)
동작 흐름:
1
2
3
4
1. virtual thread의 runContinuation을 carrier thread의 workQueue에 push
2. ForkJoinPool이 work stealing 방식으로 carrier thread에 작업 분배
3. I/O, Sleep, interrupt 시 workQueue에서 pop → park → 힙 메모리로 이동
4. I/O 완료 시 unpark → 다시 workQueue에 push → carrier thread가 처리
1
2
3
4
5
6
7
8
9
10
Virtual Thread A가 실행 중:
Carrier Thread 1 ← VT-A가 올라타 있음 (mount)
Virtual Thread A가 블로킹 I/O 시작 (외부 API 호출):
VT-A park → 힙 메모리로 이동 (unmount)
Carrier Thread 1 ← VT-B가 올라탐 (mount) → 다른 작업 실행!
Virtual Thread A의 I/O 완료:
VT-A unpark → workQueue에 push
Carrier Thread 2 ← VT-A가 다시 올라탐 (다른 carrier일 수 있음)
핵심은 블로킹 I/O 중에 carrier thread를 반환한다는 것입니다. Platform Thread는 I/O 대기 중에도 OS 스레드를 점유하지만, Virtual Thread는 park되어 힙 메모리로 이동하고 carrier thread를 양보합니다. 그래서 외부 API 호출이 많은 상황에서 특히 유리합니다.
Virtual Thread 라이프사이클 시각화
아래 버튼을 클릭하면 각 단계별로 Virtual Thread가 어떻게 생성되고, mount/unmount되는지 확인할 수 있습니다.
JDK 21에서 park/unpark가 가능한 이유
JDK 21에서는 기존 Thread 모델의 park/unpark 로직에 Virtual Thread 분기를 추가했습니다.
- LockSupport: 현재 스레드가 Virtual Thread인지 확인 후, VT이면 virtual thread 전용 park/unpark 수행
- NIOSocketImpl.park(): Socket I/O 시 VT이면
Poller.poll()로 virtual thread park 수행 (기존에는Net.poll()로 커널 영역에 요청) - Thread.sleep(): VT이면 virtual thread 기반 park 수행
기존 Thread 방식에서 코드 수정 없이 Virtual Thread 기반 컨텍스트 스위칭이 가능한 이유입니다.
Thread.ofVirtual()
1
2
3
4
5
6
7
8
// Virtual Thread 생성
Thread vt = Thread.ofVirtual().start(() -> {
System.out.println("I'm a virtual thread!");
});
// Virtual Thread용 Executor
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
executor.submit(() -> callExternalApi());
newVirtualThreadPerTaskExecutor()는 작업마다 새 Virtual Thread를 만듭니다. 풀이 아니라 매번 새로 생성하지만, Virtual Thread는 매우 가벼워서 문제 없습니다.
성능 비교 (우아한형제들 벤치마크)
우아한형제들 기술블로그에서 256MB 힙, Ngrinder를 사용한 테스트 결과입니다.
I/O Bound 작업 (300ms sleep API 3번 호출):
| 구분 | 결과 |
|---|---|
| Thread vs Virtual Thread | Virtual Thread가 51% 이상 성능 향상 |
| Virtual Thread vs Kotlin Coroutine | Virtual Thread가 37% 더 좋은 성능 |
| Virtual Thread vs Reactive (WebFlux) | Virtual Thread가 111% 더 좋은 성능 |
특히 vuser(동시 사용자)를 250 이상으로 올리면 Platform Thread 서버는 죽었지만, Virtual Thread 서버는 정상 처리했습니다.
CPU Bound 작업 (0~3억 합산 3번):
CPU Bound에서는 오히려 Platform Thread가 더 빨랐습니다. Virtual Thread가 carrier thread 위에서 동작하므로, 컨텍스트 스위칭이 없는 순수 CPU 작업에서는 Virtual Thread 생성/스케줄링 비용만 추가됩니다.
“It is more expensive to run a task in a virtual thread than running it in a platform thread.” - Oracle
결론: Virtual Thread는 I/O Bound 작업에 최적화되어 있고, CPU Bound 작업에는 적합하지 않습니다. 외부 API 호출이 많은 DrugSearchService 같은 경우에 딱 맞는 기술입니다.
카카오페이 성능 비교 결과
카카오페이 기술블로그의 벤치마크에 따르면:
| 구분 | Virtual Thread | 코루틴 |
|---|---|---|
| CPU 바운드 작업 (소수 찾기 10만번) | 약 1,910ms | 약 2,300ms |
| 메모리 사용량 | 약 30MB | 약 67MB |
Virtual Thread가 10~15% 더 빠르고, 메모리도 절반 수준이었습니다.
다른 경량 스레드 모델과의 비교 정리
| 기준 | Virtual Thread | Kotlin Coroutine | Reactive (WebFlux) |
|---|---|---|---|
| 코드 변경 | 거의 없음 | suspend 함수 전파 | 전체 리액티브 전환 필요 |
| 함수 색 문제 | 없음 | suspend 전염성 있음 | Mono/Flux 전염성 있음 |
| 디버깅 | 기존과 동일 (스택 트레이스 유지) | 비동기 스택 트레이스 어려움 | 스택 트레이스 유실 |
| 적용 범위 | 애플리케이션 전체 (Executor 교체) | 메서드 단위 선택 적용 | 전체 스택 전환 |
| JDK 버전 | 21+ 필수 | JDK 버전 무관 | JDK 버전 무관 |
| 러닝 커브 | 낮음 | 중간 (Kotlin 학습 필요) | 높음 (Reactor 학습 필요) |
spring.threads.virtual.enabled=true
Spring Boot 3.2+에서는 이 설정 한 줄로 Virtual Thread를 활성화할 수 있습니다.
1
2
3
4
5
# application.yml
spring:
threads:
virtual:
enabled: true
이 설정을 켜면:
- 톰캣의 요청 처리 스레드가 Virtual Thread로 바뀜
@Async작업도 Virtual Thread에서 실행됨- 기존 코드 변경 없이 적용 가능
즉, 모든 HTTP 요청이 Virtual Thread에서 처리되므로, 외부 API 호출로 블로킹되어도 carrier thread를 반환하여 다른 요청을 처리할 수 있습니다.
@Async + ThreadPoolTaskExecutor vs SimpleAsyncTaskExecutor
SimpleAsyncTaskExecutor (기본값)
@EnableAsync만 선언하고 커스텀 Executor를 등록하지 않으면 SimpleAsyncTaskExecutor가 사용됩니다.
1
2
3
4
5
6
7
8
@EnableAsync
@SpringBootApplication
public class MyApplication { }
@Async
public CompletableFuture<List<Drug>> searchAsync(String name) {
return CompletableFuture.completedFuture(searchEasyDrug(name));
}
문제는 SimpleAsyncTaskExecutor가 요청마다 새 스레드를 생성한다는 것입니다. 풀이 아니므로 동시 요청이 폭주하면 스레드가 무한 생성되어 OOM이 발생할 수 있습니다.
ThreadPoolTaskExecutor (권장)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Configuration
@EnableAsync
public class AsyncConfig {
@Bean("taskExecutor")
public Executor taskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(10); // 기본 스레드 수
executor.setMaxPoolSize(20); // 최대 스레드 수
executor.setQueueCapacity(100); // 대기 큐 크기
executor.setThreadNamePrefix("async-");
executor.setRejectionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
executor.initialize();
return executor;
}
}
| 설정 | 의미 |
|---|---|
corePoolSize: 10 | 항상 유지되는 스레드 수 |
maxPoolSize: 20 | 큐가 가득 차면 최대 20개까지 스레드 증가 |
queueCapacity: 100 | core가 다 바쁘면 100개까지 큐에 대기 |
CallerRunsPolicy | 큐도 가득 차면 호출한 스레드가 직접 실행 (요청 유실 방지) |
동작 흐름
1
2
3
4
요청 들어옴 → core 스레드 여유 있음? → YES → core 스레드가 처리
→ NO → 큐에 넣기 (100개까지)
→ 큐도 가득? → max까지 스레드 추가 (20개)
→ max도 가득? → RejectionPolicy 실행
비교 정리
| 기준 | SimpleAsyncTaskExecutor | ThreadPoolTaskExecutor |
|---|---|---|
| 스레드 생성 | 매번 새로 생성 | 풀에서 재사용 |
| 스레드 수 제한 | 없음 (무한 생성 가능) | maxPoolSize로 제한 |
| OOM 위험 | 있음 | 없음 (큐 + 제한) |
| 적합한 상황 | 테스트용 | 운영 환경 |
Structured Concurrency (Java 21 Preview)
문제: CompletableFuture의 에러 전파
1
2
3
4
5
CompletableFuture<A> f1 = CompletableFuture.supplyAsync(() -> callApi1());
CompletableFuture<B> f2 = CompletableFuture.supplyAsync(() -> callApi2());
// f1이 실패해도 f2는 계속 실행 중... 자원 낭비!
CompletableFuture.allOf(f1, f2).join();
allOf는 하나가 실패해도 다른 작업을 취소하지 않습니다.
Structured Concurrency의 해결
1
2
3
4
5
6
7
8
9
10
11
// Java 21 Preview - StructuredTaskScope
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
Subtask<A> f1 = scope.fork(() -> callApi1());
Subtask<B> f2 = scope.fork(() -> callApi2());
scope.join(); // 모두 완료 대기
scope.throwIfFailed(); // 하나라도 실패하면 예외 (나머지도 자동 취소!)
A result1 = f1.get();
B result2 = f2.get();
}
핵심 차이:
- ShutdownOnFailure: 하나가 실패하면 나머지 작업을 자동 취소 → 자원 낭비 방지
- ShutdownOnSuccess: 하나가 성공하면 나머지를 취소 → “가장 빠른 결과” 패턴
try-with-resources로 scope가 닫히면 모든 subtask가 정리됨
아직 Preview 기능이므로 프로덕션에서는 주의가 필요합니다. 하지만 방향성을 이해해두면 좋습니다.
Virtual Thread Pinning (synchronized 블록 문제)
Pinning이란
Virtual Thread가 carrier thread에서 내려오지 못하고 고정되는 현상입니다.
정상 동작:
1
VT-A가 I/O 대기 → carrier thread에서 내려옴 (unmount) → 다른 VT가 사용
Pinning 발생:
1
2
VT-A가 synchronized 블록 안에서 I/O 대기 → carrier thread에서 못 내려옴!
→ carrier thread가 점유된 채로 대기 → 다른 VT가 실행 못 함
왜 synchronized에서 발생하는가
synchronized는 모니터 락을 사용하는데, 이 락은 특정 OS 스레드에 바인딩됩니다. Virtual Thread가 carrier thread에서 내려오면 락의 소유권이 깨지기 때문에, JVM이 내려오는 것을 막습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// Pinning 발생하는 코드
synchronized (lock) {
restTemplate.getForObject(url, String.class); // 블로킹 I/O
// → carrier thread가 점유된 채로 I/O 대기!
}
// 해결: ReentrantLock 사용
private final ReentrantLock lock = new ReentrantLock();
lock.lock();
try {
restTemplate.getForObject(url, String.class); // 블로킹 I/O
// → carrier thread에서 정상적으로 unmount 가능
} finally {
lock.unlock();
}