비동기 코드를 짜다가 thenApply에 찍은 로그의 스레드 이름이 매번 달라지는 걸 보고 멈칫한 적이 있다. 어떤 때는 main, 어떤 때는 ForkJoinPool.commonPool-worker-1, 또 어떤 때는 내가 만든 적도 없는 풀 이름이 찍혔다. "그래서 이 콜백은 결국 누가 실행하는 거지?"가 풀리지 않으면 CompletableFuture로 짠 코드는 디버깅할 때마다 운에 기대게 된다.
이 글에서는 CompletableFuture가 등록한 콜백(thenApply, thenAccept, thenCompose …)을 어느 스레드가 실행하는지, 그리고 *Async 접미사와 Executor 인자가 그 결정을 어떻게 바꾸는지를 직접 따라가며 정리했다. 근거는 주로 Java SE API 문서(java.util.concurrent.CompletableFuture)의 설명에 기댔다.
CompletableFuture는 두 가지 역할을 한 객체에 합쳐 놓은 것으로 이해했다. 하나는 결과를 담는 미래값(Future), 다른 하나는 그 결과에 이어 붙는 콜백 체인이다. 콜백을 누가 실행하느냐는 이 둘이 만나는 시점에 결정된다.
비(非)Async 메서드의 스레드 규칙은 API 문서에 다음과 같은 취지로 적혀 있다.
접미사가 없는(non-async) 의존 작업은, 그 작업을 가능하게 만든(완성시킨) 스레드 또는 완성 메서드를 호출한 스레드에서 실행될 수 있다.
핵심은 이 한 문장으로 정리된다.
콜백을 누가 실행하는지는 "콜백을 등록한 시점에 이미 결과가 있었는가" 에 달려 있다.
즉 non-async 콜백은 "전용 스레드 풀"이 따로 있는 게 아니라, 그 순간 일을 끝낸 쪽이 손에 묻은 김에 마저 처리하는 구조다. 그래서 같은 코드라도 타이밍에 따라 실행 스레드가 달라진다.
CompletableFuture.supplyAsync(supplier)처럼 Executor를 주지 않은 *Async 메서드는 기본 실행자(default asynchronous execution facility), 즉 ForkJoinPool.commonPool()을 쓰는 것으로 알려져 있다. 그래서 스레드 이름이 ForkJoinPool.commonPool-worker-N으로 찍힌다.
여기서 한 가지 함정이 있다. API 문서에 따르면 commonPool이 병렬도(parallelism)를 1 이상 지원하지 않는 환경(예: CPU 코어가 하나로 잡히는 경우)에서는, 작업마다 별도 스레드를 만들어 실행할 수도 있다고 한다. 컨테이너에 CPU 1개만 할당한 환경에서 commonPool 동작이 평소와 다르게 보일 수 있는 이유다.
같은 thenApply라도 접미사로 동작이 갈린다.
| 메서드 형태 | 실행 스레드 | 비고 |
|---|---|---|
thenApply(fn) | 완성시킨 스레드 또는 등록 스레드 | 풀이 따로 없음 |
thenApplyAsync(fn) | 기본 실행자(commonPool) | Executor 미지정 시 |
thenApplyAsync(fn, executor) | 넘긴 executor의 스레드 | 풀을 직접 지정 |
Async가 붙으면 "콜백을 항상 실행자에 다시 제출(submit)한다"는 뜻으로 이해하면 맞다. 그래서 결과가 이미 준비돼 있어도 호출 스레드가 직접 처리하지 않고 풀에 넘긴다.
non-async 콜백의 부작용은 여기서 나온다. 이미 완성된 future에 thenApply로 무거운 작업을 걸면, 그 무거운 작업이 등록을 호출한 스레드에서 동기적으로 실행된다. 만약 그 스레드가 이벤트 루프나 요청 처리 스레드라면 그대로 블로킹된다. "비동기로 짰는데 왜 메인 스레드가 멈추지?"의 흔한 원인이다.
스레드 이름을 직접 찍어 보면 규칙이 눈에 들어온다.
import java.util.concurrent.*;
public class CfThreadDemo {
static void log(String tag) {
System.out.println(tag + " -> " + Thread.currentThread().getName());
}
public static void main(String[] args) throws Exception {
// (A) 이미 완성된 future에 non-async 콜백
CompletableFuture<String> done = CompletableFuture.completedFuture("ok");
done.thenApply(v -> { log("A non-async"); return v; });
// 출력: A non-async -> main (등록한 스레드가 직접 실행)
// (B) supplyAsync 뒤 non-async 콜백
CompletableFuture.supplyAsync(() -> { log("B supply"); return 1; })
.thenApply(v -> { log("B then"); return v; });
// supply, then 둘 다 commonPool-worker (완성시킨 스레드가 이어서)
// (C) async + 전용 executor
ExecutorService pool = Executors.newFixedThreadPool(2, r -> {
Thread t = new Thread(r); t.setName("my-pool"); return t;
});
CompletableFuture.supplyAsync(() -> 1, pool)
.thenApplyAsync(v -> { log("C thenAsync"); return v; }, pool);
// 출력: C thenAsync -> my-pool
Thread.sleep(200); // 데몬 풀 콜백을 기다리기 위함
pool.shutdown();
}
}
(A)에서 main이 찍히는 게 핵심이다. completedFuture는 이미 완성 상태라, non-async 콜백이 풀로 가지 않고 등록 스레드에서 즉시 돈다. 반면 (B)는 등록 시점에 아직 미완성이라 commonPool worker가 supply와 then을 모두 잡는다. 타이밍에 따라 (B)의 thenApply가 main에서 돌 수도 있는데, 이는 supplyAsync가 너무 빨리 끝나 등록 전에 이미 완성된 경우다.
정리하면 (A)와 (B)의 차이는 코드가 아니라 "등록과 완성 중 무엇이 먼저였나" 라는 타이밍 차이다.
엣지 케이스 하나. commonPool의 스레드는 데몬 스레드라서, 메인이 끝나면 콜백이 다 돌기 전에 JVM이 종료될 수 있다. 데모에서 Thread.sleep이나 join()을 넣는 이유다.
*Async는 항상 실행자에 다시 제출한다. Executor를 안 주면 ForkJoinPool.commonPool()을 쓴다.*Async(fn, executor)로 전용 풀을 명시하는 편이 예측 가능하다. 특히 요청 처리 스레드나 이벤트 루프가 콜백에 잡히는 걸 막을 수 있다.더 파고들 만한 주제로는 ① commonPool의 병렬도 설정(java.util.concurrent.ForkJoinPool.common.parallelism)이 비동기 처리량에 미치는 영향, ② thenCompose / allOf 같은 조합 메서드에서의 스레드 전파 규칙 두 가지를 꼽았다.
java.util.concurrent.CompletableFuture (클래스 설명의 비동기/non-async 실행 규칙 단락)java.util.concurrent.ForkJoinPool#commonPool()