CompletableFuture 내부 동작 — Treiber Stack, Completion, 그리고 두 줄짜리 상태 머신
thenApply로 길게 이어 붙인 체인이 어느 스레드에서 어떤 순서로 실행되는지 헷갈렸던 경험이 있을 것입니다. 이 글은 Java 비동기 합성의 사실상 표준 도구인CompletableFuture가 OpenJDK 소스 위에서 어떻게 동작하는지를 풀어냅니다. Doug Lea가 단 두 개의volatile필드(result,stack)와 한 종류의 자료구조(Treiber 스택), 그리고Completion추상 클래스 한 줄짜리 메서드(tryFire)로 합성·비동기·예외 전파를 모두 표현한 설계를 같은 그림 위에서 정리합니다.
1. 왜 CompletableFuture가 필요한가
Future의 두 가지 한계
java.util.concurrent.Future는 JDK 5에서 도입된 비동기 결과 핸들입니다. submit() 호출로 받은 Future에 대해 get()을 부르면 결과가 준비될 때까지 호출 스레드가 블로킹됩니다. 단순한 작업에서는 충분하지만 두 가지 한계가 명확합니다.
첫째, 합성이 불가능합니다. 결과 A를 받아 B를 호출하고, B 결과를 다시 C와 합치는 흐름을 표현하려면 결국 어딘가에서 get()을 부르고 그 스레드를 블로킹해야 합니다. 비동기의 이점이 사라집니다.
둘째, 예외 처리 모델이 빈약합니다. get()은 ExecutionException으로 원인을 감싸고, 부분 실패에 대한 합성 전략을 표현할 방법이 없습니다.
JDK 8은 이 둘을 한 번에 해결하려고 CompletionStage 인터페이스와 그 유일한 표준 구현체 CompletableFuture를 도입했습니다. Future이면서 동시에 CompletionStage이고, "다음 단계"를 등록하는 50여 개의 메서드를 노출합니다.
CompletionStage의 약속
CompletionStage는 계산 한 단계를 추상화합니다. 핵심 약속은 두 가지입니다.
- 이 stage가 정상 완료되면, 등록된 함수를 그 결과 위에서 실행한다.
- 이 stage가 예외로 완료되면, 그 예외를 다음 stage로 전파하거나 핸들러에 넘긴다.
API는 세 변형으로 같은 작업을 노출합니다.
| 접미사 | 실행 스레드 |
|---|---|
없음 (예: thenApply) |
완료시키는 스레드, 또는 이미 완료된 경우 호출자 스레드. 어디서 실행될지 미리 결정되지 않음 |
Async (예: thenApplyAsync(fn)) |
해당 stage의 기본 Executor(보통 ForkJoinPool.commonPool()) |
Async + Executor (예: thenApplyAsync(fn, exec)) |
명시한 Executor |
이 세 가지 변형의 정체와 "어떤 스레드에서 실행되는가"의 답은 사실 같은 구현 위에 얇은 분기일 뿐입니다. 그 구현을 들여다봅니다.
2. 단 두 개의 필드 — result와 stack
CompletableFuture 클래스 본체에 선언된 인스턴스 필드는 단 두 개입니다.
volatile Object result; // 결과 또는 boxed AltResult
volatile Completion stack; // 의존 액션들이 쌓인 Treiber 스택의 top
result: 비완료 시null. 정상 결과는 그대로 저장하고,null결과나 예외는AltResult로 감싸 저장합니다.stack: 이 stage가 완료되기를 기다리는 의존 액션들의 Treiber 스택. 완료된 시점에 한 번에 비워집니다.
이게 전부입니다. 상태 머신은 사실상 두 줄로 끝납니다.
flowchart LR
NotDone[result = null] -->|CAS result| Done[result != null]
Done -->|postComplete pops stack| Drained[stack drained]
result가 null이면 미완료, 아니면 완료. 완료시키는 측은 CAS로 null → 결과를 단 한 번 성공시키며, 성공한 측이 postComplete()로 stack을 순회합니다. 동시성 보호는 두 필드의 volatile과 Unsafe/VarHandle을 통한 CAS만으로 이뤄집니다.
AltResult — null과 예외를 한 슬롯에
null 결과를 result == null로 표현하면 미완료와 구별이 안 됩니다. 예외 역시 자리가 필요합니다. AltResult가 둘을 모두 흡수합니다.
static final class AltResult {
final Throwable ex; // null 결과는 ex == null, 예외 완료는 ex != null
AltResult(Throwable x) { this.ex = x; }
}
static final AltResult NIL = new AltResult(null);
규칙은 단순합니다.
- 정상 값
v이고v != null이면result = v. - 정상 값이지만
v == null이면result = NIL(싱글톤). - 예외
t로 완료되면result = new AltResult(wrapAsCompletionException(t)).
이 규약 덕분에 result != null 한 가지 검사로 "완료되었는가"를 판정할 수 있습니다.
3. Treiber 스택 — 의존 액션을 쌓는 자료구조
Treiber 스택이란
R. Kent Treiber가 1986년에 발표한 락 프리 스택입니다. 단일 head 포인터 위에서 push와 pop을 CAS 한 번으로 수행합니다.
// push 의사 코드
do {
Node old = head;
newNode.next = old;
} while (!CAS(head, old, newNode));
// pop 의사 코드
Node old;
do {
old = head;
if (old == null) return null;
} while (!CAS(head, old, old.next));
CompletableFuture의 stack 필드는 이 head 포인터입니다. 미완료 stage에 의존 액션을 등록하면 stack 위에 새 Completion을 push합니다. 여러 스레드가 같은 stage에 동시에 thenApply를 부르면 CAS 경합만 일어나고 데이터 손상은 없습니다.
Completion 노드 자체가 ForkJoinTask
스택 노드인 Completion은 그냥 데이터가 아니라 실행 가능한 작업입니다.
abstract static class Completion extends ForkJoinTask<Void>
implements Runnable, AsynchronousCompletionTask {
volatile Completion next; // Treiber 스택 next
abstract CompletableFuture<?> tryFire(int mode);
abstract boolean isLive();
// ForkJoinTask hooks
public final Void getRawResult() { return null; }
public final void setRawResult(Void v) {}
public final boolean exec() { tryFire(ASYNC); return false; }
public final void run() { tryFire(ASYNC); }
}
ForkJoinTask 상속 덕분에 노드 그 자체를 ForkJoinPool.execute(task)로 던질 수 있고, 별도의 어댑터 객체가 필요 없습니다. 동시에 Runnable도 구현하므로 임의의 Executor에서도 그대로 실행됩니다. 객체 하나가 스택 노드, 비동기 작업, Runnable 세 역할을 겸합니다.
소스 주석은 이렇게 설명합니다.
Class Completion extends ForkJoinTask to enable async execution (adding no space overhead because the implementation exploits its "tag" methods to maintain claims).
비동기 클레임(중복 실행 방지)을 위해 별도 필드를 두지 않고 ForkJoinTask.setForkJoinTaskTag 비트를 이용합니다. 메모리 한 톨도 아낀 흔적입니다.
4. Completion의 종류 — 동작별 클래스 폭발
Completion을 상속한 클래스는 20여 개에 달합니다. 입력 stage 개수와 결과 변환 방식으로 분류합니다.
단일 입력 (UniCompletion)
thenApply, thenAccept, thenRun, whenComplete, handle, exceptionally, thenCompose처럼 stage 하나에 의존하는 액션.
| 클래스 | 트리거 API |
|---|---|
UniApply |
thenApply / thenApplyAsync |
UniAccept |
thenAccept / thenAcceptAsync |
UniRun |
thenRun / thenRunAsync |
UniWhenComplete |
whenComplete / whenCompleteAsync |
UniHandle |
handle / handleAsync |
UniExceptionally |
exceptionally / exceptionallyAsync |
UniCompose |
thenCompose / thenComposeAsync |
UniComposeExceptionally |
exceptionallyCompose* |
UniRelay |
copy() 등 내부 릴레이 |
두 입력 (BiCompletion)
thenCombine, thenAcceptBoth, runAfterBoth처럼 두 stage가 모두 완료되어야 트리거되는 액션. BiApply, BiAccept, BiRun, BiRelay가 있습니다.
두 입력 액션은 같은 인스턴스가 두 source stage 양쪽 스택에 모두 등록될 수 없습니다. 동일 객체를 한 스택에 두 번 넣으면 next 포인터가 깨지기 때문입니다. 대신 한쪽에는 BiCompletion이, 다른 한쪽에는 그 인스턴스를 가리키는 CoCompletion이 들어갑니다. CoCompletion.tryFire는 본체로 위임만 합니다.
flowchart LR
A[Source A] -->|stack push| BC[BiApply]
B[Source B] -->|stack push| CC[CoCompletion]
CC -->|delegates tryFire| BC
BC --> D[Dependent CF]
양자택일 (Or)
applyToEither, acceptEither, runAfterEither. 두 source 중 먼저 완료되는 쪽으로 발화. OrApply, OrAccept, OrRun. 양쪽 스택에 같은 객체가 등록되지만 tryFire 안에서 CAS로 한 번만 발화하도록 게이트됩니다.
다항 (AnyOf, allOf)
allOf는 내부적으로 이진 트리를 만들어 BiRelay로 합칩니다. anyOf는 모든 source 스택에 같은 AnyOf 인스턴스를 등록합니다.
소스 액션과 신호기
AsyncSupply,AsyncRun:supplyAsync,runAsync의 진입점. Completion이지만 입력 stage가 없습니다.Signaller:get()/join()이 블로킹할 때 스택에 푸시되는 특수 Completion.ForkJoinPool.managedBlock을 통해 풀의 보조 워커가 깨어나도록 협조합니다.
이렇게 동작 단위마다 클래스를 따로 두는 까닭은 어댑터 비용 회피입니다. 소스 주석:
separate classes per kind of action ... avoid adapter layers in common usage patterns.
Function이든 Consumer든 Runnable이든 가장 짧은 가상 호출 경로로 실행됩니다.
5. 등록과 발화 — uniApplyStage를 따라가기
thenApply가 호출되었을 때 무슨 일이 일어나는지 단순화한 흐름입니다.
private <V> CompletableFuture<V> uniApplyStage(
Executor e, Function<? super T,? extends V> f) {
if (f == null) throw new NullPointerException();
Object r;
if ((r = result) != null)
// 이미 완료. 즉시 새 CF를 만들고 동기 발화 시도.
return uniApplyNow(r, e, f);
CompletableFuture<V> d = newIncompleteFuture();
UniApply<T,V> c = new UniApply<>(e, d, this, f);
// 미완료. 스택에 push 시도.
push(c);
// 등록 도중 완료되었을 가능성이 있으므로 한 번 더 시도.
c.tryFire(SYNC);
return d;
}
핵심 분기는 두 가지입니다.
- 이미 완료: source의
result가 이미 채워졌으면 새 dependent를 만들고 곧장tryFire(SYNC)를 시도합니다. 비동기 메서드(thenApplyAsync)면tryFire안에서 Executor에 던집니다. - 미완료: 새
UniApplyCompletion을 만들어 source 스택에 push합니다. push 이후에도 한 번 더tryFire(SYNC)를 호출하는데, 등록 사이에 source가 완료되었을 경우를 막기 위한 race recovery입니다.
UniApply의 tryFire(int mode)가 실제 발화의 본체입니다.
final CompletableFuture<V> tryFire(int mode) {
CompletableFuture<V> d;
CompletableFuture<T> a;
Object r; Throwable x; Function<? super T,? extends V> f;
if ((a = src) == null || (r = a.result) == null
|| (d = dep) == null || (f = fn) == null)
return null; // 이미 발화됨/회수됨
tryComplete: if (d.result == null) {
if (r instanceof AltResult) {
if ((x = ((AltResult)r).ex) != null) {
d.completeThrowable(x, r); // 예외 전파
break tryComplete;
}
r = null;
}
try {
if (mode <= 0 && !claim()) // ASYNC 모드면 executor에 위임 후 false
return null;
@SuppressWarnings("unchecked") T t = (T) r;
d.completeValue(f.apply(t)); // 정상 발화
} catch (Throwable ex) {
d.completeThrowable(ex); // 함수에서 던진 예외
}
}
src = null; dep = null; fn = null; // GC 도움
return d.postFire(a, mode); // dependent 측 cleanup
}
세 가지 모드의 의미가 여기서 드러납니다.
SYNC(0): 현재 스레드에서 즉시 실행 시도. 이미 다른 스레드가 클레임했으면 빠집니다.ASYNC(1): 현재 스레드는 발화하지 않고 Executor에 던집니다. 던지는 메서드(claim())가 클레임을 잡고false를 반환하며, 실제 실행은 풀 워커가run()을 통해tryFire(ASYNC)를 다시 부를 때 일어납니다.NESTED(-1): 또 다른 발화 안에서 호출된 경우. 스택 깊이가 커지는 것을 피하기 위해 일부 cleanup만 하고 끝냅니다.
마지막의 src = null; dep = null; fn = null;이 소스 주석이 강조한 GC 친화 처리입니다. Completion이 더는 필요 없는 강참조를 들고 있지 않도록 빠르게 끊어 줍니다.
6. postComplete — 스택을 비우는 알고리즘
source CompletableFuture가 결과를 얻는 순간 누군가 자신의 stack을 비워야 합니다. 그 책임을 지는 메서드가 postComplete()입니다.
final void postComplete() {
CompletableFuture<?> f = this; Completion h;
while ((h = f.stack) != null
|| (f != this && (h = (f = this).stack) != null)) {
CompletableFuture<?> d; Completion t;
if (STACK.compareAndSet(f, h, t = h.next)) {
if (t != null) {
if (f != this) {
// 중첩 발화의 결과 dependent를 평탄화
pushStack(h);
continue;
}
NEXT.compareAndSet(h, t, null);
}
f = (d = h.tryFire(NESTED)) == null ? this : d;
}
}
}
알고리즘 요지:
- 현재 stage의 스택 head를 pop.
- pop한 Completion에
tryFire(NESTED)를 호출. 이 호출은 dependent staged를 완료시키고 그 결과로d를 반환할 수 있음. - dependent
d가 반환되면 다음 iteration에서d.stack을 비우러 이동. 깊은 체인을 재귀로 풀지 않고 루프로 평탄화. - dependent의 스택이 비면 다시 원래 stage로 돌아와 남은 형제 dependent들을 처리.
이 평탄화 덕에 스택 깊이가 의존 체인 길이와 무관해집니다. chain.thenApply(x).thenApply(x).thenApply(x)...을 수천 번 이어 붙여도 postComplete는 같은 프레임 안에서 처리합니다.
flowchart TD
A[A.complete] --> A1[A.postComplete: pop UniApply_B]
A1 --> B[B.completeValue]
B --> B1[postComplete continues with B.stack]
B1 --> B2[pop UniApply_C]
B2 --> C[C.completeValue]
C --> C1[postComplete continues with C.stack]
thenApply(접미사 없는 변형)가 "어디서 실행될지 모른다"고 javadoc이 말하는 이유가 이 흐름에 있습니다. tryFire(NESTED)는 동기 실행이라 source를 완료시킨 그 스레드가 dependent 함수를 그 자리에서 실행합니다. 만약 dependent가 이미 완료되어 그것이 또 다른 dependent를 풀어내는 chain의 시작이면, 같은 스레드가 사실상 사슬 끝까지 모두 실행할 수 있습니다.
7. Executor 선택 — Async 변형과 기본 풀
세 가지 변형 다시 보기
postComplete의 tryFire(NESTED)는 항상 동기 실행처럼 보이지만, Completion 종류별로 첫 줄이 다릅니다. UniApplyAsync(개념상)의 tryFire는 모드를 무시하고 즉시 executor.execute(this)를 호출합니다. 그래서 등록 시점에 Async 변형을 선택했는지 여부가 곧 "내 dependent는 어떤 스레드에서 돌 것인가"를 결정합니다.
| 등록 메서드 | Completion의 executor 필드 | 동작 |
|---|---|---|
thenApply(fn) |
null |
tryFire가 현재 스레드에서 함수 호출 |
thenApplyAsync(fn) |
defaultExecutor() |
tryFire가 execute(this) 후 워커에서 다시 호출 |
thenApplyAsync(fn, e) |
e |
위와 동일하지만 명시한 e 사용 |
defaultExecutor()와 commonPool 폴백
JDK 9에서 defaultExecutor()가 인스턴스 메서드가 되었습니다(JEP 266). 기본 구현은 ASYNC_POOL 상수입니다.
public Executor defaultExecutor() { return ASYNC_POOL; }
private static final Executor ASYNC_POOL = USE_COMMON_POOL ?
ForkJoinPool.commonPool() : new ThreadPerTaskExecutor();
private static final boolean USE_COMMON_POOL =
(ForkJoinPool.getCommonPoolParallelism() > 1);
핵심은 USE_COMMON_POOL 분기입니다. ForkJoinPool.commonPool()의 parallelism이 2 미만이면(CPU 코어 1개거나 -Djava.util.concurrent.ForkJoinPool.common.parallelism=1로 강제한 경우) commonPool을 쓰지 않고, 태스크 하나당 새 스레드를 만드는 ThreadPerTaskExecutor 로 폴백합니다.
이유는 명확합니다. parallelism 1짜리 풀에서 dependent 액션이 자기 자신을 기다리는 get()을 부르면 데드락이 발생합니다. 하나뿐인 워커가 자기를 풀어줄 작업을 실행할 수 없기 때문입니다. 풀을 회피해 스레드를 새로 만드는 것이 차선책입니다.
이 동작은 운영 환경에서 종종 의외를 만듭니다. 컨테이너에서 CPU 한 개만 할당받은 환경에서는 CompletableFuture.supplyAsync(...) 호출이 매번 새 OS 스레드를 만든다는 뜻입니다.
서브클래스가 기본 Executor를 갈아끼우기
defaultExecutor()는 @Override 가능합니다. JEP 266이 명시적으로 추가한 확장 지점입니다. 라이브러리에서 자신만의 풀을 강제하려면 이렇게 서브클래스를 정의합니다.
public class MyCF<T> extends CompletableFuture<T> {
private final Executor executor;
public MyCF(Executor e) { this.executor = e; }
@Override public Executor defaultExecutor() { return executor; }
@Override public <U> CompletableFuture<U> newIncompleteFuture() {
return new MyCF<>(executor);
}
}
newIncompleteFuture()까지 함께 오버라이드해야 thenApply로 파생된 자식 stage도 같은 풀을 따라갑니다. 그 자식이 어떤 타입을 가질지 결정하는 팩토리이기 때문입니다.
8. 예외 전파 — AltResult 한 슬롯의 위력
예외 래핑 규칙
dependent 함수가 던진 예외 t를 dependent stage의 result에 AltResult(wrap(t))로 저장합니다. 래핑 규칙은 다음과 같습니다.
t가CompletionException이면 그대로.- 그 외에는
new CompletionException(t)로 한 번 감쌉니다.
get()은 별도로 한 번 더 감싸 ExecutionException을 던집니다. join()은 CompletionException을 그대로 throw합니다. join()이 체크 예외를 던지지 않는다는 점이 Stream 안에서 쓰기 편한 이유입니다.
전파의 단순함
UniApply.tryFire에서 본 분기가 전파의 본체입니다.
if (r instanceof AltResult) {
if ((x = ((AltResult)r).ex) != null) {
d.completeThrowable(x, r);
break tryComplete;
}
r = null;
}
source가 예외 상태면 함수 호출을 건너뛰고 그대로 dependent를 같은 예외로 완료. 별도의 try/catch도, 별도의 콜백 시그니처도 필요 없습니다. whenComplete/handle/exceptionally 같은 분기형 액션만 자기 Completion 클래스 안에서 다르게 처리합니다.
exceptionally와 handle의 차이
exceptionally(fn)은 source가 예외 상태일 때만 fn을 호출하고, 정상이면 값을 그대로 통과시킵니다(UniExceptionally).handle((v, t) -> ...)은 항상 호출되며 정상 결과나 예외를 양쪽 인자로 받습니다(UniHandle).whenComplete((v, t) -> ...)도 항상 호출되지만 값을 변환하지 않습니다. 부수 효과만 가능합니다(UniWhenComplete).
함정은 handle로 예외를 잡으면 dependent stage는 정상 상태가 되지만, whenComplete로는 예외가 그대로 전파된다는 점입니다. 같은 시그니처라도 의미가 다릅니다.
9. allOf / anyOf — 합성기
allOf는 이진 트리
allOf(c1, c2, c3, ..., cn)은 단순한 N-입력 Completion을 두지 않습니다. 대신 입력 배열을 반으로 쪼개 재귀로 합치는 트리를 만듭니다.
static CompletableFuture<Void> andTree(CompletableFuture<?>[] cfs, int lo, int hi) {
CompletableFuture<Void> d = new CompletableFuture<>();
if (lo > hi) d.result = NIL;
else {
CompletableFuture<?> a, b; Object r, s, z; Throwable x;
int mid = (lo + hi) >>> 1;
if ((a = (lo == mid ? cfs[lo] : andTree(cfs, lo, mid))) == null ||
(b = (lo == hi ? a : (hi == mid+1) ? cfs[hi] : andTree(cfs, mid+1, hi))) == null)
throw new NullPointerException();
if (!d.biRelay(a, b))
new BiRelay<>(d, a, b).tryFire(SYNC);
}
return d;
}
BiRelay는 두 입력이 모두 완료되었을 때만 발화하는 두-입력 Completion입니다. 트리 형태로 합치면 모든 leaf가 완료되어야 root가 완료되고, 첫 번째 예외가 root까지 그대로 전파됩니다. depth가 log n이라 의존 액션 등록 비용도 N에 비례하지 않습니다.
allOf의 결과 타입이 CompletableFuture<Void>인 이유도 트리 합성에서 나옵니다. 결과를 한 자리에 모을 자료구조가 없으니 사용자가 직접 결과를 모아야 합니다. 흔한 패턴은 이렇습니다.
CompletableFuture<List<T>> joined = CompletableFuture.allOf(futures)
.thenApply(__ -> Arrays.stream(futures)
.map(CompletableFuture::join)
.toList());
allOf로 join을 기다리고, thenApply 안에서는 모든 future가 완료된 후이므로 join()이 블로킹하지 않습니다.
anyOf는 공유 인스턴스
anyOf는 동일한 AnyOf Completion 인스턴스를 입력들의 스택에 모두 push합니다. 첫 번째로 완료된 입력의 스택에서 postComplete이 이 AnyOf를 트리거하면 dependent를 완료시키고, 나머지 입력 스택에 남은 같은 인스턴스는 isLive()가 false를 반환해 무해하게 무시됩니다.
flowchart LR
A[CF a] -->|push| X[AnyOf instance]
B[CF b] -->|push| X
C[CF c] -->|push| X
X -->|first wins| D[anyOf result]
10. JDK 9 이후 — JEP 266의 흔적
JDK 9의 JEP 266 More Concurrency Updates가 CompletableFuture에 굵직한 API 추가를 가져왔습니다. 핵심만 짚습니다.
defaultExecutor() 오버라이드 지점
위에서 본 그 메서드. 서브클래스로 기본 Executor를 갈아끼울 수 있게 한 변경입니다. JDK 8에서는 commonPool이 하드코딩되어 있었습니다.
newIncompleteFuture() 팩토리
thenApply 같은 메서드가 새 stage를 만들 때 호출하는 protected 팩토리. 서브클래스가 같은 타입의 자식을 만들도록 강제하기 위해 추가되었습니다.
copy()와 minimalCompletionStage()
copy(): 동일한 결과로 완료되는 새 CompletableFuture를 반환. 원본의 완료 상태를 변경하지 못하게 격리할 때 사용.UniRelay로 구현됩니다.minimalCompletionStage():CompletionStage만 노출하고complete*/obtrudeValue같은 변경 메서드는 던지도록 제한. API 경계에서 외부 호출자가 stage를 강제 완료시키는 것을 막습니다.
시간 기반 메서드
public CompletableFuture<T> orTimeout(long timeout, TimeUnit unit);
public CompletableFuture<T> completeOnTimeout(T value, long timeout, TimeUnit unit);
public static Executor delayedExecutor(long delay, TimeUnit unit);
public static Executor delayedExecutor(long delay, TimeUnit unit, Executor executor);
내부적으로 ScheduledThreadPoolExecutor 한 개를 공유하는 Delayer 정적 유틸이 시간을 잰 뒤 dependent를 완료시킵니다. Delayer의 스케줄러는 daemon 스레드 한 개입니다. 많은 양의 짧은 타임아웃을 정확히 처리하기보다는 가벼운 데드라인 도구로 설계됐습니다.
completeAsync(Supplier)
이미 만들어진 CompletableFuture를 supplier로 비동기 완료시키는 메서드. supplyAsync 결과를 다른 stage에 연결하는 두 단계가 한 줄로 줄어듭니다.
11. get()과 join()은 어떻게 블로킹하는가
미완료 stage에 get()을 부르면 호출 스레드가 멈춰야 합니다. 그 메커니즘이 Signaller입니다.
static final class Signaller extends Completion implements ForkJoinPool.ManagedBlocker {
long nanos;
final long deadline;
final boolean interruptible;
boolean interrupted;
volatile Thread thread;
// ...
public boolean isReleasable() { ... } // 완료 또는 interrupt면 true
public boolean block() { LockSupport.park(this); ... }
final CompletableFuture<?> tryFire(int mode) {
Thread w = thread;
if (w != null) { thread = null; LockSupport.unpark(w); }
return null;
}
}
get()은 새 Signaller를 만들어 스택에 push한 뒤 ForkJoinPool.managedBlock(signaller)를 호출합니다. 호출 스레드가 ForkJoin 워커라면 풀이 보조 워커를 만들어 parallelism을 보존하고, 그렇지 않으면 그냥 block() 안에서 LockSupport.park. source가 완료되어 postComplete이 Signaller.tryFire를 호출하면 unpark로 깨어나 결과를 반환합니다.
managedBlock 협조 덕에 commonPool 안에서 다른 future를 join()해도 풀이 굶지 않습니다. 이것이 같은 스레드 풀 위에서 무한 합성 체인이 가능하게 만드는 마지막 조각입니다.
12. 자주 부딪히는 함정
commonPool에서 블로킹 IO 던지기
supplyAsync(() -> jdbcTemplate.query(...))처럼 블로킹 호출을 commonPool에 던지면 풀 워커 하나가 IO 동안 잡힙니다. 합리적인 parallelism보다 많은 동시 호출이 들어오면 다른 모든 CompletableFuture가 굶습니다. 블로킹 IO는 전용 Executor에 보내고, commonPool은 짧은 CPU 작업에만 씁니다.
thenApply vs thenApplyAsync 혼동
cf.thenApply(this::heavyComputation)
cf가 이미 완료되어 있으면 heavyComputation은 호출 스레드(thenApply를 부른 스레드) 에서 실행됩니다. 미완료이면 cf를 완료시킨 스레드에서 실행됩니다. 어디서 실행될지 예측 불가능합니다. CPU 무거운 작업이라면 thenApplyAsync나 명시적 Executor를 지정합니다.
get()이 ExecutionException을 던지는 이유
get()은 체크 예외(InterruptedException, ExecutionException)를 던지는 Future 시그니처를 따릅니다. 원인 예외는 getCause()로 꺼냅니다. join()은 CompletionException(unchecked)을 던져 Stream이나 람다 안에서 쓰기 편하지만, 두 메서드의 예외 타입이 다릅니다.
whenComplete로 예외를 "처리"했다고 착각
whenComplete는 예외를 잡기만 할 뿐 dependent stage 결과를 바꾸지 못합니다. handle이나 exceptionally를 써야 예외 상태를 정상으로 회복시킵니다.
parallelism 1 환경의 thread per task
ForkJoinPool.commonPool().getParallelism() == 1이면 defaultExecutor()가 ThreadPerTaskExecutor로 바뀝니다. 의도하지 않은 스레드 생성을 피하려면 자체 ExecutorService를 명시적으로 전달합니다.
cancel()은 작업을 멈추지 않는다
CompletableFuture.cancel(mayInterruptIfRunning)은 stage를 CancellationException으로 완료시킬 뿐, 진행 중인 Supplier나 Function을 인터럽트하지 않습니다. mayInterruptIfRunning 매개변수는 무시됩니다. 진짜 인터럽트가 필요하면 Future로 캡쳐한 다른 작업을 직접 취소해야 합니다.
13. 정리
CompletableFuture의 설계 미덕은 상태 머신을 거의 없앤 것입니다. result 필드의 null 여부 하나로 완료 여부를 표현하고, 의존 액션은 Treiber 스택 한 개에 모읍니다. 모든 합성 메서드는 결국 "Completion 한 개를 만들어 스택에 push한 뒤, source의 tryFire를 한 번 호출"이라는 같은 모양으로 환원됩니다.
Completion이 ForkJoinTask이자 Runnable이라는 단일 객체로 노드, 비동기 작업, 동기 작업 세 역할을 겸하는 것도 객체 비용을 줄이는 핵심입니다. 발화 모드(SYNC/ASYNC/NESTED)와 claim() 비트만으로 "어디서 실행할지" 분기가 표현됩니다.
CompletableFuture를 잘 쓰는 길은 이 모델을 이해하고 두 가지를 신경 쓰는 것입니다.
- 어디서 실행되는가 —
Async변형과 Executor를 의식적으로 선택한다. - commonPool을 막지 않는다 — 블로킹 호출은 전용 풀로 보낸다.
상태 머신 두 줄로 시작한 글이 동시성 합성 도구로서의 깊이를 갖게 된 이유는, 그 두 줄 아래에 Treiber 스택과 Completion 클래스 패밀리, 그리고 Delayer/Signaller 같은 보조 기계가 깔끔하게 자리잡고 있기 때문입니다.
참고자료
- OpenJDK
CompletableFuture.java소스 (master 브랜치) - CompletableFuture (Java Platform SE 8) Javadoc
- CompletableFuture (Java SE 17) Javadoc
- JEP 266: More Concurrency Updates
- ForkJoinPool Javadoc — commonPool
- JSR-166 Concurrency Interest 사이트
- R. K. Treiber, "Systems Programming: Coping with Parallelism", IBM RJ 5118, 1986 (Treiber 스택 원전)

