왜 fetch 타임아웃을 표준화해야 하는가

Node.js에서 외부 API를 호출하는 코드는 처음에는 간단합니다. fetch로 요청을 보내고, JSON을 읽고, 실패하면 예외를 던지면 됩니다. 하지만 운영 환경에서는 이야기가 달라집니다. 결제사, 지도 API, 사내 인증 서버, 검색 서버처럼 응답 시간이 일정하지 않은 의존성이 하나만 있어도 전체 요청 스레드와 커넥션 풀이 묶입니다. 특히 서버 사이드 렌더링, BFF, 배치 수집기, 웹훅 처리기에서는 외부 호출이 늦어질 때 사용자 응답 지연, 중복 재시도, 큐 적체가 동시에 발생합니다.

문제의 핵심은 fetch 자체가 실패하지 않았다는 점입니다. 네트워크가 완전히 끊긴 것이 아니라 상대 서버가 너무 늦게 응답하는 상태라면, 명시적인 타임아웃과 취소 신호가 없을 때 애플리케이션은 계속 기다립니다. 그래서 Node.js 환경에서는 fetch 호출을 직접 흩뿌리지 말고, 타임아웃, 재시도, 상태 코드 검사, 취소 전파를 하나의 작은 클라이언트 함수로 묶어야 합니다. 이 문서는 AbortSignal.timeout과 AbortSignal.any를 이용해 실무에서 바로 쓸 수 있는 패턴을 정리합니다.

기본 원칙: 호출마다 시간 예산을 가진다

외부 API 호출에는 반드시 시간 예산이 있어야 합니다. 화면 응답 안에서 호출되는 API라면 보통 1초에서 3초 사이를 먼저 검토하고, 관리자 배치나 리포트 생성처럼 사용자가 기다리는 시간이 긴 작업이라도 무제한 대기는 피해야 합니다. 중요한 점은 모든 API에 같은 타임아웃을 넣지 않는 것입니다. 로그인, 결제 승인, 상품 검색, 통계 적재는 실패 비용과 재시도 가능성이 다릅니다. 따라서 함수 인자로 timeoutMs를 받고, 기본값은 보수적으로 두되 호출 지점에서 명시적으로 덮어쓸 수 있게 만드는 편이 좋습니다.

  • 사용자 요청 경로의 외부 API는 짧은 타임아웃과 명확한 대체 응답을 둡니다.
  • 멱등성이 없는 POST 요청은 무조건 재시도하지 말고, idempotency key나 중복 처리 정책을 먼저 확인합니다.
  • 일시 장애가 많은 조회성 GET 요청은 짧은 지수 백오프를 두고 1회에서 2회만 재시도합니다.
  • 상위 요청이 취소되면 하위 fetch도 함께 취소되도록 signal을 전파합니다.

공통 fetch 클라이언트 예제

아래 코드는 Node.js의 전역 fetch와 AbortSignal을 사용해 타임아웃, 상위 취소 신호, 제한된 재시도를 함께 처리합니다. 핵심은 AbortSignal.any로 상위 signal과 타임아웃 signal을 합치는 부분입니다. 사용자가 브라우저 연결을 끊거나 서버 내부 작업이 취소되면 상위 signal이 먼저 abort되고, 아무 일도 없더라도 timeoutMs가 지나면 타임아웃 signal이 요청을 중단합니다.

const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

export async function fetchJson(url, {
  method = 'GET',
  headers = {},
  body,
  timeoutMs = 2500,
  retries = 1,
  signal,
} = {}) {
  let lastError;

  for (let attempt = 0; attempt <= retries; attempt += 1) {
    const timeoutSignal = AbortSignal.timeout(timeoutMs);
    const requestSignal = signal
      ? AbortSignal.any([signal, timeoutSignal])
      : timeoutSignal;

    try {
      const res = await fetch(url, {
        method,
        headers: {
          'accept': 'application/json',
          'content-type': 'application/json',
          ...headers,
        },
        body: body === undefined ? undefined : JSON.stringify(body),
        signal: requestSignal,
      });

      if (res.status === 429 || res.status >= 500) {
        throw new Error(`retryable status ${res.status}`);
      }
      if (!res.ok) {
        const text = await res.text();
        throw new Error(`request failed ${res.status}: ${text.slice(0, 300)}`);
      }
      return await res.json();
    } catch (err) {
      lastError = err;
      if (signal?.aborted) throw err;
      if (attempt === retries) break;
      await sleep(150 * 2 ** attempt);
    }
  }

  throw lastError;
}

