외부 API를 세 번 순차로 호출하면, 각 호출이 1초씩만 걸려도 사용자는 3초를 기다립니다. 그런데 세 호출이 서로 의존하지 않는다면 굳이 줄 세울 이유가 없습니다. 동시에 띄우고 다 같이 기다리면 총 시간은 가장 느린 하나, 즉 1초로 줄어듭니다. 이걸 깔끔하게 표현하는 도구가 자바의 CompletableFuture입니다.
이 글에서는 순차 호출이 왜 느린지부터 시작해, supplyAsync로 각 호출을 비동기로 띄우고 allOf로 결과를 모으는 기본 패턴, 그리고 실무에서 반드시 챙겨야 할 타임아웃·예외 처리와 전용 스레드풀 설정까지 단계별로 정리합니다.
순차 호출은 응답 시간이 그냥 더해진다

먼저 흔한 순차 호출 코드입니다. 프로필 화면을 그리기 위해 사용자·주문·포인트 정보를 각각 다른 API에서 가져옵니다.
// 순차 호출: 세 응답 시간이 그대로 더해진다 (약 3초)
UserInfo user = userClient.get(id); // ~1s
List<Order> orders = orderClient.get(id); // ~1s
Point point = pointClient.get(id); // ~1s
return new Profile(user, orders, point);
세 호출은 서로의 결과를 쓰지 않습니다. 그런데도 코드가 위에서 아래로 실행되기 때문에, 앞 호출이 끝나야 다음이 시작됩니다. 응답 시간이 1 + 1 + 1 = 3초로 그대로 합산되는 것입니다. 외부 호출 하나하나에 타임아웃을 거는 문제는 RestTemplate 타임아웃 누락, 전 서버가 멈춘 날에서 다뤘는데, 여기서는 한 걸음 더 나아가 여러 호출을 동시에 처리해 대기 시간 자체를 줄이는 이야기입니다.
supplyAsync로 각 호출을 비동기로 띄운다
첫 단계는 각 호출을 CompletableFuture.supplyAsync로 감싸는 것입니다. 이렇게 하면 호출이 즉시 반환되고, 실제 작업은 별도 스레드에서 시작됩니다.
private final Executor apiExecutor; // 전용 스레드풀 (아래에서 설정)
CompletableFuture<UserInfo> fUser =
CompletableFuture.supplyAsync(() -> userClient.get(id), apiExecutor);
CompletableFuture<List<Order>> fOrders =
CompletableFuture.supplyAsync(() -> orderClient.get(id), apiExecutor);
CompletableFuture<Point> fPoint =
CompletableFuture.supplyAsync(() -> pointClient.get(id), apiExecutor);
이 세 줄이 실행되는 순간 세 호출이 거의 동시에 시작됩니다. 각 supplyAsync는 작업을 스레드풀에 맡기고 곧바로 다음 줄로 넘어가기 때문입니다. 여기서 두 번째 인자로 apiExecutor를 넘긴 점이 중요합니다. 실행할 스레드풀을 명시하지 않으면 기본 공용 풀이 쓰이는데, 이는 외부 호출처럼 대기가 많은 작업에는 적합하지 않습니다. 이 부분은 뒤에서 다시 다룹니다.
allOf로 모두 끝나길 기다렸다가 결과를 모은다
호출을 띄웠으니, 이제 셋이 모두 끝나기를 기다렸다가 결과를 합쳐야 합니다. CompletableFuture.allOf가 이 역할을 합니다.
// 셋 다 끝날 때까지 대기 (총 시간 ≈ 가장 느린 호출 ≈ 1초)
CompletableFuture.allOf(fUser, fOrders, fPoint).join();
return new Profile(fUser.join(), fOrders.join(), fPoint.join());
allOf(...).join()은 전달한 모든 Future가 완료될 때까지 기다립니다. 세 호출이 병렬로 진행됐으므로 전체 대기 시간은 세 개의 합이 아니라 가장 오래 걸린 하나로 수렴합니다. 3초짜리 작업이 1초로 줄어드는 것입니다. 이후 각 join()으로 개별 결과를 꺼내 최종 객체를 조립합니다. 이 시점에는 이미 작업이 끝나 있으므로 join()은 즉시 값을 돌려줍니다.
타임아웃과 예외 처리를 반드시 건다
병렬로 띄웠다고 끝이 아닙니다. 외부 호출은 언제든 느려지거나 실패할 수 있고, 하나가 무한정 늘어지면 allOf도 그만큼 멈춥니다. 그래서 각 Future에 타임아웃과 예외 대비를 걸어야 합니다.
CompletableFuture<Point> fPoint =
CompletableFuture.supplyAsync(() -> pointClient.get(id), apiExecutor)
.orTimeout(2, TimeUnit.SECONDS) // Java 9+: 2초 넘으면 예외 발생
.exceptionally(ex -> Point.zero()); // 실패 시 기본값으로 대체
orTimeout은 정해진 시간이 지나면 해당 Future를 예외로 완료시켜, 느린 호출 하나가 전체를 붙잡는 상황을 막습니다. exceptionally는 실패했을 때 기본값을 돌려주어, 포인트 조회가 실패해도 사용자·주문 정보는 정상적으로 화면에 그릴 수 있게 합니다. 부분 실패를 어떻게 다룰지를 미리 정해두는 것이, 병렬 호출을 안전하게 만드는 핵심입니다.
전용 스레드풀을 반드시 지정한다
마지막으로, 앞에서 미룬 스레드풀 이야기입니다. supplyAsync에 Executor를 주지 않으면 자바의 공용 풀(ForkJoinPool.commonPool)이 쓰이는데, 이 풀은 CPU 코어 수에 맞춰 크기가 정해집니다. CPU 연산에는 적합하지만, 응답을 기다리며 노는 시간이 많은 외부 호출(I/O 대기)에는 스레드가 금세 부족해집니다.
@Bean("apiExecutor")
public Executor apiExecutor() {
var executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(20); // I/O 대기가 많으므로 코어 수보다 넉넉히
executor.setMaxPoolSize(40);
executor.setQueueCapacity(200);
executor.setThreadNamePrefix("api-call-");
executor.initialize();
return executor;
}
외부 호출 전용 풀을 따로 두면, 이 작업이 애플리케이션의 다른 비동기 작업과 스레드를 다투지 않습니다. 스레드 이름 접두어를 지정해 두면 문제 발생 시 스레드 덤프에서 추적하기도 쉽습니다. 비동기 실행에서 스레드풀과 프록시 경계를 다루는 함정은 @Async를 정리한 글에서 별도로 짚었고, 블로킹 호출 자체를 줄이는 대안은 Java 가상 스레드 실전 적용과 한계 총정리에서 다뤘습니다. 여러 서비스를 호출해 조합하는 설계 전반은 MSA 서비스 간 호출 해결 방법 비교 가이드를 참고하면 좋습니다.
자주 묻는 질문 (FAQ)
Q1. supplyAsync에 Executor를 안 넘기면 어떻게 되나요?
기본 공용 풀(ForkJoinPool.commonPool)이 사용됩니다. 이 풀은 CPU 코어 수에 맞춰져 있어, 외부 호출처럼 대기가 많은 작업이 몰리면 스레드가 부족해 병렬 효과가 떨어집니다. I/O 성격의 호출에는 전용 ThreadPoolTaskExecutor를 지정하는 것이 좋습니다.
Q2. 호출 하나가 실패하면 전체가 실패하나요?
allOf(...).join()은 하나라도 예외로 끝나면 전체가 예외를 던집니다. 그래서 각 Future에 exceptionally나 handle로 개별 fallback을 걸어두면, 실패한 호출만 기본값으로 대체하고 나머지 결과는 정상적으로 사용할 수 있습니다.
Q3. 가상 스레드를 쓰면 CompletableFuture가 필요 없나요?
가상 스레드는 블로킹 호출의 비용을 낮춰 주지만, “여러 작업을 동시에 띄우고 그 결과를 조합하는” 흐름 자체는 여전히 표현이 필요합니다. 그 역할을 CompletableFuture(또는 구조적 동시성)가 담당하므로, 둘은 대체 관계라기보다 함께 쓰는 도구에 가깝습니다.
적용 순서로 정리하면
병렬 호출은 세 단계로 요약됩니다. 서로 의존하지 않는 외부 호출을 supplyAsync로 각각 띄우고, allOf로 모두 기다렸다가, 각 결과를 조합합니다. 여기에 실무에서는 두 가지를 반드시 얹어야 합니다. 각 호출에 타임아웃과 fallback을 걸어 부분 실패에 대비하고, 외부 호출 전용 스레드풀을 지정해 다른 작업과 자원을 다투지 않게 하는 것입니다.
한 줄로 남기면, 서로 독립적인 외부 호출은 순차로 줄 세우지 말고 CompletableFuture로 병렬로 띄운 뒤 함께 기다리면, 총 응답 시간이 ‘합’에서 ‘최댓값’으로 줄어듭니다.