Skip to Content

11장. CompletableFuture의 내부 — 콜백 스택과 완료 경합

만화로 보는 요약 — 먼저 읽어보세요

만화로 보는 요약 — 먼저 읽어보세요

콜백을 1, 2, 3 순서로 등록했더니 3, 2, 1 순서로 실행됐습니다. 필드 이름 하나가 답이었어요 — 그냥 stack입니다.

면접 실전 질문: ① CompletableFuture의 콜백은 어느 스레드에서 실행되나요? ② thenApplythenApplyAsync는 무엇이 다른가요? ③ 콜백을 여러 개 등록하면 실행 순서가 보장되나요?


배경 — 상자에 목록을 하나 붙였다

10장의 결론은 이랬습니다. Future는 결과를 담을 뿐, “도착하면 무엇을 할지”를 담지 못한다고요.

CompletableFuture가 한 일은 상자에 목록을 하나 더 붙인 것입니다. 진짜로 필드가 두 개예요.

volatile Object result; // Either the result or boxed AltResult volatile Completion stack; // Top of Treiber stack of dependent actions

(JDK 25 CompletableFuture.java 265~266행의 원문 주석)

  • result — 완료 값입니다. null이면 아직 미완료예요. 결과가 진짜 null이거나 예외일 땐 AltResult로 박싱해 넣습니다. 그래서 “값이 null”과 “미완료”가 구분돼요. 4장에서 스택에 내 코드가 한 줄도 없던 그 CompletionException도 여기 AltResult 안에 들어 있습니다.
  • stack“완료되면 실행할 것들”의 목록입니다. 10장에서 없다고 한 그것이죠.

두 번째 필드 주석에 이 장의 핵심이 이미 적혀 있습니다. Treiber stack. 락 없이 CAS로만 밀어 넣는 스택이에요.

스택입니다. 큐가 아니라요. 이 단어 하나 때문에 잠시 뒤 ②에서 등록 순서가 뒤집힙니다.

CAS(compare-and-swap)는 *“지금 값이 A면 B로 바꿔라”*를 CPU 명령 하나로 처리하는 것입니다. 락을 잡지 않고도 “누가 먼저 바꿨나”를 판정할 수 있어요. Treiber는 사람 이름입니다(R. Kent Treiber, 1986).

스토리 — 두 사건이 경합한다

10장 마지막 질문이 이거였습니다.

“목록에 콜백을 넣는 순간과 상자가 완료되는 순간이 동시에 일어나면 어떻게 될까요?”

CompletableFuture에서 일어나는 일은 딱 두 종류입니다.

  • 등록thenApply(fn)CompletableFuture를 하나 만들어 돌려줍니다. fn을 감싼 노드는 원본의 stack에 CAS로 밀어 넣고요. 대기도, 스레드 예약도 없습니다.
  • 완료complete(v) 호출. result에 CAS로 값을 넣고, 그 자리에서 stack을 비우며 콜백들을 실행합니다.

둘은 서로 다른 스레드에서 아무 때나 일어날 수 있습니다. 그래서 콜백이 실행되는 경로가 둘로 갈려요.

순서누가 콜백을 실행하나
완료가 한참 먼저등록할 때 이미 result != null이니, 등록한 스레드가 그 자리에서 실행
등록이 한참 먼저완료시킨 스레드stack을 훑으며 실행
거의 동시에밀어 넣은 직후 result를 다시 확인해서, 등록한 스레드가 실행

세 번째 줄이 10장 질문의 진짜 답입니다. JDK 소스의 unipush()가 이렇게 생겼어요.