이 예제는 작지만 운영에서 필요한 기준을 담고 있습니다. 400번대 일반 오류는 재시도해도 성공할 가능성이 낮으므로 즉시 실패시키고, 429와 500번대는 일시 장애로 보고 재시도 대상으로 둡니다. 다만 실제 서비스에서는 API별 정책을 분리해야 합니다. 예를 들어 결제 승인 POST는 서버가 이미 처리했는데 응답만 늦었을 수 있으므로 같은 요청을 다시 보내기 전에 반드시 거래 고유 키를 사용해야 합니다. 반대로 상품 목록 조회나 환율 조회처럼 멱등적인 GET 요청은 짧은 재시도가 사용자 경험을 크게 개선합니다.

상위 요청 취소를 하위 호출에 전파하기

서버 애플리케이션에서 자주 놓치는 부분은 취소 전파입니다. 사용자가 요청을 중단했는데 서버가 여전히 외부 API를 호출하고 데이터베이스 작업을 이어가면, 트래픽이 몰릴 때 필요 없는 부하가 커집니다. Node.js의 HTTP 서버나 프레임워크에서는 요청 종료 이벤트를 받아 AbortController를 abort하고, 그 signal을 내부 서비스 함수로 넘기는 식으로 정리할 수 있습니다.

import http from 'node:http';
import { fetchJson } from './fetch-json.js';

http.createServer(async (req, res) => {
  const controller = new AbortController();
  req.on('close', () => controller.abort(new Error('client closed')));

  try {
    const data = await fetchJson('https://api.example.com/products', {
      timeoutMs: 1800,
      retries: 1,
      signal: controller.signal,
    });
    res.setHeader('content-type', 'application/json');
    res.end(JSON.stringify(data));
  } catch (err) {
    if (controller.signal.aborted) return;
    res.statusCode = 502;
    res.end(JSON.stringify({ message: 'upstream request failed' }));
  }
}).listen(3000);

프레임워크를 사용하더라도 원리는 같습니다. 컨트롤러에서 외부 API 클라이언트까지 signal을 전달하고, 중간 계층에서 임의로 삼키지 않습니다. 이렇게 해두면 배포 후 장애 상황에서 효과가 큽니다. 느린 외부 API 하나 때문에 Node.js 프로세스의 이벤트 루프가 막히지는 않더라도, 대기 중인 Promise와 소켓, 로그, 큐 작업은 계속 쌓일 수 있습니다. 취소 가능한 작업은 빨리 취소하는 것이 가장 저렴한 안정화 방법입니다.

재시도 정책을 넣을 때 주의할 점

재시도는 장애를 숨기는 도구가 아니라 일시적 흔들림을 흡수하는 장치입니다. 재시도 횟수를 크게 잡으면 평균 성공률은 올라가 보일 수 있지만, 장애가 길어질 때는 오히려 상대 서버와 우리 서버 양쪽에 더 큰 압력을 줍니다. 운영 서비스에서는 최대 1회 또는 2회 재시도로 시작하고, 호출별 성공률과 지연 시간을 관찰한 뒤 조정하는 편이 안전합니다.

  • timeoutMs는 한 번의 시도에 적용되는 시간인지, 전체 작업에 적용되는 시간인지 명확히 정합니다.
  • 재시도 사이에는 짧은 백오프를 둬서 동시에 몰리는 요청을 줄입니다.
  • 로그에는 URL 전체보다 서비스명, 엔드포인트 이름, 상태 코드, 시도 횟수, 소요 시간을 남깁니다.
  • 개인정보, 인증 토큰, 원문 응답 전체를 장애 로그에 남기지 않습니다.
  • 429 응답에 Retry-After 헤더가 있다면 자체 백오프보다 우선 적용할지 정책을 정합니다.

운영 적용 체크리스트

첫째, 프로젝트 안에서 fetch를 직접 호출하는 위치를 검색하고 외부 API 호출을 서비스별 클라이언트로 모읍니다. 둘째, 클라이언트 함수의 기본 타임아웃과 재시도 횟수를 정하되 호출 지점에서 덮어쓸 수 있게 합니다. 셋째, GET과 POST의 재시도 정책을 분리하고 멱등성이 없는 요청에는 거래 고유 키를 설계합니다. 넷째, 상위 요청 취소 signal을 하위 fetch에 전달해 끊어진 요청이 계속 실행되지 않게 합니다. 다섯째, 실패 로그에는 상태 코드, 소요 시간, 시도 횟수, 취소 여부를 남기고 비밀값은 제외합니다. 이 다섯 가지만 적용해도 Node.js 서비스의 외부 API 장애 대응력은 눈에 띄게 좋아집니다.