Skip to Content
JavaFuture vs CompletableFuture

Future.get() vs CompletableFuture — 논블로킹의 실체

CompletableFuture결과가 도착했을 때 실행할 코드를 미리 등록해두는 자료구조다. Future처럼 결과를 꺼내러 가지 않고, 결과가 오면 등록해둔 콜백이 실행된다.

언제 찾게 되는가 — 외부 API 3곳을 호출하는데 응답이 300ms 걸릴 때, 혹은 Future.get()을 쓴 코드에서 스레드 풀이 계속 고갈될 때.

핵심 답 (먼저 읽을 3줄)

  1. Future가 블로킹인 건 API에 콜백 등록 수단이 없기 때문이다. 결과를 얻는 유일한 방법이 get()이고, get()은 결과가 없으면 스레드를 park한다.
  2. CompletableFuture는 I/O를 논블로킹으로 만들지 않는다. 논블로킹이 되는 건 결과를 조립하는 방식이다. supplyAsync(() -> blockingHttpCall())은 여전히 어떤 스레드 하나를 100ms 동안 통째로 잡아먹는다.
  3. 진짜 논블로킹 I/O는 아래 계층(HttpClient의 NIO Selector, Netty)이 만든다. CompletableFuture는 그 계층이 “다 됐다”고 알려주는 전달 통로일 뿐이다.

⚠️ 흔한 오해: “CompletableFuture가 스레드를 OS 이벤트 루프에 등록해두고, 콜백이 오면 커널이 그 스레드를 깨워 실행한다.” 아니다. CompletableFutureOS와 아무 관계가 없는 순수 JVM 자료구조(필드 2개짜리 객체)다. 이벤트 루프는 OS 기능이 아니라 유저 공간 스레드가 epoll_wait를 도는 것이고, 커널은 “준비됐다”만 알려줄 뿐 콜백을 불러주지 않는다 — 자세한 건 블로킹 I/O와 이벤트 루프.

Future.get()이 블로킹인 이유

Future 인터페이스의 메서드는 JDK 25 기준 get(), get(timeout), isDone(), isCancelled(), cancel(), 그리고 JDK 19에서 추가된 state(), resultNow(), exceptionNow()다 (✅ 실측 — 리플렉션으로 확인). 목록은 늘었지만 어느 것도 “끝나면 이걸 해줘”를 등록하지 못한다. 전부 “지금 어떤 상태냐”를 묻는 조회용이다. 그래서 결과를 쓰려면 누군가는 반드시 기다려야 한다.

기다리는 스레드의 실제 상태를 떠보면 이렇다 (미완료 CompletableFutureget()을 건 스레드의 스택 덤프):

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.CompletableFuture$Signaller.block(CompletableFuture.java:1885) at java.util.concurrent.ForkJoinPool.unmanagedBlock(ForkJoinPool.java:4364) at java.util.concurrent.ForkJoinPool.managedBlock(ForkJoinPool.java:4310)

✅ 실측 (JDK 25 / macOS, 2026-08)

Unsafe.park는 결국 OS의 futex/조건변수 대기다. 이 스레드는 CPU를 한 톨도 쓰지 않으면서 스택 메모리와 스레드 슬롯은 계속 점유한다. 응답을 기다리는 200개 요청 = 잠자는 스레드 200개. 이게 스레드 풀 고갈의 정체다.

스택 맨 아래 managedBlock이 힌트다. waitingGet() 소스를 보면 park 하기 전에 이런 분기가 있다 (JDK 25 소스 확인):

if (Thread.currentThread() instanceof ForkJoinWorkerThread wt) ForkJoinPool.helpAsyncBlocker(wt.pool, wt); // 잠들기 전에 큐에 밀린 다른 작업을 먼저 돕는다 ... ForkJoinPool.managedBlock(q); // 그래도 결과가 없으면 park

get()을 부른 스레드가 ForkJoinPool 워커라면 그냥 잠들지 않고 다른 대기 작업을 먼저 처리한다. 위 덤프가 unmanagedBlock을 거친 건 get()을 건 스레드가 FJ 워커가 아니라 일반 스레드였기 때문이다. 어느 쪽이든 결과가 없으면 마지막엔 park다.

예제: 외부 API 3곳 호출 (각 100ms)

