JAVA

Java 모던 동시성 — ExecutorService & CompletableFuture로 스레드 풀과 비동기 처리 정리

IT Lab 2026. 2. 25. 10:00

Java 17 기준으로 ExecutorService 스레드 풀 관리와 CompletableFuture 비동기 조합 패턴을 실무 관점에서 정리합니다.

도입 (문제 상황)

외부 API를 5개 호출하는데, 하나가 느려지면 전체 응답이 같이 늦어지는 경험을 해 보셨을 거예요. 또는 “스레드를 직접 만들면 되지 않나?”로 시작했다가, 스레드가 늘어나면서 CPU 컨텍스트 스위칭과 장애 대응이 어려워진 적도 있을 겁니다. 이럴 때 필요한 게 스레드 풀(ExecutorService)비동기 조합(CompletableFuture) 입니다.

핵심 개념: Java에서 “스레드 관리”와 “비동기 조합”을 분리해서 생각하기

ExecutorService 스레드 풀에서 여러 비동기 작업이 실행되고 CompletableFuture로 합쳐지는 흐름도

 

동시성에서 중요한 건 단순히 “동시에 실행”이 아니라, 자원을 예측 가능하게 쓰고(스레드 풀), 결과를 안전하게 합치고(비동기 파이프라인), 실패/타임아웃을 통제하는 일입니다.

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 원칙 — 코드로 이해하기]