final void unipush(Completion c) { if (c != null) { while (!tryPushStack(c)) { if (result != null) { NEXT.set(c, null); break; } } if (result != null) c.tryFire(SYNC); // ← 밀어 넣은 직후 다시 확인. 등록한 스레드가 직접 실행 } }

“등록할 땐 미완료였는데 밀어 넣는 사이에 완료된” 경우, 등록한 스레드가 그 자리에서 콜백을 돌립니다. Javadoc도 이걸 인정해요 — “현재 CompletableFuture를 완료시킨 스레드가 수행할 수도 있고, 완료 메서드를 호출한 다른 어떤 호출자가 수행할 수도 있다.”

여기서 실무 함의가 나옵니다. “등록만 미리 해두면 내 요청 처리 스레드는 콜백을 안 돌린다”는 가정이 성립하지 않습니다. 특정 스레드가 예약돼 있는 게 아니라, 누가 늦었느냐로 갈리는 진짜 경합이에요.

7장에서 “커널은 콜백을 부르지 않는다”고 했죠. 여기가 그 자바 버전입니다. complete()를 부른 스레드가 자기 발로 여러분의 콜백을 실행합니다.

핵심 — 직접 확인해보기

측정 환경: Apple M1(8코어) / 램 16GB / macOS 26.5 / JDK 25. 전체 소스는 docs/book/code/async/Ch11CompletableFutureInside.java에 있습니다.

① 경합의 두 결과

완료를 먼저 시킨 경우와, 등록을 먼저 한 경우를 나눠 찍어봤습니다.

// A-1. 완료가 먼저 CompletableFuture<String> a1 = new CompletableFuture<>(); a1.complete("v"); // 먼저 완료 a1.thenApply(v -> { print(현재_스레드); return v; }); // 나중에 등록 // A-2. 등록이 먼저 CompletableFuture<String> a2 = new CompletableFuture<>(); a2.thenApply(v -> { print(현재_스레드); return v; }); // 먼저 등록 completer.submit(() -> a2.complete("v")); // 나중에 다른 스레드가 완료
A-1 이미 완료된 CF 에 등록 → 실행 스레드: main A-2 미완료 CF 에 등록 → 실행 스레드: 완료시키는-스레드

✅ 실측 (같은 환경, 2026-08)

표대로입니다. 같은 thenApply 한 줄인데 실행 스레드가 다릅니다. 코드를 바꾼 게 아니라 타이밍만 달랐어요.

여기서 실무 함정이 나옵니다. thenApply 안에 무거운 연산이나 블로킹 호출을 넣으면 어느 스레드가 그 부담을 질지 코드만 보고는 알 수 없습니다. 어떤 날은 HTTP 클라이언트의 I/O 스레드가, 어떤 날은 여러분의 요청 처리 스레드가 그 일을 합니다. 코드는 그대로인데요.

② 콜백 세 개를 등록하면 어떤 순서로 실행되나

미완료 CompletableFuture에 콜백을 1, 2, 3 순서로 등록하고 완료시켰습니다.

CompletableFuture<String> b = new CompletableFuture<>(); for (int i = 1; i <= 3; i++) { final int n = i; b.thenRun(() -> System.out.println(" 콜백 " + n + " 실행")); } b.complete("v");
콜백 3 실행 콜백 2 실행 콜백 1 실행

✅ 실측 (같은 환경, 2026-08. 5회 반복 모두 동일. 타이밍이 아니라 자료구조를 재는 측정이라 재현됩니다 — 다만 재현된다고 보장은 아닙니다. 바로 아래를 보세요.)

역순입니다. 1, 2, 3으로 등록했는데 3, 2, 1로 실행됐어요.

이유는 필드 주석에 이미 있었습니다. Treiber 스택. 등록할 때마다 스택 맨 위에 밀어 넣으니, 꺼낼 때는 마지막에 넣은 것부터 나옵니다. LIFO예요.

이건 문서화된 보장이 아닙니다. Javadoc은 여러 콜백의 실행 순서를 정의하지 않아요 — 지금 이렇게 동작할 뿐입니다. 그래서 결론은 하나입니다.

같은 CompletableFuture에 등록한 콜백들의 실행 순서에 절대 의존하지 마세요. 순서가 필요하면 등록을 나열하지 말고 체인으로 이으세요(thenApply().thenApply()).

체인이 왜 다른지도 분명합니다. 체인은 스택 하나에 셋을 쌓는 게 아니라 CF 세 개를 줄로 잇는 것이라, 앞이 완료돼야 뒤가 트입니다. 위 실험의 thenRun 세 번은 반환값을 전부 버리고 같은 스택 하나에 쌓은 거였어요.

실무에서 이렇게 물립니다. 로깅 콜백과 정리(cleanup) 콜백을 나란히 등록해두고 “로그가 먼저 찍히겠지” 생각하면, 반대로 나옵니다.

③ then* 계열은 어디서 도는가

마지막으로 세 가지 형태를 나란히 재봤습니다.

CompletableFuture<String> c = new CompletableFuture<>(); c.thenApply(...) // 스레드 안 옮김 .thenApplyAsync(...) // 기본 풀 .thenApplyAsync(..., mine); // 내 풀 completer.submit(() -> c.complete("v")); // ← "완료시키는-스레드"가 여기서 등장
thenApply : 완료시키는-스레드 thenApplyAsync(기본) : ForkJoinPool.commonPool-worker-1 thenApplyAsync(내 풀) : 내-전용-풀

✅ 실측 (같은 환경, 2026-08. 이 기계는 가용 코어 8, ForkJoinPool.getCommonPoolParallelism() = 7)

읽는 법은 이렇습니다.

  • thenApply — 스레드를 안 옮깁니다. ①에서 본 경합의 승자가 그대로 이어서 실행해요.
  • thenApplyAsync(인자 없음) — 기본 Executor로 옮기고, 그게 ForkJoinPool.commonPool입니다. JVM 전역 공용 풀이에요. 인자 없는 supplyAsync도 같은 풀을 씁니다.
  • thenApplyAsync(fn, myPool) — 지정한 풀로 옮깁니다.

두 번째가 위험합니다. commonPool의 병렬도는 가용 코어 − 1이고(이 기계에서 7로 실측됐습니다), 최솟값이 1입니다. cpu: 1 컨테이너에 띄우면 워커가 JVM 전체에 딱 하나예요. 그리고 이 풀은 JVM 전체가 공유합니다. 여기에 블로킹 호출을 던지면 병렬 스트림처럼 전혀 무관한 코드까지 함께 멈춰요. 14장이 통째로 이 이야기입니다.

그래서 실무 규칙은 단순합니다.

콜백이 가벼우면 thenApply, 무거우면 thenApplyAsync(fn, 전용풀). 인자 없는 thenApplyAsync는 CPU 작업에만, 그것도 짧은 것에만.

그래서 무엇이 논블로킹인가

이제 이 책의 첫 문장으로 돌아갈 수 있습니다.

CompletableFuture는 지금까지 본 게 전부입니다 — 필드 두 개와 CAS 몇 번. 콜백을 등록하고 실행하는 경로에는 epoll도, 커널 호출도, 이벤트 루프도 없습니다.

딱 하나 예외가 join()/get()입니다. 10장에서 본 park 스택 기억하시죠? 거기서는 CompletableFuture.java:1885가 찍혔어요. 기다리기로 결심하는 순간에만 커널이 등장합니다. 그래서 join()을 부르면 논블로킹이 끝나는 겁니다.

그럼 무엇이 논블로킹일까요? thenApply를 부른 스레드가 기다리지 않는다는 것뿐입니다. 등록하고 즉시 반환하니까요.

정작 I/O를 논블로킹으로 만드는 건 아래 계층입니다. HttpClient.sendAsync가 내부에 NIO Selector 스레드를 돌리고(7장의 그 루프예요), 응답이 완성되면 complete()를 불러줍니다. 📄 문서 기반 (미검증 — 12장에서 그 스레드에 이름을 붙입니다) CompletableFuture는 그 신호를 여러분 코드로 옮기는 통로일 뿐이에요.

이 구분이 중요한 이유가 있습니다.

CompletableFuture.supplyAsync(() -> blockingHttpCall()); // ← 논블로킹이 아니다

이 코드는 어떤 스레드 하나를 호출이 끝날 때까지 통째로 잡아먹습니다. 9장의 100ms 다운스트림이라면 100ms 내내요. CompletableFuture로 감쌌다고 대기가 사라지지 않습니다 — 대기를 어느 스레드에 떠넘겼는지만 바뀝니다.

정리

  • CompletableFuture는 필드 두 개입니다. result(완료 값)와 stack(완료되면 실행할 것들). 10장에서 Future에 없다고 한 게 이 두 번째 필드예요.
  • 콜백을 실행하는 스레드는 경합으로 정해집니다. 등록이 한참 먼저면 완료시킨 스레드가, 완료가 먼저면 등록한 스레드가 실행합니다. 거의 동시라면 unipush가 다시 확인해 등록한 스레드가 돌려요.
  • 콜백 여러 개의 실행 순서는 LIFO였습니다. 1, 2, 3으로 등록하니 3, 2, 1로 실행됐어요. Treiber 스택이니까요. 문서화된 보장이 아니니 순서에 의존하지 말고 체인으로 이으세요.
  • thenApply는 스레드를 안 옮기고, thenApplyAsynccommonPool로 옮깁니다. 그 풀의 병렬도는 코어 − 1(실측 7)이고 JVM 전역 공용입니다.
  • 콜백 경로에는 커널도 이벤트 루프도 없습니다. 예외는 join()/get() 하나뿐이에요 — 기다리기로 결심하는 순간에만 커널이 나옵니다. 진짜 논블로킹 I/O는 아래 계층이 만듭니다.

생각해볼 질문

  1. thenApply의 실행 스레드가 비결정적이라면, 그 콜백 안에서 ThreadLocal에 담아둔 값을 읽으면 어떻게 될까요?
  2. supplyAsync(() -> blockingCall())commonPool을 쓴다면, 그런 호출을 동시에 열 개 던지면 무슨 일이 생길까요? 이 기계의 병렬도는 7이었습니다.
  3. 이 장의 CompletableFuture에는 커널이 없었습니다. 그럼 HttpClient.sendAsync가 응답을 받아 complete()를 부르기까지, 어느 스레드가 무엇을 하고 있었을까요?

1번은 13장, 2번은 14장의 주제입니다. 그리고 3번이 다음 장이에요. 7장에서 C로 만든 이벤트 루프가 자바 프레임워크 안에서는 어떤 모습인지 — Netty와 WebFlux를 열어봅니다.

12장. 이벤트 루프를 프레임워크로 — Netty와 WebFlux

Last updated on