[Java] 비동기 프로그래밍: Future에서 Virtual Threads까지

2026. 3. 25. 14:53Language/Java

Future (Java 5) — 비동기의 시작, 블로킹의 모순

자바에서는 new Thread(task).start()를 통해 비동기 실행을 할 수 있습니다. 다만, 요청마다 스레드를 새로 만들게 되면 스레드의 생성/소멸 비용이 매번 발생하고, 스레드가 무한정 늘어나서 결국에는 서버가 터질 수 있습니다. 

 

그래서 자바에서는 Executor 클래스를 통해 스레드의 수를 제한하고 재사용함으로써 이문제를 해결합니다. 

ExecutorService executor = Executors.newFixedThreadPool(10);
// 스레드 10개짜리 풀 생성 — 이 10개를 돌려쓰겠다는 선언

Future<String> future = executor.submit(() -> {
    Thread.sleep(2000);
    return "Data";
});

 

submit()은 이 스레드 풀에 작업을 맡기는 행위로 스레드 풀에서 놀고 잇는 스레드가 잇으면 즉시 그 스레드를 작업에 할당합니다. 호출한 스레드에게는 Future 객체를 즉시 반환하고 작업 완료를 기다리지 않습니다. 

 

executor.submit()을 호출하는 순간 작업은 별도 스레드에서 실행되기 시작합니다. 앞서 말한것처럼 Future 객체를 즉시반환하고 다음 코드를 실행하는 메커니즘은 비동기로 동작합니다. 

Future<String> future = executor.submit(() -> heavyWork()); // 즉시 반환
doSomethingElse(); // 작업이 돌아가는 동안 여기도 실행 가능
String result = future.get(); // 여기서 블로킹

 

즉, 작업의 사작은 비동기로 호출자와 작업이 동시에 실행되게 됩니다. 다만 실제로 비동기 작업의 결과를 쓰려면 future.get() 메서드를 호출해야하는데 그 순간 스레드는 멈춰버립니다. 비동기라고 이름을 붙인 API가 사실상 동작처럼 동작하는 아이러니한 상황이 생겼습니다. 

왜 블로킹이 발생하는가? 
future.get() 은 결과 값을 호출 스레드의 스택 프레임으로 복사해야 합니다. 값이 아직 메모리에 없으면, OS는 해당 스레드의 Program Counter를 멈추고 상태를 WAITING으로 전환합니다. 스레드는 아무 일도 하지 않으면서 약 1MB의 스택 메모리를 계속 점유합니다. 동시에 1,000개의 요청이 대기 중이라면 결과도 없이 1GB가 낭비되는 셈입니다.
ExecutorService executor = Executors.newFixedThreadPool(10);
Future<String> future = executor.submit(() -> {
    Thread.sleep(2000); // I/O 대기 시뮬레이션
    return "Data";
});

// 이 시점에 호출 스레드가 WAITING으로 전환 — 자원 낭비 시작
String result = future.get(); 

 

Future의 근본적인 문제는 결과를 Pull 하는 구조이기 때문에 결과가 언제 올지 모르니 기다릴수 밖에 없고, 그 결과 스레드 점유가 필요한것 입니다. 


CompletableFuture (Java 8) — Push 모델과 제어의 역전

Java 8의 CompletableFuture는 Future와 달리 결과를 기다리는 대신 "결과가 오면 이걸 실행해줘" 라고 미리 선로를 깔아두는 방식입니다. 

CompletableFuture
    .supplyAsync(() -> fetchUser(userId))          // ForkJoinPool에서 실행
    .thenApply(user -> enrichWithOrders(user))   // 완료 이벤트 시 즉시 실행
    .thenCompose(user ->
        CompletableFuture.supplyAsync(           // 중첩 비동기도 평탄화 가능
            () -> callExternalApi(user)))
    .exceptionally(ex -> fallbackUser())         // 예외도 파이프라인 안에서 처리
    .thenAccept(result ->
        log.info("done: {}", result));

 

CompletableFuture는 내부에 두 가지를 들고 있습니다. 

// 단순화한 내부 구조 (실제 구현은 더 복잡)
class CompletableFuture<T> {
    volatile Object result;      // 아직 없으면 null
    volatile Completion stack;   // 등록된 콜백들의 연결 리스트
}

 

thenApply(fn)를 호출하면 fn을 즉시 실행하는 게 아니라 stack에 콜백을 등록합니다. complete(value)가 호출되어 result에 값이 써지는 순간, stack에 쌓인 콜백들이 연쇄 실행됩니다.

CompletableFuture<String> cf = new CompletableFuture<>();

cf.thenApply(s -> s + "!")      // 콜백 등록 (아직 실행 안 됨)
  .thenApply(s -> s + "!")      // 콜백 등록 (아직 실행 안 됨)
  .thenAccept(System.out::println);

cf.complete("hello");  // 이 순간 "hello!!" 출력 — 콜백 연쇄 실행

 

2. thenApply vs thenCompose — 왜 헷갈리는가

이 둘의 차이는 콜백이 반환하는 것이 값이냐, 또 다른 Future냐의 차이입니다. thenApply는 동기 변환입니다. 값을 받아서 값을 반환합니다.

