CompletableFuture는 콜백을 어느 스레드에서 실행하는가

seonwoo_jung·3일 전

1. 도입

비동기 코드를 짜다가 thenApply에 찍은 로그의 스레드 이름이 매번 달라지는 걸 보고 멈칫한 적이 있다. 어떤 때는 main, 어떤 때는 ForkJoinPool.commonPool-worker-1, 또 어떤 때는 내가 만든 적도 없는 풀 이름이 찍혔다. "그래서 이 콜백은 결국 누가 실행하는 거지?"가 풀리지 않으면 CompletableFuture로 짠 코드는 디버깅할 때마다 운에 기대게 된다.

이 글에서는 CompletableFuture가 등록한 콜백(thenApply, thenAccept, thenCompose …)을 어느 스레드가 실행하는지, 그리고 *Async 접미사와 Executor 인자가 그 결정을 어떻게 바꾸는지를 직접 따라가며 정리했다. 근거는 주로 Java SE API 문서(java.util.concurrent.CompletableFuture)의 설명에 기댔다.

2. 핵심 개념: "완성시킨 스레드"라는 규칙

CompletableFuture는 두 가지 역할을 한 객체에 합쳐 놓은 것으로 이해했다. 하나는 결과를 담는 미래값(Future), 다른 하나는 그 결과에 이어 붙는 콜백 체인이다. 콜백을 누가 실행하느냐는 이 둘이 만나는 시점에 결정된다.

비(非)Async 메서드의 스레드 규칙은 API 문서에 다음과 같은 취지로 적혀 있다.

접미사가 없는(non-async) 의존 작업은, 그 작업을 가능하게 만든(완성시킨) 스레드 또는 완성 메서드를 호출한 스레드에서 실행될 수 있다.

핵심은 이 한 문장으로 정리된다.

콜백을 누가 실행하는지는 "콜백을 등록한 시점에 이미 결과가 있었는가" 에 달려 있다.

  • 등록 시점에 아직 미완성이면 → 나중에 그 future를 완성시키는(complete) 스레드가 콜백까지 이어서 실행한다.
  • 등록 시점에 이미 완성돼 있으면 → 콜백을 등록하는(호출하는) 스레드가 그 자리에서 바로 실행한다.

즉 non-async 콜백은 "전용 스레드 풀"이 따로 있는 게 아니라, 그 순간 일을 끝낸 쪽이 손에 묻은 김에 마저 처리하는 구조다. 그래서 같은 코드라도 타이밍에 따라 실행 스레드가 달라진다.

3. 내부 동작: commonPool, Async, Executor

3.1 supplyAsync의 기본 풀

CompletableFuture.supplyAsync(supplier)처럼 Executor를 주지 않은 *Async 메서드는 기본 실행자(default asynchronous execution facility), 즉 ForkJoinPool.commonPool()을 쓰는 것으로 알려져 있다. 그래서 스레드 이름이 ForkJoinPool.commonPool-worker-N으로 찍힌다.

여기서 한 가지 함정이 있다. API 문서에 따르면 commonPool이 병렬도(parallelism)를 1 이상 지원하지 않는 환경(예: CPU 코어가 하나로 잡히는 경우)에서는, 작업마다 별도 스레드를 만들어 실행할 수도 있다고 한다. 컨테이너에 CPU 1개만 할당한 환경에서 commonPool 동작이 평소와 다르게 보일 수 있는 이유다.

3.2 Async vs non-async

같은 thenApply라도 접미사로 동작이 갈린다.

메서드 형태실행 스레드비고
thenApply(fn)완성시킨 스레드 또는 등록 스레드풀이 따로 없음
thenApplyAsync(fn)기본 실행자(commonPool)Executor 미지정 시
thenApplyAsync(fn, executor)넘긴 executor의 스레드풀을 직접 지정

Async가 붙으면 "콜백을 항상 실행자에 다시 제출(submit)한다"는 뜻으로 이해하면 맞다. 그래서 결과가 이미 준비돼 있어도 호출 스레드가 직접 처리하지 않고 풀에 넘긴다.

3.3 콜백이 호출 스레드를 막는 경우

non-async 콜백의 부작용은 여기서 나온다. 이미 완성된 future에 thenApply로 무거운 작업을 걸면, 그 무거운 작업이 등록을 호출한 스레드에서 동기적으로 실행된다. 만약 그 스레드가 이벤트 루프나 요청 처리 스레드라면 그대로 블로킹된다. "비동기로 짰는데 왜 메인 스레드가 멈추지?"의 흔한 원인이다.

4. 예시 / 코드

스레드 이름을 직접 찍어 보면 규칙이 눈에 들어온다.

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)의 thenApplymain에서 돌 수도 있는데, 이는 supplyAsync가 너무 빨리 끝나 등록 전에 이미 완성된 경우다.

정리하면 (A)와 (B)의 차이는 코드가 아니라 "등록과 완성 중 무엇이 먼저였나" 라는 타이밍 차이다.

엣지 케이스 하나. commonPool의 스레드는 데몬 스레드라서, 메인이 끝나면 콜백이 다 돌기 전에 JVM이 종료될 수 있다. 데모에서 Thread.sleep이나 join()을 넣는 이유다.

5. 정리

  • non-async 콜백은 전용 풀이 없다. 완성시킨 스레드 또는 (이미 완성됐다면) 등록한 스레드가 실행한다.
  • *Async는 항상 실행자에 다시 제출한다. Executor를 안 주면 ForkJoinPool.commonPool()을 쓴다.
  • 실행 스레드를 통제하고 싶으면 *Async(fn, executor)로 전용 풀을 명시하는 편이 예측 가능하다. 특히 요청 처리 스레드나 이벤트 루프가 콜백에 잡히는 걸 막을 수 있다.

더 파고들 만한 주제로는 ① commonPool의 병렬도 설정(java.util.concurrent.ForkJoinPool.common.parallelism)이 비동기 처리량에 미치는 영향, ② thenCompose / allOf 같은 조합 메서드에서의 스레드 전파 규칙 두 가지를 꼽았다.

참고 자료

  • Java SE API Documentation — java.util.concurrent.CompletableFuture (클래스 설명의 비동기/non-async 실행 규칙 단락)
  • Java SE API Documentation — java.util.concurrent.ForkJoinPool#commonPool()

0개의 댓글