Contents
see List왜 fetch 타임아웃을 명시해야 하는가
Node.js 서비스에서 외부 API를 호출할 때 가장 흔한 장애는 상대 서버가 완전히 내려가는 상황보다 응답이 애매하게 늦어지는 상황입니다. 결제, 알림, 검색, 배송 조회, 사내 ERP 연동처럼 HTTP 호출이 많은 업무 시스템에서는 한 요청이 오래 붙잡히면 웹 서버의 동시 처리량이 줄고, 커넥션 풀이 밀리고, 사용자는 같은 버튼을 여러 번 누르게 됩니다. 운영 관점에서는 오류보다 지연이 더 위험할 때가 많습니다. 오류는 빠르게 감지하고 우회할 수 있지만, 지연은 장애 범위를 천천히 넓힙니다.
Node.js의 전역 fetch는 브라우저와 같은 형태로 사용할 수 있어 편리하지만, 업무 서비스에서는 기본 호출을 그대로 흩뿌리면 안 됩니다. 호출 목적별 타임아웃, 재시도 가능 여부, 상태 코드 처리, 로그 필드, 응답 크기 제한을 한곳에 모아야 합니다. 특히 POST처럼 부작용이 있는 요청을 무조건 재시도하면 중복 결제, 중복 발송, 중복 등록 같은 문제가 생길 수 있습니다. 반대로 GET 조회성 요청은 짧은 재시도만으로 일시적인 네트워크 흔들림을 흡수할 수 있습니다.
기본 정책부터 정한다
타임아웃 값은 서버 전체에 하나로 두기보다 업무 중요도와 사용자 대기 시간을 기준으로 나누는 것이 좋습니다. 화면 렌더링에 필요한 조회 API는 보통 1초에서 3초 안에 실패시키고 대체 문구나 캐시를 보여주는 쪽이 낫습니다. 백오피스 배치에서 외부 정산 파일을 내려받는 작업은 더 길게 기다릴 수 있지만, 그래도 무한 대기는 금지해야 합니다. 재시도는 짧고 제한적으로 둡니다. 500, 502, 503, 504처럼 서버나 게이트웨이 쪽 일시 장애 가능성이 있는 응답과 네트워크 오류는 재시도 후보가 될 수 있습니다. 400, 401, 403, 404 같은 클라이언트 오류는 같은 요청을 반복해도 해결되지 않는 경우가 많으므로 즉시 실패시키는 편이 안전합니다.
- 조회성 GET은 짧은 타임아웃과 1~2회 재시도를 허용한다.
- POST, PATCH, DELETE는 멱등성 키나 중복 방지 장치가 있을 때만 재시도한다.
- 재시도 간격은 고정값보다 점진적으로 늘리고 약간의 흔들림을 둔다.
- 모든 실패 로그에는 호출 대상, 메서드, 소요 시간, 시도 횟수, 상태 코드를 남긴다.
- 사용자 요청의 전체 제한 시간보다 외부 API 타임아웃 합계가 짧아야 한다.
공통 fetch 래퍼 예제
아래 예제는 Node.js에서 전역 fetch와 AbortController를 이용해 타임아웃, 재시도, 상태 코드 검사를 한곳으로 모은 코드입니다. 실무에서는 이 함수를 서비스마다 복사하기보다 httpClient 같은 모듈로 두고, 결제사, 문자 발송사, 사내 API처럼 대상별 기본값만 다르게 주입하는 방식이 관리하기 쉽습니다.
const RETRYABLE_STATUS = new Set([500, 502, 503, 504]);
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function backoffMs(attempt) {
const base = 150 * 2 ** attempt;
const jitter = Math.floor(Math.random() * 100);
return Math.min(base + jitter, 1000);
}
async function fetchJson(url, options = {}) {
const {
method = 'GET',
timeoutMs = 2500,
retries = method === 'GET' ? 2 : 0,
headers = {},
body,
traceId,
} = options;
let lastError;
for (let attempt = 0; attempt <= retries; attempt += 1) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
const startedAt = Date.now();
try {
const response = await fetch(url, {
method,
headers: {
accept: 'application/json',
'content-type': 'application/json',
...(traceId ? { 'x-trace-id': traceId } : {}),
...headers,
},
body: body === undefined ? undefined : JSON.stringify(body),
signal: controller.signal,
});
const elapsedMs = Date.now() - startedAt;
if (!response.ok) {
const retryable = RETRYABLE_STATUS.has(response.status);
const error = new Error('HTTP ' + response.status + ' from ' + url);
error.status = response.status;
error.elapsedMs = elapsedMs;
error.attempt = attempt + 1;
if (!retryable || attempt === retries) {
throw error;
}
await sleep(backoffMs(attempt));
continue;
}
return await response.json();
} catch (error) {
lastError = error;
if (error.name !== 'AbortError' && attempt < retries) {
await sleep(backoffMs(attempt));
continue;
}
throw error;
} finally {
clearTimeout(timeout);
}
}
throw lastError;
}
async function loadOrderStatus(orderId, traceId) {
return fetchJson('https://api.example.com/orders/' + orderId, {
timeoutMs: 1800,
retries: 1,
traceId,
});
}
운영 로그와 사용자 응답을 분리한다
외부 API 호출 실패를 그대로 사용자에게 노출하면 장애 대응이 어려워집니다. 사용자는 결제사 응답 지연, 배송사 503, 내부 게이트웨이 오류의 차이를 알 필요가 없습니다. 화면에는 업무적으로 이해 가능한 문장을 보여주고, 서버 로그에는 재현에 필요한 정보를 충분히 남겨야 합니다. 예를 들어 주문 상태 조회가 실패했다면 화면에는 “현재 배송 정보를 불러오지 못했습니다. 잠시 후 다시 확인해 주세요.”처럼 안내하고, 로그에는 provider, url, method, timeoutMs, attempt, elapsedMs, status, traceId를 남깁니다.
여기서 중요한 점은 로그에 개인정보나 토큰을 남기지 않는 것입니다. URL 쿼리에 전화번호, 이메일, 인증 코드가 섞여 있다면 마스킹해야 합니다. 요청 본문 전체를 남기는 습관도 피해야 합니다. 운영 로그는 장애 원인 파악용이지 데이터 백업 공간이 아닙니다. 필요한 필드만 구조화해서 남기면 검색과 알림 규칙을 만들기도 쉬워집니다.
재시도보다 먼저 멱등성을 확인한다
재시도는 편리하지만 모든 문제의 답은 아닙니다. 사용자가 결제 버튼을 눌렀고 서버가 결제 API를 호출한 뒤 응답을 받기 전에 타임아웃이 발생했다고 가정해 봅니다. 이때 같은 결제 요청을 다시 보내면 실제로는 첫 번째 결제가 성공했는데 두 번째 결제까지 발생할 수 있습니다. 이런 종류의 API는 외부 서비스가 제공하는 idempotency key, 주문번호 기반 중복 방지, 상태 조회 후 보정 절차가 있어야 재시도할 수 있습니다. 중복 실행이 치명적인 요청은 실패를 빠르게 기록하고 별도 보정 작업으로 넘기는 편이 더 안전합니다.
반대로 재고 조회, 우편번호 검색, 환율 조회처럼 읽기 전용 요청은 짧은 재시도가 효과적입니다. 다만 재시도 횟수가 많아지면 장애 중인 외부 API에 더 큰 부하를 줄 수 있습니다. 특정 공급자가 느려지는 상황이 반복된다면 서킷 브레이커, 캐시, 폴백 데이터, 비동기 처리 큐를 함께 고려해야 합니다. fetch 래퍼는 시작점일 뿐이고, 장애가 잦은 연동은 업무 흐름 자체를 지연에 강하게 설계해야 합니다.
테스트할 때 확인할 항목
타임아웃 코드는 정상 응답만 보고 배포하면 실제 장애 때 동작하지 않을 수 있습니다. 로컬 테스트 서버를 만들어 3초 뒤 응답하는 API, 503을 반환하는 API, JSON이 아닌 HTML 오류 페이지를 반환하는 API를 각각 호출해 보아야 합니다. 또한 프론트엔드나 상위 API의 제한 시간이 5초인데 내부에서 외부 API를 3개 순차 호출하면서 각각 3초씩 기다리는 구조라면 전체 요청은 결국 시간 안에 끝나기 어렵습니다. 병렬화할 수 있는 호출은 병렬로 보내고, 꼭 순서가 필요한 호출만 직렬로 처리해야 합니다.
- 외부 API별 기본 timeoutMs와 retries가 문서화되어 있는지 확인한다.
- POST 재시도에는 멱등성 키나 중복 방지 로직이 있는지 확인한다.
- AbortError, 5xx, 4xx, JSON 파싱 실패를 서로 다른 로그 코드로 구분한다.
- traceId를 전달해 내부 로그와 외부 문의 기록을 연결할 수 있게 한다.
- 장애 시 화면 응답, 운영 로그, 알림 기준이 각각 준비되어 있는지 점검한다.
정리하면 Node.js에서 fetch를 직접 호출하는 코드는 작아 보여도 운영 정책이 빠지면 장애 전파 지점이 됩니다. 타임아웃을 명시하고, 재시도 범위를 좁히고, 멱등성을 먼저 확인하고, 구조화 로그로 남기는 네 가지 원칙만 지켜도 외부 API 지연이 전체 서비스 장애로 번지는 위험을 크게 줄일 수 있습니다.