// String -> String 변환 (동기)
CompletableFuture<String> result =
    CompletableFuture.supplyAsync(() -> "hello")
                     .thenApply(s -> s.toUpperCase()); // "HELLO"

 

thenCompose는 비동기 변환입니다. 값을 받아서 Future를 반환하고, 그 Future를 평탄화합니다.

 
 
java
// String -> CompletableFuture<String> 반환 후 평탄화
CompletableFuture<String> result =
    CompletableFuture.supplyAsync(() -> "userId-123")
                     .thenCompose(id -> fetchUserAsync(id)); // Future<User>를 평탄화

thenApply에 Future를 반환하는 함수를 넘기면,

// 실수 — 중첩 Future 생성
CompletableFuture<CompletableFuture<User>> wrong =
    CompletableFuture.supplyAsync(() -> "userId-123")
                     .thenApply(id -> fetchUserAsync(id)); // 래핑이 한 겹 더 생김

// 이걸 꺼내 쓰려면 이중으로 get()해야 함 — 사실상 쓸 수 없는 타입
wrong.get().get(); // 이렇게 써야 한다면 설계가 잘못된 것

Stream의 map vs flatMap과 완전히 같은 구조입니다. 내부에서 비동기 작업이 나온다면 thenCompose(flatMap)을 써야 합니다.

왜 체이닝이 가능해졌는가?
내부적으로 완료 상태가 true로 바뀌는 순간, 연결된 CompletionStage들이 연쇄 실행되는 이벤트 리스너 구조입니다. 각 콜백은 대부분 ForkJoinPool의 워커 스레드에서 실행되므로, 호출 스레드는 선로만 깔고 자리를 떠날 수 있습니다.

 


ForkJoinPool — Work-Stealing이 빠른 진짜 이유

CompletableFuture의 기본 실행 엔진은 ForkJoinPool.commonPool()입니다. 일반 스레드 풀과 다르게 동작하는데, 그 핵심이 Work-Stealing입니다.

WORK-STEALING의 논리
일반 스레드 풀은 단일 공유 큐에서 작업을 꺼냅니다. 경합(Contention)이 발생할 수밖에 없습니다. ForkJoinPool은 각 워커 스레드가 개인 덱(Deque) 을 가집니다. 자신의 큐가 비면 다른 스레드 덱의 꼬리(Tail)에서 일을 훔쳐와 처리합니다. 본인은 앞(Head)에서 꺼내고, 도둑질은 뒤(Tail)에서 일어나므로 충돌이 최소화됩니다. 결과적으로 컨텍스트 스위칭이 줄고, 같은 스레드가 연관 작업을 이어 처리하므로 CPU 캐시 히트율도 높아집니다.
COMMONPOOL을 함부로 쓰면 안 되는 이유
commonPool은 JVM 전역 공유 자원입니다. supplyAsync()에 executor를 넘기지 않으면 여기서 실행됩니다. I/O 블로킹 작업이 commonPool을 점령하면 Parallel Stream을 비롯한 다른 곳에도 영향이 퍼집니다. 프로덕션에서는 반드시 별도 executor를 분리하세요.

Virtual Threads (Java 21) — M:N 매핑의 혁명

 

기존 OS 스레드(PLATFORM THREAD)가 무거운 이유
생성할 때마다 OS 커널 시스템 콜을 호출하고, 최소 1MB의 스택이 할당됩니다. 전환 시엔 커널 모드로 진입하는 컨텍스트 스위칭이 발생합니다. 보통 서버 한 대에 수천 개 이상 만들기가 현실적으로 어렵습니다.

 

Virtual Thread는 OS 자원이 아니라 JVM 힙에 생성되는 일반 객체입니다. I/O를 만나면 JVM은 해당 스레드의 스택 프레임을 힙에 저장하고, 실제 OS 스레드(Carrier Thread)에서 내려버립니다(Unmount). I/O가 완료되면 다시 적절한 Carrier Thread에 올라탑니다(Mount). OS 입장에선 Carrier Thread가 쉬지 않고 다른 객체를 처리하는 것처럼 보이므로, 커널 스위칭이 전혀 발생하지 않습니다.

// 20만 개를 생성해도 실제 OS 스레드는 소수만 사용됨
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    IntStream.range(0, 200_000).forEach(i -> {
        executor.submit(() -> {
            Thread.sleep(100); // 겉으론 블로킹, 실제론 Unmount
            return i;
        });
    });
} // try-with-resources로 자동 종료
PINNING — VIRTUAL THREAD의 치명적 함정
synchronized 블록이나 네이티브 메서드 내에서 I/O가 발생하면, Virtual Thread가 Carrier Thread에 고정(Pinned)되어 Unmount가 되지 않습니다. 이 경우 결국 OS 스레드를 점유하게 되어 Virtual Thread의 장점이 사라집니다.
ReentrantLock 으로 교체하거나, JVM 옵션 -Djdk.tracePinnedThreads=full 으로 문제 지점을 먼저 확인하세요.

결국 비동기의 역사는 "한정된 스레드로 어떻게 더 많은 요청을 처리할까"의 기록입니다. Future는 도구를 줬고, CompletableFuture는 조합을 줬고, Virtual Thread는 스레드 자체의 비용을 바꿨습니다. 각 기술의 탄생 이유를 이해하면, 어떤 상황에서 무엇을 선택해야 할지 자연스럽게 보입니다.