CompletableFuture로 외부 API 병렬 호출하기

외부 API를 세 번 순차로 호출하면, 각 호출이 1초씩만 걸려도 사용자는 3초를 기다립니다. 그런데 세 호출이 서로 의존하지 않는다면 굳이 줄 세울 이유가 없습니다. 동시에 띄우고 다 같이 기다리면 총 시간은 가장 느린 하나, 즉 1초로 줄어듭니다. 이걸 깔끔하게 표현하는 도구가 자바의 CompletableFuture입니다.

이 글에서는 순차 호출이 왜 느린지부터 시작해, supplyAsync로 각 호출을 비동기로 띄우고 allOf로 결과를 모으는 기본 패턴, 그리고 실무에서 반드시 챙겨야 할 타임아웃·예외 처리와 전용 스레드풀 설정까지 단계별로 정리합니다.


순차 호출은 응답 시간이 그냥 더해진다

순차는 응답 시간이 합산(3초), 병렬은 가장 느린 하나(1초)로 수렴.

먼저 흔한 순차 호출 코드입니다. 프로필 화면을 그리기 위해 사용자·주문·포인트 정보를 각각 다른 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에 exceptionallyhandle로 개별 fallback을 걸어두면, 실패한 호출만 기본값으로 대체하고 나머지 결과는 정상적으로 사용할 수 있습니다.

Q3. 가상 스레드를 쓰면 CompletableFuture가 필요 없나요?

가상 스레드는 블로킹 호출의 비용을 낮춰 주지만, “여러 작업을 동시에 띄우고 그 결과를 조합하는” 흐름 자체는 여전히 표현이 필요합니다. 그 역할을 CompletableFuture(또는 구조적 동시성)가 담당하므로, 둘은 대체 관계라기보다 함께 쓰는 도구에 가깝습니다.


적용 순서로 정리하면

병렬 호출은 세 단계로 요약됩니다. 서로 의존하지 않는 외부 호출을 supplyAsync로 각각 띄우고, allOf로 모두 기다렸다가, 각 결과를 조합합니다. 여기에 실무에서는 두 가지를 반드시 얹어야 합니다. 각 호출에 타임아웃과 fallback을 걸어 부분 실패에 대비하고, 외부 호출 전용 스레드풀을 지정해 다른 작업과 자원을 다투지 않게 하는 것입니다.

한 줄로 남기면, 서로 독립적인 외부 호출은 순차로 줄 세우지 말고 CompletableFuture로 병렬로 띄운 뒤 함께 기다리면, 총 응답 시간이 ‘합’에서 ‘최댓값’으로 줄어듭니다.

댓글 남기기