// 테스트 서버: 모든 요청에 100ms 뒤 응답 HttpClient client = HttpClient.newHttpClient(); // A. 순차 블로킹 — 한 스레드가 3번 연속 잠든다 String user = client.send(req("user"), ofString()).body(); String order = client.send(req("orders"), ofString()).body(); String reco = client.send(req("recommend"), ofString()).body(); // B. 스레드로 병렬화 — 빨라지지만 스레드 3개가 잠든다 Future<String> f1 = pool.submit(() -> client.send(req("user"), ofString()).body()); Future<String> f2 = pool.submit(() -> client.send(req("orders"), ofString()).body()); Future<String> f3 = pool.submit(() -> client.send(req("recommend"), ofString()).body()); String all = f1.get() + f2.get() + f3.get(); // ← 여기서 main도 잠든다 // C. CompletableFuture + sendAsync — 아무도 잠들지 않는다 CompletableFuture<String> c1 = client.sendAsync(req("user"), ofString()).thenApply(HttpResponse::body); CompletableFuture<String> c2 = client.sendAsync(req("orders"), ofString()).thenApply(HttpResponse::body); CompletableFuture<String> c3 = client.sendAsync(req("recommend"), ofString()).thenApply(HttpResponse::body); c1.thenCombine(c2, (a, b) -> a + b) .thenCombine(c3, (ab, c) -> ab + c) .thenAccept(result -> respond(result)); // 등록만 하고 즉시 반환

측정 결과 — “대기로 멈춘 스레드”는 스택에 블로킹 소켓 read 또는 Future.get() park 프레임이 있는 플랫폼 스레드를 2ms 간격으로 샘플링한 피크값:

방식소요호출 스레드 반환까지대기로 멈춘 스레드
A. send() 순차322ms1
B. 풀 3개 + Future.get()108ms1ms (submit)4 (워커 3 + get() 건 main 1)
C. sendAsync() + thenCombine107ms1ms0
D. 가상 스레드 + 블로킹 send()110ms1 (get() 건 main뿐)

✅ 실측 (JDK 25 / macOS, 2026-08, 로컬 HTTP 서버 100ms 지연)

읽는 법:

  • B와 C는 소요 시간이 같다(≈107ms). 지연을 줄인 건 CompletableFuture가 아니라 병렬화다. 순차(A)를 병렬로 바꾼 순간 이미 300ms→100ms가 된다.
  • 차이는 “0 vs 4”에 있다. B는 100ms 동안 스레드 4개가 아무 일도 안 하면서 묶여 있고, C는 그 시간 동안 어떤 스레드도 묶이지 않는다. 처리량은 여기서 갈린다. 톰캣 워커 200개로 100ms 백엔드를 부르면 B 방식의 상한은 200/0.1 = 초당 2,000건이다 (Little의 법칙, 📄 계산).
  • D를 보면 “블로킹 문법 = 나쁨”이 아님을 알 수 있다. 코드는 A와 똑같이 블로킹인데 플랫폼 스레드는 잡히지 않는다. 가상 스레드가 대기 시점에 캐리어에서 내려오기 때문이다.

그래서 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 결과”와 “미완료”가 구분된다.)

  • thenApply(fn) → 새 CompletableFuture를 만들고, fn을 감싼 Completion 노드를 스택에 CAS로 push하고 즉시 반환한다. 여기엔 대기도, 스레드 등록도, 커널 호출도 없다.
  • complete(v)result에 CAS로 값을 넣고, 완료시킨 그 스레드가 스택을 pop 하며 콜백들을 실행한다.
  • 등록 시점에 이미 result != null이면 → 등록한 스레드가 콜백을 그 자리에서 직접 실행한다.

즉 “누가 콜백을 실행하는가”는 등록과 완료 중 누가 늦었느냐로 갈리는 경합이다. 특정 스레드가 예약되어 있는 게 아니다.

콜백을 실행하는 스레드는 누구인가 (함정)

client.sendAsync(req, ofString()) .whenComplete((r, t) -> log("완료시킨 스레드")) .thenApply(r -> log("thenApply")) .thenApplyAsync(r -> log("thenApplyAsync 기본")) .thenApplyAsync(r -> log("thenApplyAsync 내 풀"), myPool);
지점실행 스레드
HTTP 응답 완료ForkJoinPool.commonPool-worker-2
thenApplyForkJoinPool.commonPool-worker-2 (완료시킨 스레드가 이어서)
thenApplyAsync (기본)ForkJoinPool.commonPool-worker-2 (기본 풀 = commonPool)
thenApplyAsync(fn, myPool)my-pool-1
이미 완료된 CF에 thenApplymain (등록한 스레드가 직접)

✅ 실측 (JDK 25 / macOS, 2026-08)

