Java API 동시 호출이 폭주할 때 생기는 실제 장애

외부 API를 여러 개 호출하는 서비스는 순간 요청 수가 커지면 곧바로 병목이 드러납니다. 가장 흔한 증상은 아래 순서입니다.

  • 연결 타임아웃이 쌓이며 95퍼센타일 응답시간이 급등
  • 쓰레드 풀(ExecutorService)에서 큐가 꽉 차고 신규 요청이 거부
  • 한 개 실패가 여러 하위 호출을 연쇄 실패시키며 전체 요청이 실패
  • 장애가 있어도 운영자가 사후에야 원인을 찾음

이 문서는 "Java에서 동시 호출 경로를 제어하고, 실패를 격리하며, 복구 가능하게 만드는" 기준을 바로 실무에 적용할 수 있도록 정리했습니다.

기본 전략: 동시성은 반드시 제한하고, timeout은 항상 분리한다

중요한 원칙은 세 가지입니다.

  • 무한 병렬화 금지: 전체 요청에서 하위 API 동시 호출 개수에 상한을 둡니다.
  • 동일 경로 timeout 통일: 연결/쓰기/읽기 시간 제한을 각각 분리합니다.
  • 부분 실패 허용: 전체를 바로 실패 처리하지 않고, 실패 경로만 빠르게 완화해 실패 범위를 축소합니다.

가장 많이 쓰는 조합은 ExecutorService + Semaphore + HttpClient timeout + CompletableFuture 집계입니다.

실전 코드 예시

import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.List;
import java.util.Map;
import java.util.concurrent.*;
import java.util.stream.Collectors;

public class DownstreamBatch {
  private static final ExecutorService apiPool =
      Executors.newFixedThreadPool(32, r -> new Thread(r, "api-worker-" + System.nanoTime()));
  private static final Semaphore inFlight = new Semaphore(24);
  private static final HttpClient http = HttpClient.newBuilder()
      .connectTimeout(Duration.ofSeconds(1))
      .build();

  public static Map callAll(List endpoints) {
    List> tasks = endpoints.stream().map(url ->
      CompletableFuture.supplyAsync(() -> {
        boolean acquired = false;
        try {
          acquired = inFlight.tryAcquire(1, Duration.ofMillis(200));
          if (!acquired) return "SKIP_IN_FLIGHT_FULL";

          HttpRequest req = HttpRequest.newBuilder(URI.create(url))
              .timeout(Duration.ofSeconds(2))
              .GET()
              .build();
          return http.sendAsync(req, HttpResponse.BodyHandlers.ofString())
              .thenApply(r -> r.body())
              .get(2, TimeUnit.SECONDS);
        } catch (TimeoutException e) {
          return "TIMEOUT:" + url;
        } catch (Exception e) {
          return "ERROR:" + e.getClass().getSimpleName();
        } finally {
          if (acquired) inFlight.release();
        }
      }, apiPool)
    ).collect(Collectors.toList());

    Map results = new java.util.LinkedHashMap<>();
    for (CompletableFuture f : tasks) {
      try { results.put("ok", f.get(3, TimeUnit.SECONDS)); }
      catch (Exception e) { results.put("ok", "FALLBACK"); }
    }
    return results;
  }
}

운영 체크포인트

위 코드를 그대로 붙여 쓰는 것보다 운영 정책을 함께 맞춰야 효과가 납니다.

  • 동일 API 그룹별로 최대 동시 요청 수를 분리합니다.
  • 각 그룹 timeout을 SLO 기준으로 정합니다. 1초 타임아웃만 쓰지 말고 connect/read/write를 분리하세요.
  • 예외를 숨기지 말고 메트릭으로 분류합니다. TIMEOUT, ERROR, fallback를 따로 집계해야 원인 분리가 쉽습니다.
  • 재시도는 무조건 지수 백오프를 사용하고 동일 시간대에 동시에 재시도하지 않도록 jitter를 넣습니다.

기술 요약 체크리스트

  • 요청마다 최소 1개 이상의 동시성 상한이 있는가?
  • 전체 실패 시나리오를 부분 실패로 축소할 fallback이 있는가?
  • timeout이 연결/요청/응답 단계로 분리돼 있는가?
  • in-flight, queue length, timeout 비율, 실패 유형별 집계가 모니터링되는가?
  • 장애 재현을 위해 부하 테스트에서 동일 패턴을 자동 검증하고 있는가?