Timeout 없는 외부 API 호출이 위험한 이유
Timeout 없는 외부 API 호출이 위험한 이유
Timeout은 단순히 3초 뒤 예외를 만드는 옵션이 아니다. 연결, 응답 헤더, 본문 읽기, 전체 업무에 서로 다른 시간 예산이 필요하다. 상위 요청의 deadline을 하위 호출에 전달하고 실제 I/O를 취소해야 한다. 클라이언트가 기다리기를 중단해도 서버 작업은 이미 완료됐을 수 있으므로 쓰기 요청에는 멱등성이 필요하다.
목차
- #응답하지 않는 호출이 자원을 계속 점유한다
- #Timeout과 Deadline을 구분한다
- #HTTP 호출에는 여러 대기 구간이 있다
- #Node.js timeout 이벤트는 요청을 자동 중단하지 않는다
- #fetch와 AbortSignal로 전체 호출을 제한한다
- #상위 요청의 남은 시간을 하위 호출에 전달한다
- #응답 본문을 읽는 시간도 포함한다
- #Timeout 이후 서버 결과는 알 수 없다
- #요청 종류마다 다른 예산을 둔다
- #재시도와 Circuit Breaker에 시간 예산을 연결한다
- #스트리밍과 긴 작업은 별도 프로토콜로 다룬다
- #테스트와 운영 관측
- #마무리
- #참고 자료
- #관련 노트
응답하지 않는 호출이 자원을 계속 점유한다
다음 코드는 외부 배송 API의 응답을 기다린다.
async function createShipment(
command: CreateShipmentCommand,
): Promise<Shipment> {
const response = await fetch(
"https://shipping.example.test/shipments",
{
method: "POST",
headers: {
"content-type": "application/json",
},
body: JSON.stringify(command),
},
);
return response.json() as Promise<Shipment>;
}
네트워크 연결은 되었지만 상대 서버가 응답하지 않거나 본문 전송을 끝내지 않으면 호출은 오래 대기할 수 있다. 대기 중인 Promise 자체보다 함께 붙잡힌 자원이 문제다.
- 외부 요청 소켓과 연결 풀 슬롯
- 들어온 사용자 요청과 응답 객체
- 요청 본문과 인증 문맥
- 데이터베이스 트랜잭션과 행 잠금
- 동시성 제한기의 permit
- 로그·트레이스 span과 메모리
초당 200개 요청이 들어오고 외부 호출이 30초씩 멈추면 단순 계산으로 6,000개 호출이 대기할 수 있다.
대기 호출 ≈ 요청률 × 대기 시간
≈ 200 req/s × 30s
≈ 6,000
flowchart LR
A[외부 API 무응답] --> B[요청 대기 증가]
B --> C[소켓·메모리·풀 점유]
C --> D[내 API 응답 지연]
D --> E[클라이언트 재시도]
E --> B외부 API 하나의 장애가 내 서비스의 모든 요청으로 번진다.
원격 호출은 반드시 실패할 수 있고, 실패가 언제 돌아올지도 보장하지 않는다. 호출자가 감당할 수 있는 시간 안에 제어권을 되찾아야 한다.
Timeout과 Deadline을 구분한다
두 용어를 같은 뜻처럼 쓰지만 설계 관점에서는 구분하면 편하다.
Timeout
특정 작업을 최대 얼마 동안 기다릴지 나타내는 상대 시간이다.
요청 timeout = 800ms
각 계층이 800ms를 새로 시작하면 전체 시간은 계속 늘어난다.
Deadline
전체 작업이 끝나야 할 절대 시각이다.
deadline = 2025-08-22T10:00:02.500Z
하위 계층은 현재 시각과 비교해 남은 예산을 계산한다.
type Deadline = Readonly<{
expiresAt: number;
}>;
function remainingMs(deadline: Deadline): number {
return Math.max(
0,
deadline.expiresAt - Date.now(),
);
}
한 번의 요청 흐름을 보자.
전체 deadline 2,500ms
├─ 인증·입력 검증 100ms 사용
├─ DB 조회 300ms 사용
├─ 외부 API에 남은 예산 1,900ms
└─ 응답 직렬화·전송을 위해 200ms 예약
하위 외부 호출에 고정 3초 timeout을 주면 상위 deadline을 넘긴다. 남은 시간에서 후처리 예산을 뺀 값을 사용한다.
HTTP 호출에는 여러 대기 구간이 있다
“HTTP timeout 3초”라는 하나의 숫자는 어떤 구간을 뜻하는지 모호하다.
sequenceDiagram
participant C as Client
participant DNS
participant S as Server
C->>DNS: 이름 조회
DNS-->>C: IP
C->>S: TCP 연결
C->>S: TLS handshake
C->>S: 요청 헤더·본문 전송
S-->>C: 응답 헤더
S-->>C: 응답 본문 스트리밍구간별 시간 제한을 구분할 수 있다.
| 제한 | 보호하는 구간 | 대표 실패 |
|---|---|---|
| DNS timeout | 이름 조회 | DNS 지연·장애 |
| Connect timeout | TCP 연결 | 라우팅·방화벽·포트 문제 |
| TLS handshake timeout | 보안 연결 협상 | 인증서·네트워크 지연 |
| Headers timeout | 응답 헤더까지 | 서버 처리 지연 |
| Body timeout | 본문 chunk 사이 또는 전체 | 느리거나 멈춘 스트림 |
| Total timeout | 호출 전체 | 위 단계 누적 |
| Business deadline | 상위 업무 전체 | 여러 하위 호출 누적 |
사용하는 HTTP 클라이언트마다 이름과 의미가 다르다. 어떤 라이브러리의 timeout은 socket inactivity만 보고, 다른 라이브러리는 전체 요청을 본다. 옵션 이름만 믿지 말고 공식 문서와 장애 테스트로 확인한다.
Inactivity timeout과 전체 timeout
서버가 900ms마다 한 바이트씩 보내면 1초 inactivity timeout은 계속 갱신될 수 있다. 전체 본문이 끝나는 데 몇 분이 걸려도 timeout이 발생하지 않는다.
0.9s 1 byte
1.8s 1 byte
2.7s 1 byte
...
따라서 느린 스트림을 허용하지 않는 일반 JSON API에는 전체 deadline도 필요하다.
Node.js timeout 이벤트는 요청을 자동 중단하지 않는다
저수준 node:http API의 request.setTimeout()은 timeout 이벤트를 발생시키지만 요청을 자동으로 중단하지 않는다. Node.js 공식 문서도 timeout 옵션이나 setTimeout()이 이벤트를 추가할 뿐 요청을 abort하지 않는다고 명시한다.
import { request } from "node:https";
const req = request(url, options, (res) => {
// 응답 처리
});
req.setTimeout(1000, () => {
logger.warn("request timed out");
});
req.end();
위 콜백에서 로그만 남기면 소켓이 계속 살아 있을 수 있다. 실제로 요청을 파괴한다.
req.setTimeout(1000, () => {
req.destroy(
new Error("shipping request timed out"),
);
});
더 일관되게 AbortSignal을 전달할 수 있다.
const controller = new AbortController();
const timer = setTimeout(() => {
controller.abort(
new Error("shipping request timed out"),
);
}, 1000);
const req = request(
url,
{
...options,
signal: controller.signal,
},
handleResponse,
);
req.once("close", () => {
clearTimeout(timer);
});
req.end();
오류 이벤트를 반드시 처리한다.
req.once("error", (error) => {
logger.warn(
{ error },
"shipping request failed",
);
});
Promise.race([fetch(), sleep(1000)])로 먼저 실패를 반환해도 원래 fetch를 취소하지 않으면 네트워크 요청은 백그라운드에서 계속된다. 취소 신호를 실제 I/O에 연결해야 한다.
fetch와 AbortSignal로 전체 호출을 제한한다
Node.js의 fetch는 AbortSignal을 받을 수 있다.
const response = await fetch(url, {
signal: AbortSignal.timeout(3000),
});
상위 요청 취소와 timeout을 함께 적용하려면 AbortSignal.any()로 조합한다.
function createCallSignal(
parentSignal: AbortSignal,
timeoutMs: number,
): AbortSignal {
return AbortSignal.any([
parentSignal,
AbortSignal.timeout(timeoutMs),
]);
}
async function loadShipment(
shipmentId: string,
options: {
signal: AbortSignal;
timeoutMs: number;
},
): Promise<Shipment> {
const response = await fetch(
`https://shipping.example.test/shipments/${shipmentId}`,
{
signal: createCallSignal(
options.signal,
options.timeoutMs,
),
},
);
if (!response.ok) {
throw new HttpResponseError(response.status);
}
return response.json() as Promise<Shipment>;
}
상위 사용자가 연결을 끊거나 서버가 graceful shutdown을 시작하면 같은 신호로 하위 호출도 멈출 수 있다.
취소 이유를 분류한다
상위 취소와 timeout은 운영 의미가 다르다.
class DependencyTimeoutError extends Error {}
class RequestCancelledError extends Error {}
AbortSignal.any() 결과의 reason을 사용해 어느 신호가 먼저 발생했는지 구분할 수 있지만 런타임 버전과 사용하는 클라이언트의 오류 래핑을 확인해야 한다.
직접 controller를 사용하면 이유를 명시할 수 있다.
function createDeadlineController(
parentSignal: AbortSignal,
timeoutMs: number,
): {
signal: AbortSignal;
dispose: () => void;
} {
const controller = new AbortController();
const onParentAbort = () => {
controller.abort(
new RequestCancelledError(
"parent request was cancelled",
),
);
};
parentSignal.addEventListener(
"abort",
onParentAbort,
{ once: true },
);
const timer = setTimeout(() => {
controller.abort(
new DependencyTimeoutError(
"dependency deadline exceeded",
),
);
}, timeoutMs);
return {
signal: controller.signal,
dispose: () => {
clearTimeout(timer);
parentSignal.removeEventListener(
"abort",
onParentAbort,
);
},
};
}
const call = createDeadlineController(
requestSignal,
timeoutMs,
);
try {
return await fetch(url, {
signal: call.signal,
});
} finally {
call.dispose();
}
이벤트 리스너와 타이머를 정리하지 않으면 장기 프로세스에서 누적될 수 있다.
상위 요청의 남은 시간을 하위 호출에 전달한다
Controller, Service, API Client가 각자 고정 timeout을 시작하지 않도록 deadline을 문맥에 둔다.
type RequestContext = Readonly<{
requestId: string;
deadline: Deadline;
signal: AbortSignal;
}>;
function dependencyTimeout(input: {
context: RequestContext;
configuredMaxMs: number;
responseReserveMs: number;
}): number {
const available =
remainingMs(input.context.deadline) -
input.responseReserveMs;
if (available <= 0) {
throw new DeadlineExceededError();
}
return Math.min(
input.configuredMaxMs,
available,
);
}
async function checkout(
command: CheckoutCommand,
context: RequestContext,
): Promise<CheckoutResult> {
const inventoryTimeoutMs =
dependencyTimeout({
context,
configuredMaxMs: 700,
responseReserveMs: 200,
});
const reservation =
await inventoryClient.reserve(command.items, {
timeoutMs: inventoryTimeoutMs,
signal: context.signal,
});
const paymentTimeoutMs =
dependencyTimeout({
context,
configuredMaxMs: 900,
responseReserveMs: 200,
});
return paymentClient.charge(
{
reservationId: reservation.id,
amount: command.amount,
},
{
timeoutMs: paymentTimeoutMs,
signal: context.signal,
},
);
}
두 번째 호출은 첫 번째 호출이 실제 사용한 시간만큼 줄어든 예산을 받는다.
병렬 호출의 예산
서로 독립적인 호출은 병렬로 실행해 전체 latency를 줄일 수 있다.
const [profile, benefits] = await Promise.all([
profileClient.load(userId, options),
benefitClient.load(userId, options),
]);
각 호출에 전체 남은 시간과 같은 timeout을 무조건 주는 것이 아니라 중요도에 따라 예산과 fallback을 정한다. 하나가 실패했을 때 다른 호출도 취소할지, 정상 결과를 사용할지 결정해야 한다.
const groupController = new AbortController();
const groupSignal = AbortSignal.any([
context.signal,
groupController.signal,
]);
try {
return await Promise.all([
requiredClient.load({ signal: groupSignal }),
requiredOtherClient.load({ signal: groupSignal }),
]);
} catch (error) {
groupController.abort(error);
throw error;
}
응답 본문을 읽는 시간도 포함한다
fetch() Promise는 응답 헤더를 받은 시점에 fulfilled될 수 있고, 본문은 이후 읽는다.
const response = await fetch(url, { signal });
const data = await response.json();
헤더는 빨리 왔지만 본문이 멈출 수 있다. 같은 AbortSignal이 본문 소비에도 적용되는지 런타임 구현을 확인하고 전체 작업을 timeout 범위 안에 둔다.
async function fetchJson<T>(
url: URL,
options: {
timeoutMs: number;
parentSignal: AbortSignal;
maxBytes: number;
},
): Promise<T> {
const signal = createCallSignal(
options.parentSignal,
options.timeoutMs,
);
const response = await fetch(url, { signal });
if (!response.ok) {
throw new HttpResponseError(response.status);
}
const contentLength =
response.headers.get("content-length");
if (
contentLength &&
Number(contentLength) > options.maxBytes
) {
throw new ResponseTooLargeError();
}
return response.json() as Promise<T>;
}
Content-Length가 없거나 거짓일 수 있으므로 스트림을 읽으며 실제 바이트 수를 제한하는 방어도 필요하다.
async function readBodyWithLimit(
response: Response,
maxBytes: number,
): Promise<Uint8Array> {
if (!response.body) {
return new Uint8Array();
}
const reader = response.body.getReader();
const chunks: Uint8Array[] = [];
let total = 0;
try {
while (true) {
const { done, value } = await reader.read();
if (done) {
break;
}
total += value.byteLength;
if (total > maxBytes) {
await reader.cancel("response too large");
throw new ResponseTooLargeError();
}
chunks.push(value);
}
} finally {
reader.releaseLock();
}
return concatBytes(chunks, total);
}
Timeout뿐 아니라 응답 크기도 메모리 자원 점유를 제한하는 경계다.
Timeout 이후 서버 결과는 알 수 없다
클라이언트의 timeout은 “응답을 제때 받지 못했다”는 뜻이지 “서버가 실패했다”는 뜻이 아니다.
sequenceDiagram
participant C as Client
participant S as Shipping API
participant DB as Shipping DB
C->>S: 배송 생성
S->>DB: INSERT + COMMIT
C--xS: 응답 전에 timeout
Note over C: 결과 불확실네트워크 연결을 끊어도 서버는 이미 요청 본문을 받았고 DB 커밋을 끝냈을 수 있다. 서버가 클라이언트 연결 종료를 감지해 작업을 취소하더라도 커밋 이후라면 되돌릴 수 없다.
쓰기 요청을 그대로 재시도하면 배송이 두 개 생성될 수 있다.
Idempotency Key
논리 작업마다 안정적인 키를 보내고 시도마다 재사용한다.
await fetch(
"https://shipping.example.test/shipments",
{
method: "POST",
headers: {
"content-type": "application/json",
"idempotency-key":
`order:${orderId}:create-shipment`,
},
body: JSON.stringify(payload),
signal,
},
);
작업 상태 조회
timeout 뒤 바로 새 요청을 만들지 않고 같은 키로 결과를 조회한다.
GET /operations/order%3Aexample%3Acreate-shipment
succeeded → 기존 shipment 반환
processing → 짧은 시간 뒤 조회
not_found → 정책에 따라 같은 키로 재요청
failed → 실패 유형에 따라 종료·재시도
DB 트랜잭션을 외부 호출 동안 열지 않는다
await database.transaction(async (tx) => {
const order = await tx.lockOrder(orderId);
await shippingApi.create(order); // 외부 timeout 동안 잠금 유지
await tx.markShipped(orderId);
});
외부 호출이 느리면 DB 잠금도 오래 유지된다. 상태를 shipping_requested로 원자적으로 바꾸고 Outbox 이벤트를 저장한 뒤 비동기 worker가 호출하는 구조를 고려한다.
요청 종류마다 다른 예산을 둔다
모든 외부 API에 timeout=3000을 복사하면 업무 중요도와 latency 분포를 반영하지 못한다.
| 호출 | 사용자 경로 | 평소 p99 | 실패 시 대안 | 예산 예시 |
|---|---|---|---|---|
| 재고 예약 | 동기 필수 | 300ms | 주문 불가 | 700ms |
| 추천 조회 | 동기 선택 | 500ms | 빈 목록 | 400ms |
| 결제 승인 | 동기 필수 | 800ms | 대기·실패 | 1,500ms |
| 분석 이벤트 | 비동기 | 200ms | 큐 보관 | 300ms |
| 대용량 보고서 | 비동기 | 수분 | 작업 상태 조회 | HTTP deadline과 분리 |
숫자는 가상 예시이며 실제 분포와 SLO로 결정한다.
너무 짧은 timeout
정상 요청도 취소되어 재시도 부하와 오류율이 증가한다. 배포 직후 새 연결, TLS handshake, DNS cold cache처럼 평상시와 다른 구간도 고려한다.
너무 긴 timeout
장애 감지가 늦고 자원을 오래 점유하며 상위 deadline을 초과한다.
percentile 기반 출발점
정상 latency p99.9에 네트워크 변동 여유를 더해 시작할 수 있다. 그러나 트래픽이 적어 percentile이 불안정하거나 긴 꼬리가 정상인 API에는 업무 deadline이 더 중요한 기준이다.
timeout = min(
dependency latency 기반 상한,
상위 요청의 남은 deadline,
자원 점유 허용 시간
)
재시도와 Circuit Breaker에 시간 예산을 연결한다
Timeout은 한 시도의 경계다. Retry는 여러 시도의 경계, Circuit Breaker는 시도할 가치가 있는지의 경계다.
flowchart LR
A[전체 Deadline] --> B[Circuit Breaker]
B -->|Closed| C[Retry Policy]
B -->|Open| F[빠른 실패·Fallback]
C --> D[시도별 Timeout]
D --> E[외부 API]재시도 횟수만 정하지 않고 전체 예산에 맞춘다.
전체 외부 호출 예산 1,800ms
├─ attempt 1 timeout 700ms
├─ full-jitter backoff 최대 200ms
├─ attempt 2 timeout 700ms
└─ 응답 처리 200ms
첫 시도가 50ms 만에 503을 반환하면 남은 시간으로 재시도할 수 있다. 700ms를 모두 사용했다면 다음 시도 예산이 줄어든다.
Circuit Breaker가 Open이면 timeout 시간을 소비하지 않고 즉시 fallback 또는 오류를 반환한다. CircuitOpenError를 재시도 정책이 다시 반복하지 않도록 분류한다.
자세한 내용은 재시도에 지수 백오프와 지터가 필요한 이유, Circuit Breaker로 연쇄 장애 줄이기에서 연결된다.
스트리밍과 긴 작업은 별도 프로토콜로 다룬다
모든 요청을 짧은 JSON API timeout에 맞출 수는 없다.
대용량 다운로드
전체 시간이 길어도 데이터가 꾸준히 오면 정상일 수 있다.
connect timeout: 짧게
headers timeout: 짧게
chunk inactivity timeout: 중간
total timeout: 파일 크기·업무에 맞게 길게
진행률과 최대 크기를 함께 제한한다.
긴 보고서 생성
HTTP 연결을 10분 유지하기보다 비동기 작업으로 바꾼다.
POST /reports
→ 202 Accepted
Location: /reports/report-example-42
GET /reports/report-example-42
→ {"status":"processing","progress":0.63}
작업에는 HTTP 요청과 별도의 최대 실행 시간, heartbeat, 체크포인트, 취소 상태가 필요하다.
WebSocket·SSE
전체 연결 timeout 대신 handshake timeout, heartbeat 간격, idle timeout을 사용한다. 정상적으로 오래 유지되는 연결을 total timeout으로 끊지 않는다.
짧은 요청·응답, 스트리밍, 비동기 작업, 지속 연결은 서로 다른 생명주기를 가진다. 하나의 전역 숫자로 통일하지 않는다.
테스트와 운영 관측
응답하지 않는 서버
테스트 서버가 연결만 받고 응답 헤더를 보내지 않게 한다.
it("aborts when the dependency does not respond", async () => {
const server = await createHangingServer();
const startedAt = performance.now();
await expect(
fetchJson(server.url, {
timeoutMs: 100,
parentSignal:
new AbortController().signal,
maxBytes: 1024,
}),
).rejects.toThrow();
expect(
performance.now() - startedAt,
).toBeLessThan(500);
});
CI 부하 때문에 정확히 100ms에 끝난다고 단정하지 않고 넉넉한 상한으로 자원 회수 여부를 본다.
느린 본문
헤더는 즉시 보내고 본문 chunk를 멈춘 서버로 body timeout 또는 total deadline이 적용되는지 확인한다.
호출자 취소
it("propagates parent cancellation", async () => {
const controller = new AbortController();
const call = loadShipment("shipment-example", {
signal: controller.signal,
timeoutMs: 5000,
});
controller.abort(
new RequestCancelledError(),
);
await expect(call).rejects.toThrow();
});
Timeout 뒤 중복 방지
첫 요청은 서버에서 커밋하되 응답을 지연시킨다. 같은 Idempotency Key로 재시도했을 때 하나의 결과만 있는지 통합 테스트한다.
Given 같은 idempotency key
When 첫 응답 timeout 후 다시 요청
Then 외부 논리 작업은 1개
And 두 요청이 같은 결과 식별자를 받음
메트릭
- 의존성·연산별 connect/headers/body/total timeout 횟수
- 취소 이유별 호출 수
- 외부 호출 latency p50·p95·p99
- timeout 시점의 in-flight 호출 수
- deadline 소진 비율
- 호출자 취소 후 남은 작업 수
- timeout 뒤 재시도와 멱등성 적중 횟수
- HTTP 연결 풀 대기 시간과 사용량
로그
{
"dependency": "shipping-api",
"operation": "create-shipment",
"phase": "response_headers",
"timeoutMs": 800,
"elapsedMs": 803,
"remainingDeadlineMs": 417,
"requestId": "req_example",
"message": "dependency call timed out"
}
URL 쿼리 문자열, Authorization 헤더, 요청 본문, Idempotency Key 원문을 무분별하게 기록하지 않는다.
운영 체크리스트
마무리
원격 호출에는 “언젠가 응답한다”는 보장이 없다. Timeout이 없으면 하나의 느린 의존성이 요청, 소켓, 연결 풀, 트랜잭션을 계속 붙잡아 내 서비스까지 장애로 만들 수 있다.
연결과 응답 헤더, 본문, 전체 업무 deadline을 구분하고 상위 요청의 남은 시간을 하위 호출에 전달해야 한다. 제한 시간이 끝났을 때는 Promise만 포기하지 말고 실제 I/O에 취소 신호를 전달한다.
클라이언트 timeout은 서버 작업 실패를 증명하지 않는다. 쓰기 작업에는 Idempotency Key와 상태 조회를 두고, 재시도는 남은 deadline 안에서만 수행한다. Timeout은 Retry와 Circuit Breaker보다 먼저 세워야 하는 가장 작은 실패 경계다.