여기서 나오는 실전 규칙:

  • thenApply의 실행 스레드는 비결정적이다. 완료가 빠르면 등록 스레드가, 느리면 완료 스레드가 실행한다. 콜백 안에서 무거운 연산이나 블로킹 호출을 하면 I/O 스레드나 요청 스레드를 오염시킨다. 무거우면 thenApplyAsync(fn, 전용풀)로 명시적으로 옮긴다.
  • supplyAsync(블로킹작업)은 commonPool을 쓴다. ForkJoinPool.commonPool의 기본 병렬도는 가용 코어 - 1이다 (✅ 실측: 8코어 → getCommonPoolParallelism() = 7). 여기에 100ms짜리 블로킹 HTTP를 던지면 JVM 전역 공용 풀이 막혀 병렬 스트림 등 무관한 코드까지 같이 멈춘다. 블로킹 작업엔 항상 전용 Executor를 넘긴다: supplyAsync(task, myPool).
  • 예외는 값처럼 흐른다. exceptionally(e -> fallback) / handle((v, e) -> ...)로 체인 중간에서 복구하고, 아무 데서도 처리하지 않으면 조용히 사라진다. 체인 끝에 whenComplete로 로깅을 붙이는 습관이 필요하다.
  • 여러 개 모으기allOf(...)CompletableFuture<Void>라 결과를 따로 join()해 꺼내야 하고(이 시점엔 이미 완료라 안 막힌다), 2개면 thenCombine이 값까지 합쳐준다.

thenApply vs thenCompose — 콜백이 또 CF를 반환할 때

첫 호출 결과로 두 번째 호출을 해야 하는, 가장 흔한 형태에서 갈린다.

// ❌ thenApply → CompletableFuture<CompletableFuture<String>> (중첩) findUserId().thenApply(id -> fetchUser(id)); // join() 두 번 해야 값이 나온다 // ✅ thenCompose → CompletableFuture<String> (평탄화) findUserId().thenCompose(id -> fetchUser(id));

✅ 실측 — thenApplyjoin() 결과의 타입은 CompletableFuture였고 join().join()을 해야 값이 나왔다.

Streammap(thenApply) vs flatMap(thenCompose)과 같은 구도다. 콜백이 CompletableFuture를 반환하면 thenCompose, 평범한 값을 반환하면 thenApply.

타임아웃은 기본으로 없다

블로킹 코드엔 get(3, SECONDS)가 있었지만, 콜백 체인은 아무도 기다리지 않으므로 영원히 완료되지 않아도 알아채는 사람이 없다. Java 9부터 두 가지 장치가 있다.

cf.orTimeout(3, TimeUnit.SECONDS) // → TimeoutException으로 예외 완료 cf.completeOnTimeout("기본값", 3, TimeUnit.SECONDS) // → 폴백 값으로 정상 완료

✅ 실측 — 각각 java.util.concurrent.TimeoutException, "기본값" 반환 확인. HTTP 클라이언트 자체 타임아웃과 별개로 체인 전체의 상한선을 이걸로 건다.

Future와 다른 두 가지 함정

FutureTask (ExecutorService.submit)CompletableFuture
cancel(true)실행 중인 스레드를 인터럽트한다인터럽트하지 않는다. 대기자만 CancellationException을 받고 작업은 끝까지 돈다
대기 중 인터럽트get()InterruptedExceptionget()은 탈출, join()은 안 깨어난다 (계속 WAITING)

✅ 실측 — cancel(true) 직후에도 3초 sleep 작업이 끝까지 수행됐고, join() 스레드는 인터럽트 1초 뒤에도 WAITING이었다. JDK 25 소스에서도 CompletableFuture.cancel(boolean mayInterruptIfRunning)파라미터를 아예 사용하지 않는다 (Javadoc 명시: “this value has no effect in this implementation”).

실무 함의: CompletableFuture로는 이미 시작된 작업을 멈출 수 없다. 취소는 “결과를 안 쓰겠다”는 선언일 뿐이라, 외부 호출을 실제로 끊으려면 클라이언트 타임아웃을 따로 걸어야 한다.

그리고 join()/get()을 부르는 순간 논블로킹은 끝난다. 웹 요청 처리 스레드에서 join()을 부르면 위 표의 B 시나리오로 되돌아간다. 컨트롤러라면 CompletableFuture 자체를 반환해 프레임워크가 완료를 구독하게 둔다.

언제 뭘 쓰나

상황선택
CPU 작업 병렬화supplyAsync(task, 전용풀) — 풀 크기 ≈ 코어 수
외부 API 여러 곳 호출sendAsync + thenCombine/allOf (진짜 논블로킹)
JDBC·레거시 블로킹 라이브러리가상 스레드 + 평범한 블로킹 코드 (JDK 21+). 콜백 지옥 없이 D 효과
응답 조립 후 반환컨트롤러에서 CompletableFuture 반환, join() 금지
Last updated on