동기 외부 API 호출이 느려질 때 서버 장애로 번지지 않게 막는 방법
빠른 답
- 동기 외부 호출은
connection timeout,read timeout,connection pool wait timeout을 나눠 설정해야 합니다. - 외부 서비스 여러 개가 같은 HTTP 커넥션 풀을 공유하면, 한 서비스의 지연이 다른 연동까지 막을 수 있습니다.
- 서비스별 커넥션 풀, 동시 실행량 제한, 서킷 브레이커를 함께 두면 장애 전파 범위를 줄일 수 있습니다.
- fallback은 실패를 숨기는 장치가 아니라, 실패를 기록하면서 사용자 흐름을 어디까지 유지할지 정하는 정책입니다.
목차
시간 흐름으로 이해하기
선택 기준 매트릭스
외부 API 지연이 내부 장애로 번지는 이유
동기 방식의 외부 API 호출은 내부 서버가 외부 서비스의 응답을 받은 뒤 다음 처리를 이어가는 구조입니다. 흐름이 단순하고 결과를 다루기 쉽지만, 외부 서비스가 느려질 때 내부 서버의 스레드, 커넥션, 메모리도 함께 오래 묶입니다.
예를 들어 주문 API가 결제 승인 API를 동기 호출한다고 가정해 보겠습니다. 결제사가 100ms 안에 응답할 때는 문제가 잘 보이지 않습니다. 그런데 결제사 응답이 5초, 10초로 늘어나면 주문 서버는 그 시간 동안 요청 처리를 끝내지 못합니다. 사용자는 응답을 기다리고, 애플리케이션 스레드는 반환되지 않으며, HTTP 커넥션 풀도 점점 비어갑니다.
이때 장애는 외부 API 하나에서 시작했지만 내부 서버에서는 다음 신호로 관찰됩니다.
- 특정 API의
p95,p99응답 시간이 증가합니다. - WAS 또는 애플리케이션 서버의 활성 스레드 수가 증가합니다.
- 외부 API 커넥션 풀의
active,pending값이 증가합니다. - 정상인 다른 외부 서비스 호출까지 지연됩니다.
- 게이트웨이나 애플리케이션에서
504,503,500응답이 늘어납니다.
동기 호출에서는 기다리는 행위 자체가 내부 서버의 자원을 점유합니다. 그래서 외부 장애를 내부 장애로 만들지 않으려면 “얼마나 기다릴지”, “어떤 기능이 어떤 자원을 쓸 수 있는지”, “계속 실패할 때 호출을 멈출지”를 미리 정해 두어야 합니다.
동기 호출 흐름과 대기 지점
외부 API 호출은 단순히 요청을 보내는 한 줄의 코드처럼 보이지만, 운영 환경에서는 여러 대기 지점을 지나갑니다. 각각의 대기 지점을 구분해야 장애가 연결 문제인지, 외부 서버 처리 지연인지, 내부 풀 고갈인지 나눠 볼 수 있습니다.
일반적인 요청 흐름은 다음 순서로 이어집니다.
- 요청 유입: 사용자 요청이 게이트웨이, 로드 밸런서, 애플리케이션 서버로 들어옵니다.
- 게이트웨이 또는 프록시: 요청 제한, 라우팅, 전체 요청 타임아웃이 적용될 수 있습니다.
- 애플리케이션 처리: 비즈니스 로직이 실행되고 외부 API 호출 코드에 도달합니다.
- 외부 서비스 대기: HTTP 연결 획득, TCP 연결, 응답 읽기 중 하나 이상의 구간에서 대기합니다.
- 모니터링과 응답: 성공, 실패, timeout, fallback 여부가 로그와 메트릭으로 남습니다.
외부 API 호출에서 특히 중요한 대기 지점은 세 곳입니다. 첫째, 외부 서버와 연결을 맺는 시간입니다. 둘째, 연결을 맺은 뒤 응답 데이터를 읽는 시간입니다. 셋째, 내부 커넥션 풀에서 사용할 연결을 빌릴 때까지 기다리는 시간입니다.
이 셋은 서로 다른 문제를 가리킵니다. 연결 자체가 안 되는 문제인지, 연결은 됐지만 외부 서버가 응답을 늦게 주는 문제인지, 아니면 우리 애플리케이션의 커넥션 풀이 이미 고갈된 문제인지가 다릅니다. 운영 중에는 이 구분이 점검 순서의 출발점이 됩니다.
타임아웃 3가지를 나눠 설정하기
동기 외부 API 호출에서 타임아웃은 하나만 두면 부족합니다. connection timeout, read timeout, connection pool wait timeout은 서로 다른 구간을 제한합니다.
connection timeout은 외부 서버와 네트워크 연결을 맺는 데 허용할 시간입니다. DNS, 라우팅, 방화벽, 외부 서버 포트 문제 등이 있으면 이 구간에서 실패할 수 있습니다.
read timeout은 연결을 맺은 뒤 응답 데이터를 기다리는 시간입니다. 외부 서버가 요청을 받았지만 내부 처리 지연, DB 병목, 장애 등으로 응답을 늦게 주면 이 타임아웃에 걸립니다.
connection pool wait timeout은 내부 애플리케이션이 커넥션 풀에서 연결을 빌릴 때까지 기다리는 시간입니다. 외부 API 응답이 느려져 기존 커넥션이 오래 반환되지 않으면 새 요청은 외부 서비스로 나가기 전부터 풀 앞에서 대기합니다.
아래는 외부 서비스별로 타임아웃과 풀 크기를 분리한 설정 예시입니다. 실제 값은 트래픽, 외부 API SLA, 게이트웨이 timeout, 사용자 요청 허용 시간을 함께 놓고 조정해야 합니다.
external-api:
payment:
base-url: "https://payment.example.com"
connect-timeout-ms: 500
read-timeout-ms: 1500
pool-wait-timeout-ms: 200
max-connections: 50
shipping:
base-url: "https://shipping.example.com"
connect-timeout-ms: 500
read-timeout-ms: 1000
pool-wait-timeout-ms: 200
max-connections: 30
게이트웨이 timeout과 애플리케이션 내부 timeout도 함께 맞아야 합니다. 게이트웨이 timeout이 3초인데 내부 외부 API read timeout이 5초라면, 사용자는 이미 실패 응답을 받았는데 애플리케이션은 뒤늦게 외부 응답을 기다리는 상황이 생길 수 있습니다.
커넥션 풀 분리와 벌크헤드
여러 외부 서비스를 하나의 HTTP 커넥션 풀로 묶는 구성은 처음에는 간단합니다. 하지만 결제 API가 느려져 커넥션을 오래 붙잡으면, 배송 API나 알림 API 호출도 같은 풀에서 커넥션을 빌리지 못해 지연될 수 있습니다.
벌크헤드 패턴은 자원을 나누어 일부 장애가 전체로 번지지 않게 하는 방식입니다. 서버 애플리케이션에서는 외부 서비스별 커넥션 풀, 스레드 풀, 동시 실행량 제한으로 구현할 수 있습니다. 중요한 점은 “풀을 많이 만든다”가 아니라 “장애가 난 외부 서비스가 사용할 수 있는 자원의 경계를 둔다”는 데 있습니다.
아래 코드는 외부 서비스별 HTTP 클라이언트를 분리한다는 의도를 보여주는 예시입니다. 사용하는 HTTP 클라이언트 라이브러리에 따라 속성 이름은 달라질 수 있지만, 결제와 배송이 같은 풀을 공유하지 않도록 구성하는 방향은 같습니다.
public final class ExternalApiClients {
public CloseableHttpClient paymentClient() {
PoolingHttpClientConnectionManager manager =
new PoolingHttpClientConnectionManager();
manager.setMaxTotal(50);
manager.setDefaultMaxPerRoute(50);
RequestConfig config = RequestConfig.custom()
.setConnectTimeout(Timeout.ofMilliseconds(500))
.setResponseTimeout(Timeout.ofMilliseconds(1500))
.setConnectionRequestTimeout(Timeout.ofMilliseconds(200))
.build();
return HttpClients.custom()
.setConnectionManager(manager)
.setDefaultRequestConfig(config)
.build();
}
public CloseableHttpClient shippingClient() {
PoolingHttpClientConnectionManager manager =
new PoolingHttpClientConnectionManager();
manager.setMaxTotal(30);
manager.setDefaultMaxPerRoute(30);
RequestConfig config = RequestConfig.custom()
.setConnectTimeout(Timeout.ofMilliseconds(500))
.setResponseTimeout(Timeout.ofMilliseconds(1000))
.setConnectionRequestTimeout(Timeout.ofMilliseconds(200))
.build();
return HttpClients.custom()
.setConnectionManager(manager)
.setDefaultRequestConfig(config)
.build();
}
}
커넥션 풀을 분리했다면 메트릭도 함께 분리해야 합니다. external.payment.latency, external.payment.timeout, external.shipping.latency, external.shipping.pool.pending처럼 서비스별 지연 시간과 실패율을 따로 봐야 장애가 어디에서 시작됐는지 확인할 수 있습니다.
서킷 브레이커와 fallback 설계
타임아웃은 오래 기다리는 문제를 줄여 주지만, 외부 API가 계속 실패하는 상황에서는 매 요청마다 timeout까지 기다리는 것 자체가 비용이 됩니다. 서킷 브레이커는 실패율이나 느린 호출 비율이 기준을 넘으면 일정 시간 호출을 막고 빠르게 실패하도록 돕습니다.
서킷 브레이커에는 보통 세 가지 상태가 있습니다. closed는 평소처럼 호출하는 상태입니다. open은 실패가 많아 호출을 막는 상태입니다. half-open은 일정 시간이 지난 뒤 일부 호출만 통과시켜 회복 여부를 확인하는 상태입니다.
다음은 결제 API에 적용할 수 있는 설정 예시입니다. 최근 20개 호출 중 최소 10개 이상이 집계된 상태에서 실패율이 50%를 넘거나, 1.5초 이상 걸린 느린 호출 비율이 50%를 넘으면 회로를 열도록 잡았습니다.
resilience4j:
circuitbreaker:
instances:
paymentApi:
sliding-window-size: 20
minimum-number-of-calls: 10
failure-rate-threshold: 50
slow-call-duration-threshold: 1500ms
slow-call-rate-threshold: 50
wait-duration-in-open-state: 10s
permitted-number-of-calls-in-half-open-state: 3
서킷 브레이커는 fallback과 함께 설계해야 의미가 분명해집니다. 결제 승인처럼 정확성이 중요한 기능은 빠르게 실패시키고 사용자 재시도나 내부 보상 흐름으로 넘길 수 있습니다. 추천 상품, 부가 배너, 읽기 전용 통계처럼 핵심 흐름이 아닌 기능은 캐시된 값, 빈 목록, 기본값으로 사용자 흐름을 유지할 수 있습니다.
다만 fallback이 실패를 감추는 방식으로만 동작하면 운영자는 장애를 늦게 알아차립니다. fallback 응답을 주더라도 로그, 메트릭, 알림에서는 외부 API 실패와 서킷 상태가 분명히 보여야 합니다.
동시 실행량 제한 예시
외부 API별 커넥션 풀을 분리해도 애플리케이션 내부에서 너무 많은 동시 요청이 몰리면 자원 압박은 계속될 수 있습니다. 이때는 커넥션 풀과 별도로 동시 실행량 제한을 둘 수 있습니다.
아래 예시는 결제 API 호출을 최대 30개까지만 동시에 허용합니다. 제한을 넘으면 오래 줄 세우지 않고 빠르게 실패시켜 내부 서버의 대기열이 커지는 것을 막습니다.
public class PaymentApiCaller {
private final Semaphore bulkhead = new Semaphore(30);
private final HttpClient client;
public PaymentApiCaller(HttpClient client) {
this.client = client;
}
public String approve(String orderId) throws Exception {
if (!bulkhead.tryAcquire(100, TimeUnit.MILLISECONDS)) {
throw new IllegalStateException("payment api bulkhead full");
}
try {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://payment.example.com/approvals/" + orderId))
.timeout(Duration.ofMillis(1500))
.GET()
.build();
return client.send(request, HttpResponse.BodyHandlers.ofString()).body();
} finally {
bulkhead.release();
}
}
}
이 제한은 일부 요청을 빠르게 실패시킬 수 있습니다. 하지만 모든 요청이 내부 서버에서 오래 대기하다가 전체 장애로 번지는 것보다, 자원 사용 상한을 두고 나머지 요청의 회복 가능성을 남기는 편이 운영 관점에서는 다루기 쉽습니다.
재시도도 같은 기준으로 봐야 합니다. read timeout이 1.5초이고 재시도를 3번 허용하면, 한 요청이 외부 API 응답을 기다리는 시간은 최대 4.5초 이상이 될 수 있습니다. 게이트웨이 timeout, 사용자 요청 timeout, 내부 스레드 점유 시간을 합쳐 계산하지 않으면 재시도 정책이 장애를 키울 수 있습니다.
디버깅과 운영 점검 순서
외부 API 지연이 의심될 때는 먼저 대기 지점을 나눠 보는 편이 좋습니다. 연결이 느린지, 응답 읽기가 느린지, 풀 대기가 긴지에 따라 조치가 달라집니다.
애플리케이션 로그는 외부 서비스 이름, timeout 종류, 소요 시간, 커넥션 풀 상태를 함께 남기는 편이 분석에 유리합니다.
2026-04-13T10:15:21.342+09:00 WARN order-api
event=external_api_timeout
service=payment
timeout_type=read_timeout
elapsed_ms=1503
connect_timeout_ms=500
read_timeout_ms=1500
pool_wait_timeout_ms=200
pool_active=50
pool_idle=0
pool_pending=37
path=/orders
request_id=req-8f21a
이 로그에서는 연결 실패가 아니라 read_timeout이 발생했습니다. 동시에 pool_active=50, pool_idle=0, pool_pending=37이므로 결제 API 커넥션 풀이 꽉 차 있고 뒤에 기다리는 요청도 있다는 뜻입니다. 이 경우 외부 결제 API 지연이 내부 서버의 대기열로 번지고 있다고 해석할 수 있습니다.
운영 점검은 보통 다음 순서로 진행하면 원인을 좁히기 쉽습니다.
- 게이트웨이에서 특정 경로의 응답 시간과
5xx,504,499증가 여부를 확인합니다. - 애플리케이션에서 외부 서비스별 latency, timeout count, circuit breaker state를 확인합니다.
- 커넥션 풀의
active,idle,pending값을 봅니다. - 스레드 풀 또는 이벤트 루프가 고갈되고 있는지 확인합니다.
- 외부 API 제공자 상태 페이지, 네트워크 지표, DNS 오류 로그를 확인합니다.
- 최근 배포에서 timeout, pool size, retry 설정이 바뀌었는지 확인합니다.
명령 출력도 함께 남겨 두면 장애 공유가 쉬워집니다. 예를 들어 특정 API가 게이트웨이 앞단에서 이미 오래 걸리고 있는지 다음처럼 확인할 수 있습니다.
$ curl -s -o /dev/null -w "status=%{http_code} connect=%{time_connect}s starttransfer=%{time_starttransfer}s total=%{time_total}s\n" \
https://api.example.com/orders/1001
status=504 connect=0.018s starttransfer=3.002s total=3.004s
이 출력은 TCP 연결은 0.018초로 빠르게 맺어졌지만, 첫 응답 바이트가 오기까지 약 3초가 걸렸고 결국 504가 반환된 상황을 보여줍니다. 연결 자체보다 뒤쪽 처리나 외부 호출 대기가 문제일 가능성이 큽니다.
배포와 운영 체크리스트
외부 API 장애 전파를 막는 설정은 코드에만 넣고 끝나지 않습니다. 배포 전후에 운영 환경의 timeout 계층이 서로 맞는지 확인해야 합니다. 게이트웨이, 애플리케이션, HTTP 클라이언트, 외부 API 제공자의 timeout이 충돌하면 의도와 다른 실패가 발생합니다.
배포 전에는 다음 항목을 확인합니다.
- 외부 서비스별 HTTP 클라이언트와 커넥션 풀이 분리되어 있는지 확인합니다.
connection timeout,read timeout,connection pool wait timeout이 모두 명시되어 있는지 확인합니다.- 내부 API의 전체 timeout보다 외부 API 호출 timeout과 재시도 총합이 짧은지 계산합니다.
- 재시도 횟수, 백오프, jitter, 재시도 가능한 오류 조건이 정의되어 있는지 확인합니다.
- 서킷 브레이커의 실패율, 느린 호출 기준, open 유지 시간이 서비스 특성에 맞는지 확인합니다.
- fallback 응답이 비즈니스 의미를 깨뜨리지 않는지 확인합니다.
- 외부 서비스별 메트릭과 알림이 분리되어 있는지 확인합니다.
배포 후에는 정상 트래픽에서 기준선을 잡아야 합니다. 평소 p95, p99 응답 시간, timeout 비율, 커넥션 풀 pending 값을 알아야 장애 때 이상 징후를 빨리 볼 수 있습니다. 기준선 없이 알림 임계값만 정하면 너무 늦게 울리거나 너무 자주 울릴 수 있습니다.
장애 훈련도 도움이 됩니다. 스테이징 환경에서 특정 외부 API 응답을 2초 지연시키거나 50% 실패시키는 식으로 테스트하면 timeout과 서킷 브레이커가 의도대로 동작하는지 확인할 수 있습니다. 이때 사용자 API 전체가 몇 초 안에 실패하는지, 정상 외부 API 호출도 함께 느려지는지, 알림이 어느 시점에 울리는지를 같이 봐야 합니다.
흔한 오해와 주의할 점
timeout 값을 길게 잡으면 안정적이라고 생각하기 쉽습니다. timeout이 길면 일시적인 느림을 더 기다릴 수는 있지만, 장애 상황에서는 내부 자원을 더 오래 붙잡습니다. 사용자 요청의 전체 허용 시간과 서버 자원 점유 시간을 함께 봐야 합니다.
커넥션 풀 크기를 키우는 것만으로는 문제가 해결되지 않는 경우도 많습니다. 풀을 키우면 순간 처리량은 늘 수 있지만, 외부 서비스가 느린 상태에서는 더 많은 요청이 외부 장애에 매달리게 됩니다. 풀 크기 증가는 동시 실행량 제한, timeout, 서킷 브레이커와 함께 검토해야 합니다.
재시도는 짧은 네트워크 흔들림에는 도움이 될 수 있습니다. 그러나 외부 서비스가 이미 과부하인 상태에서 모든 서버가 동시에 재시도하면 호출량이 더 늘어 장애가 길어질 수 있습니다. 재시도에는 최대 횟수, 백오프, jitter, 재시도 가능한 오류 조건이 필요합니다.
fallback은 비즈니스 의미를 기준으로 나눠야 합니다. 결제 승인 실패를 성공처럼 처리할 수는 없습니다. 반대로 추천 상품이나 부가 배너처럼 핵심 흐름이 아닌 기능은 빈 결과나 캐시 응답으로도 사용자 흐름을 유지할 수 있습니다. 외부 API마다 실패 시 허용 가능한 응답을 분리해 두면 장애 상황에서 판단할 일이 줄어듭니다.
원문 참고
https://www.maeil-mail.kr/question/74
댓글
댓글 쓰기