10장. Future의 한계 — 결과를 꺼내려면 누군가는 기다려야 한다

만화로 보는 요약 — 먼저 읽어보세요
100ms에 끝난 결과를 503ms에 받았습니다. 작업이 느려서가 아니라, get()을 부른 순서 때문에요.
면접 실전 질문: ①
Future로는 왜 콜백을 등록할 수 없나요? ②Future.get()을 여러 개 순서대로 부르면 무슨 문제가 생기나요? ③CompletionService는 무엇을 해결하나요?
배경 — 셋을 동시에 부르려면
9장이 남긴 질문입니다.
“요청 하나가 다운스트림 세 곳을 순서대로 불러야 한다면 스레드는 몇 번 잠들까요? 그 셋을 동시에 부르려면 코드가 어떻게 생겨야 할까요?”
순서대로 부르면 세 번 잠들고, 100ms짜리 셋이면 300ms입니다. 동시에 부르면 100ms면 되죠. 자바가 2004년 java.util.concurrent와 함께 내놓은 답이 **Future**였습니다.
Future<String> f1 = pool.submit(() -> callUserApi());
Future<String> f2 = pool.submit(() -> callOrderApi());
Future<String> f3 = pool.submit(() -> callPaymentApi());
String result = f1.get() + f2.get() + f3.get(); // ← 여기서 기다린다동시에 던지고, 결과를 모읍니다. 300ms가 100ms가 됐어요. 여기까지는 잘 작동합니다.
그런데 마지막 줄에 이상한 게 있습니다. get(). 이 한 단어가 이 장의 주제예요.
스토리 — 조회는 되는데 등록이 안 된다
Future가 제공하는 메서드를 전부 꺼내봤습니다.
for (var m : Future.class.getDeclaredMethods()) names.add(m.getName());[cancel, exceptionNow, get, get, isCancelled, isDone, resultNow, state]✅ 실측 (JDK 25 / macOS 26.5, 2026-08)
여덟 개입니다(get이 두 번인 건 타임아웃 버전 때문이고, state·resultNow·exceptionNow는 JDK 19에서 추가됐습니다).
이 목록을 성격별로 나눠보면 이렇습니다.
| 성격 | 메서드 |
|---|---|
| 지금 상태를 묻는다 | isDone, isCancelled, state |
| 결과를 꺼낸다 | get, get(timeout), resultNow, exceptionNow |
| 취소한다 | cancel |
| 끝나면 이걸 해달라고 등록한다 | — 없음 |
덧붙이면 resultNow·exceptionNow는 안 끝났으면 IllegalStateException을 던집니다. 안 기다리고 꺼내는 건 아예 금지돼 있는 셈이에요.
마지막 칸이 비어 있습니다. JDK 19에서 메서드가 셋이나 늘었는데도 여전히 없어요. 전부 “지금 어떠냐”를 묻는 조회용입니다.
이게 무슨 뜻이냐면 — Future는 여러분에게 결과를 밀어(push) 줄 방법이 없습니다. 여러분이 가지러(pull) 가야 해요. 그리고 아직 안 끝났으면?
5장에서 본 그 일이 일어납니다.
Thread.State = WAITING
at jdk.internal.misc.Unsafe.park(Native Method)
at java.util.concurrent.locks.LockSupport.park(LockSupport.java:223)
at java.util.concurrent.FutureTask.awaitDone(FutureTask.java:511)
at java.util.concurrent.FutureTask.get(FutureTask.java:192)✅ 실측 (JDK 25 / Apple M1 / macOS 26.5, 2026-08. pool.submit()이 돌려준 미완료 Future에 get()을 건 스레드. 가독성을 위해 모듈 접두사는 지웠습니다.)
잠듭니다. 5장에서 센 것과 같은 종류의 사건이에요 — 자발적 컨텍스트 스위치 1회(다만 이 장에서는 세지 않았습니다, 상태만 확인했어요).
그러니 Future로 병렬화를 하면 이런 그림이 됩니다. 작업 스레드 셋이 각각 다운스트림을 기다리며 자고, 거기에 더해 get()을 부른 스레드가 하나 더 잡니다. 이 책 서문에서 “묶인 스레드가 4개냐 0개냐”라고 미리 꺼낸 그 4가 이겁니다 — 워커 3 + get() 건 스레드 1. 반대쪽 0이 어떻게 가능한지, 그러니까 아무도 안 기다리는 구조가 무엇으로 되어 있는지는 11장에서 뜯어봅니다.
핵심 — 순서 하나가 400ms를 흘린다
여기까지는 “스레드 하나 더 자는 정도”로 들립니다. 그런데 Future의 진짜 비용은 다른 데 있어요.
측정 환경: Apple M1(8코어) / 램 16GB / macOS 26.5 / JDK 25. 전체 소스는 docs/book/code/async/Ch10FutureLimits.java에 있습니다.
작업 다섯 개를 만들었습니다. r0은 100ms, r1은 200ms… r4는 500ms가 걸려요. 그리고 느린 것부터 제출했습니다. 실무에서 흔한 상황이죠 — 어느 API가 느릴지 미리 알 수 없으니까요.
for (int i = N - 1; i >= 0; i--) fs.add(pool.submit(task(i))); // r4, r3, r2, r1, r0 순으로 제출
for (Future<String> f : fs) got.add(f.get()); // 제출한 순서대로 꺼낸다결과 ① — 제출 순서대로 get()
B. 제출 순서대로 get() 전체 503 ms
수집: r4@503ms r3@503ms r2@503ms r1@503ms r0@503ms✅ 실측 (같은 환경, 2026-08. 3회 실행 502~511 ms, 중앙값)
r0을 보세요. 100ms에 끝난 작업인데 503ms에 손에 들어왔습니다.
이유는 단순합니다. f.get()을 r4부터 불렀으니, r4가 끝나는 503ms까지 그 자리에서 자고 있었어요. 그동안 r0·r1·r2·r3은 진작 끝나 있었지만 아무도 안 가져갔습니다. r4가 풀려난 뒤에야 다섯 개를 한꺼번에 수거합니다 — 전부 같은 시각이에요.
작업은 제때 끝났는데 결과를 늦게 받은 겁니다. 그리고 그 원인은 순전히 get()을 부른 순서예요.
결과 ② — 끝난 순서대로 받으면
CompletionService는 이 문제를 위해 있는 물건입니다. 완료된 것부터 큐에 넣어주거든요.
CompletionService<String> cs = new ExecutorCompletionService<>(pool);
for (int i = N - 1; i >= 0; i--) cs.submit(task(i));
for (int i = 0; i < N; i++) { String r = cs.take().get(); use(r); } // 끝난 순서대로C. CompletionService 전체 510 ms
수집: r0@108ms r1@209ms r2@309ms r3@408ms r4@509ms✅ 실측 (같은 환경, 2026-08. 3회 실행 504~510 ms, 중앙값)
끝나는 즉시 받습니다. r0은 108ms, r1은 209ms에요.
두 결과를 나란히 놓으면 이렇습니다.
| 전체 소요 | 첫 결과를 손에 쥔 시각 | |
|---|---|---|
제출 순서대로 get() | 503 ms | 503 ms |
CompletionService | 510 ms | 108 ms |
전체 소요는 사실상 같습니다(503 vs 510ms, 회차 편차 안). 어차피 제일 느린 r4를 기다려야 하니까요. 다른 건 첫 결과를 쓸 수 있는 시점 — 4.7배 차이입니다. r0을 400ms 동안 그냥 손에 안 쥐고 있었던 셈이에요.
이게 왜 중요하냐면, 실무에서는 결과를 다 모아야만 쓸 수 있는 게 아니거든요. 먼저 온 것부터 렌더링하거나, 스트리밍으로 흘려보내거나, 하나만 성공하면 나머지를 취소하거나. Future로 하려면 결국 누군가 블로킹으로 앉아 있어야 합니다.
여기서 이런 생각이 들 겁니다. “invokeAll이랑 invokeAny는요?”
invokeAll은 전부 끝날 때까지 부른 스레드를 붙잡습니다 — B와 같아요. invokeAny는 첫 성공만 주고 나머지를 취소합니다. 쓸 데가 분명히 있죠. 다만 둘 다 부른 스레드가 잔다는 점은 똑같습니다. 콜백이 아니라 블로킹이니까요.
CompletionService는 문제를 옮겼을 뿐이다
CompletionService가 문제를 풀어준 것처럼 보이지만, 자세히 보면 문제를 옮겼을 뿐입니다.
cs.take()도 블로킹입니다. 여전히 스레드 하나가 결과를 수거하려고 앉아서 기다려요. poll()로 논블로킹하게 볼 수는 있지만, 그럼 직접 돌면서 물어봐야 합니다 — 밀어주는 게 아니라 여전히 가지러 가는 거죠. 순서만 똑똑해졌지 “누군가는 기다려야 한다”는 구조는 그대로입니다.
그리고 더 근본적인 한계가 있어요. Future로는 결과를 조합할 수 없습니다.
// 하고 싶은 것: 사용자 조회 → 그 ID로 주문 조회 → 둘을 합쳐 응답
// Future 로 쓰면?
Future<User> fu = pool.submit(() -> getUser(id));
User u = fu.get(); // ← 여기서 잠든다
Future<Order> fo = pool.submit(() -> getOrder(u)); // 그다음에야 던질 수 있다
Order o = fo.get(); // ← 또 잠든다두 번째 호출이 첫 번째 결과에 의존하니, get()으로 값을 꺼내야만 다음 단계로 갈 수 있습니다. 결국 순차 실행이에요. 병렬화를 하려고 Future를 썼는데, 의존 관계가 하나만 있어도 다시 블로킹으로 돌아옵니다.
공정하게 적어둡시다. 팬아웃해서 전부 모아야만 다음이 진행되는 배치라면 Future로 충분합니다. 어차피 다 기다려야 하니 콜백이 있어도 이득이 없어요. 문제는 결과가 도착할 때마다 뭔가를 하고 싶을 때, 그리고 그 스레드를 놓아주고 싶을 때 생깁니다.
정리하면 Future가 못 하는 게 셋입니다.
- 완료를 통보받기 — 콜백 등록 메서드가 없습니다
- 완료 순서대로 처리하기 —
CompletionService로 우회하지만 여전히 블로킹입니다 - 결과를 이어 붙이기 — 값을 꺼내야 다음 단계를 시작할 수 있습니다
셋 다 같은 뿌리에서 나옵니다. Future는 “결과를 담는 상자”일 뿐, “결과가 도착했을 때 무엇을 할지”를 담지 못합니다.
그래서 10년을 기다렸다
Future는 2004년 Java 5에 들어왔습니다. 위 세 가지 한계를 해결한 CompletableFuture는 2014년 Java 8에 왔어요. 그사이 자바 개발자들은 맨손으로 버티다, 2010년쯤부터 Guava의 ListenableFuture 같은 외부 라이브러리를 썼습니다. 📄 문서 기반 (미검증)
무엇을 더했길래 10년이 걸렸을까요? 답은 놀랍도록 단순합니다. 상자에 “끝나면 실행할 것들” 목록을 하나 붙였습니다. 그게 전부예요.
다음 장에서 그 목록을 직접 열어봅니다.
정리
Future의 메서드 여덟 개 중 콜백을 등록하는 건 하나도 없습니다. 전부 “지금 어떠냐”를 묻는 조회용이에요. 그래서 결과를 쓰려면get()으로 가지러 가야 하고, 안 끝났으면 잠듭니다.get()을 부르는 순서가 결과 수집 시각을 바꿉니다. 느린 것부터 기다리면 100ms에 끝난 결과를 503ms에 받아요 — 400ms를 그냥 흘린 겁니다.CompletionService는 순서 문제만 풉니다. 첫 결과를 108ms에 받게 해주지만(4.7배),take()도 블로킹이라 “누군가는 기다린다”는 구조는 그대로입니다.- 의존 관계가 하나만 있어도
Future는 순차로 돌아갑니다. 다음 호출을 던지려면 앞 결과를get()해야 하니까요. - 셋 다 같은 뿌리입니다.
Future는 결과를 담을 뿐, “도착하면 무엇을 할지”를 담지 못합니다.
생각해볼 질문
Future에 콜백 등록 메서드를 하나 추가한다면 시그니처가 어떻게 생겨야 할까요? 그 콜백은 어느 스레드에서 실행돼야 할까요?f.get()을 부른 스레드가 잠든다면, 그 스레드는 8장에서 본 세금을 내고 있는 걸까요?- “끝나면 실행할 것들” 목록을 상자에 붙인다고 칩시다. 목록에 콜백을 넣는 순간과 상자가 완료되는 순간이 동시에 일어나면 어떻게 될까요?
3번이 다음 장의 핵심입니다. 그 경합이 CompletableFuture의 설계 전부를 결정하고, “콜백을 실행하는 스레드가 누구냐”라는 실무 함정도 거기서 나옵니다.