Java 17 기준으로 ExecutorService 스레드 풀 관리와 CompletableFuture 비동기 조합 패턴을 실무 관점에서 정리합니다.
도입 (문제 상황)
외부 API를 5개 호출하는데, 하나가 느려지면 전체 응답이 같이 늦어지는 경험을 해 보셨을 거예요. 또는 “스레드를 직접 만들면 되지 않나?”로 시작했다가, 스레드가 늘어나면서 CPU 컨텍스트 스위칭과 장애 대응이 어려워진 적도 있을 겁니다. 이럴 때 필요한 게 스레드 풀(ExecutorService) 과 비동기 조합(CompletableFuture) 입니다.
핵심 개념: Java에서 “스레드 관리”와 “비동기 조합”을 분리해서 생각하기

동시성에서 중요한 건 단순히 “동시에 실행”이 아니라, 자원을 예측 가능하게 쓰고(스레드 풀), 결과를 안전하게 합치고(비동기 파이프라인), 실패/타임아웃을 통제하는 일입니다.
ExecutorService: 스레드를 “직접 생성”하지 말고 “풀로 운영”해야 하는 이유
new Thread()는 호출할 때마다 OS 스레드를 만들 수 있어, 트래픽이 올라가면 스레드 폭증으로 이어지기 쉽습니다.ExecutorService는 작업(태스크)을 큐에 넣고, 정해진 스레드 수로 처리합니다. 즉 동시 실행량을 상한선으로 제한할 수 있습니다.- 스레드 풀은 “성능”보다도 안정성(예측 가능성) 때문에 실무에서 필수입니다.
CompletableFuture: 콜백 지옥 대신 “조합 가능한 비동기”를 만드는 도구
CompletableFuture는 비동기 작업을 값처럼 다루게 해 줍니다.
thenApply/thenCompose: 다음 단계로 자연스럽게 연결allOf/anyOf: 여러 작업을 합치기exceptionally/handle/whenComplete: 실패를 파이프라인 안에서 처리orTimeout/completeOnTimeout: 타임아웃을 선언적으로 부여 (Java 9+)
핵심은 “비동기” 자체가 아니라, 여러 I/O 작업을 병렬로 던지고, 합치고, 실패를 통제하는 패턴을 깔끔하게 만드는 것입니다.
선택 가이드: ExecutorService vs CompletableFuture
둘은 경쟁 관계가 아니라 역할이 다릅니다. 하지만 실무에서는 “어떤 API를 중심으로 설계할지”가 고민이 되니, 아래처럼 정리해 두면 편합니다.
| 관점 | ExecutorService | CompletableFuture |
|---|---|---|
| 책임 | 스레드 풀/큐 기반 실행 관리 | 비동기 결과의 조합/파이프라인 |
| 사용 방식 | submit(), invokeAll() 등 |
supplyAsync(), then..., allOf() |
| 강점 | 동시 실행량 제한, 운영 안정성 | 병렬 실행 + 결과 합치기, 예외/타임아웃 처리 |
| 주의점 | 종료(shutdown) 누락, 큐 적체 | 기본 풀(공용 풀) 남용, join 남발 |
| 추천 상황 | “이 작업은 풀에서 실행”이 명확할 때 | “여러 비동기 결과를 조합”해야 할 때 |
flowchart LR
A["Request"] --> B["Create tasks"]
B --> C["ExecutorService thread pool"]
C --> D["CompletableFuture pipeline"]
D --> E["Combine results"]
E --> F["Response"]
스레드 풀에서 실행(자원 통제)하고, CompletableFuture로 결과를 조합(흐름 제어)하는 구조입니다.
코드 예제: ExecutorService + CompletableFuture로 “외부 API 병렬 호출 + 타임아웃 + 부분 실패 허용”
아래 코드는 Java 17에서 그대로 실행 가능합니다. 핵심은:
- 고정 크기 스레드 풀로 동시성 상한을 둡니다.
CompletableFuture.supplyAsync(..., executor)로 공용 풀(ForkJoinPool.commonPool) 대신 우리가 만든 풀을 씁니다.orTimeout+handle로 타임아웃/예외를 값으로 흡수해서 “부분 실패 허용”을 구현합니다.- 마지막에 반드시
shutdown()합니다.
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.*;
import java.util.stream.Collectors;
public class ModernConcurrencyDemo {
// 외부 API 호출 결과를 단순화한 DTO
record ApiResult(String name, boolean success, String payload, String error) {
static ApiResult ok(String name, String payload) {
return new ApiResult(name, true, payload, null);
}
static ApiResult fail(String name, String error) {
return new ApiResult(name, false, null, error);
}
}
public static void main(String[] args) {
int poolSize = 4; // 동시 실행 상한(예: 외부 API 병렬 호출 수 제한)
ExecutorService executor = Executors.newFixedThreadPool(poolSize, new ThreadFactory() {
private final ThreadFactory defaultFactory = Executors.defaultThreadFactory();
private int idx = 1;
@Override
public Thread newThread(Runnable r) {
Thread t = defaultFactory.newThread(r);
t.setName("api-pool-" + (idx++));
t.setDaemon(false);
return t;
}
});
try {
List<String> apis = List.of("profile", "orders", "coupon", "recommend", "inventory");
Duration timeout = Duration.ofMillis(700);
List<CompletableFuture<ApiResult>> futures = new ArrayList<>();
for (String api : apis) {
CompletableFuture<ApiResult> f = CompletableFuture
.supplyAsync(() -> callExternalApi(api), executor)
// Java 9+: 타임아웃을 선언적으로 부여
.orTimeout(timeout.toMillis(), TimeUnit.MILLISECONDS)
// 예외/타임아웃을 "실패 결과"로 변환해 전체 파이프라인을 살림
.handle((value, ex) -> {
if (ex != null) {
return ApiResult.fail(api, ex.getClass().getSimpleName() + ": " + ex.getMessage());
}
return ApiResult.ok(api, value);
});
futures.add(f);
}
// allOf는 "모두 완료"만 알려주므로, 결과 수집은 join으로 가져옵니다.
CompletableFuture<Void> allDone = CompletableFuture.allOf(futures.toArray(new CompletableFuture[0]));
List<ApiResult> results = allDone.thenApply(v ->
futures.stream().map(CompletableFuture::join).collect(Collectors.toList())
).join();
System.out.println("=== Aggregated Results ===");
results.forEach(r -> {
if (r.success()) {
System.out.printf("[%s] OK: %s%n", r.name(), r.payload());
} else {
System.out.printf("[%s] FAIL: %s%n", r.name(), r.error());
}
});
} finally {
// 종료 누락은 스레드 누수/프로세스 종료 지연으로 이어질 수 있습니다.
executor.shutdown();
}
}
// 데모용: 외부 API 호출을 흉내냄 (랜덤 지연 + 일부 실패)
private static String callExternalApi(String name) {
try {
long delayMs = switch (name) {
case "profile" -> 120;
case "orders" -> 450;
case "coupon" -> 900; // 일부러 타임아웃 유도
case "recommend" -> 250;
case "inventory" -> 600;
default -> 300;
};
Thread.sleep(delayMs);
if ("inventory".equals(name)) {
throw new RuntimeException("Upstream 500");
}
return "data-from-" + name + " (delay=" + delayMs + "ms, thread=" + Thread.currentThread().getName() + ")";
} catch (InterruptedException e) {
Thread.currentThread().interrupt(); // 인터럽트 복구
throw new RuntimeException("Interrupted");
}
}
}
실무 팁
💡 실무에서는: CompletableFuture 기본 풀(commonPool) 남용을 피하세요
CompletableFuture.supplyAsync()에 executor를 안 넘기면 기본적으로ForkJoinPool.commonPool을 사용합니다.- 공용 풀은 애플리케이션 전역에서 공유되기 때문에, 특정 기능의 느린 I/O가 공용 풀을 잠식하면 전혀 다른 기능까지 같이 느려지는 문제가 생길 수 있어요.
- I/O 작업(외부 API, DB, 파일)은 보통 전용 ExecutorService를 두고, 풀 크기/큐 정책을 서비스 특성에 맞게 운영하는 편이 안전합니다.
💡 실무에서는: “부분 실패 허용”과 “전체 실패”를 명확히 나누어 설계해 보세요
- 위 예제처럼
handle()로 실패를 값으로 바꾸면 응답은 항상 내려가지만, 장애를 조용히 숨길 위험도 있습니다. - “추천/쿠폰”처럼 없어도 되는 데이터는 부분 실패 허용, “결제/재고”처럼 필수 데이터는 즉시 실패처럼 업무 중요도에 따라 정책을 분리해 두는 게 좋습니다.
- 타임아웃도 마찬가지로, 전체 SLA에 맞춰 “각 호출 타임아웃”과 “전체 요청 타임아웃”을 구분해 적용해 보세요.
핵심 요약: ExecutorService로 동시 실행량을 통제하고, CompletableFuture로 비동기 결과를 조합하세요.
핵심 요약: 공용 풀(commonPool) 대신 전용 풀을 쓰면 장애 전파를 줄일 수 있습니다.
핵심 요약: 타임아웃/예외를 “정책”으로 다뤄 부분 실패 허용 여부를 명확히 하세요.
다음 글: [#27 SOLID 원칙 — 코드로 이해하기]