Post

CompletableFuture, Virtual Thread, @Async - 병렬 호출

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 ThreadVirtual 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되는지 확인할 수 있습니다.

Virtual thread / continuation
Carrier thread
I/O / park / unpark 이벤트
ForkJoinPool 스케줄러

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 ThreadVirtual Thread가 51% 이상 성능 향상
Virtual Thread vs Kotlin CoroutineVirtual 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 ThreadKotlin CoroutineReactive (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: 100core가 다 바쁘면 100개까지 큐에 대기
CallerRunsPolicy큐도 가득 차면 호출한 스레드가 직접 실행 (요청 유실 방지)

동작 흐름

1
2
3
4
요청 들어옴 → core 스레드 여유 있음? → YES → core 스레드가 처리
                                      → NO  → 큐에 넣기 (100개까지)
                                               → 큐도 가득? → max까지 스레드 추가 (20개)
                                                              → max도 가득? → RejectionPolicy 실행

비교 정리

기준SimpleAsyncTaskExecutorThreadPoolTaskExecutor
스레드 생성매번 새로 생성풀에서 재사용
스레드 수 제한없음 (무한 생성 가능)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();
}

참고 자료

This post is licensed under CC BY 4.0 by the author.