- Published on
Node.js fetch ECONNRESET·ETIMEDOUT 해결법
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 사이드에서 fetch 를 쓰다 보면 간헐적으로 ECONNRESET(연결이 강제로 끊김), ETIMEDOUT(연결/응답이 제한 시간 내 도착하지 않음) 같은 네트워크 계열 에러가 터집니다. 문제는 “네트워크가 불안정해서요” 수준으로 뭉뚱그리면 재발을 막기 어렵다는 점입니다. 이 글은 Node.js 기본 fetch(Undici 기반) 를 기준으로, 어디서 끊기고 왜 느려졌는지 원인을 계층별로 분해한 뒤 타임아웃, 재시도, 커넥션 풀, DNS, 프록시/NAT, 서버 리밋까지 실전 처방을 한 번에 정리합니다.
에러 코드의 의미를 정확히 구분하기
ECONNRESET
- TCP 연결이 성립된 뒤, 상대(서버) 또는 중간 장비(LB, 프록시, NAT)가 RST(Reset) 로 연결을 끊어 발생합니다.
- 흔한 원인
- 서버가 keep-alive 연결을 유휴 상태로 판단해 닫았는데, 클라이언트가 그 소켓을 재사용하려다 실패
- 프록시/LB/NAT의 idle timeout이 짧아 연결이 중간에서 정리됨
- 서버가 과부하로 커넥션을 강제 종료
- TLS 핸드셰이크/레코드 문제(인증서, SNI, 중간 장비)
ETIMEDOUT
- 보통 다음 중 하나에서 “시간 초과”가 납니다.
- DNS 조회가 지연
- TCP connect가 지연(방화벽, 라우팅, SYN 재전송)
- TLS 핸드셰이크가 지연
- 서버가 응답을 늦게 줌(애플리케이션 처리 지연)
핵심은 어느 단계에서 시간이 초과되었는지를 로그로 남겨야 한다는 점입니다. 그냥 “fetch 타임아웃”만 걸면 원인 구분이 흐려져 튜닝이 어려워집니다.
Node.js fetch(Undici)의 기본 동작 이해
Node.js fetch 는 내부적으로 Undici를 사용합니다. Undici는 기본적으로 커넥션 풀을 관리하고 keep-alive를 적극적으로 사용합니다. 이 구조는 성능에는 좋지만, 다음 환경에서 ECONNRESET 빈도가 올라갈 수 있습니다.
- 중간 장비의 idle timeout이 짧음: 클라이언트는 소켓이 살아있다고 생각하고 재사용하지만, 중간에서 이미 정리됨
- 서버가 keep-alive 정책이 공격적: 서버가 먼저 연결을 닫는 패턴
- 대량 동시 요청: 풀/소켓/ephemeral port/NAT 테이블 한계에 가까워짐
따라서 해결은 크게 2갈래입니다.
- 요청 단위: 타임아웃, 재시도, 백오프, 멱등성
- 커넥션 단위: 풀 크기, keep-alive, idle timeout, DNS/프록시/NAT 정책 정합
1단계: 반드시 넣어야 하는 관측(로그) 포인트
에러가 나면 “어떤 URL, 어떤 메서드, 어느 리전/Pod, 얼마나 걸렸는지, 재시도 몇 번 했는지”가 남아야 합니다.
아래는 최소한의 계측 래퍼 예시입니다.
import { randomUUID } from "node:crypto";
export async function fetchWithMetrics(input: string, init: RequestInit = {}) {
const requestId = randomUUID();
const start = Date.now();
try {
const res = await fetch(input, init);
const ms = Date.now() - start;
console.log(JSON.stringify({
msg: "fetch_ok",
requestId,
url: input,
method: init.method ?? "GET",
status: res.status,
ms,
}));
return res;
} catch (err: any) {
const ms = Date.now() - start;
console.error(JSON.stringify({
msg: "fetch_err",
requestId,
url: input,
method: init.method ?? "GET",
ms,
name: err?.name,
code: err?.code,
message: err?.message,
cause: err?.cause?.code ?? err?.cause,
}));
throw err;
}
}
이 정도만 있어도 ETIMEDOUT이 “항상 10초에 딱 떨어진다” 같은 패턴(특정 타임아웃 설정/중간장비)을 잡기 쉬워집니다.
2단계: AbortController로 “응답 타임아웃”을 명시하기
Node.js fetch 는 브라우저처럼 기본 타임아웃이 명확하지 않습니다. 서비스 요구사항에 맞게 상한을 강제해야 합니다.
export async function fetchWithTimeout(url: string, init: RequestInit = {}, timeoutMs = 8000) {
const controller = new AbortController();
const id = setTimeout(() => controller.abort(), timeoutMs);
try {
return await fetch(url, {
...init,
signal: controller.signal,
});
} finally {
clearTimeout(id);
}
}
주의할 점
- 이 타임아웃은 “전체 요청” 관점이라 DNS/connect/TLS/서버처리 모두를 한꺼번에 묶습니다.
- 원인 분석이 필요하면 connect 타임아웃, headers 타임아웃처럼 단계별로 쪼개는 것이 더 좋습니다(아래 Undici 설정 참고).
3단계: 재시도는 “멱등성”과 “에러 타입”으로 제한하기
ECONNRESET/ETIMEDOUT는 재시도로 회복되는 경우가 많지만, 무턱대고 재시도하면 더 큰 장애(트래픽 폭증, 중복 요청)를 만듭니다.
- 안전한 기본 전략
GET,HEAD같은 멱등 메서드만 자동 재시도POST는 Idempotency-Key 같은 중복 방지 장치가 있을 때만 재시도- 지수 백오프 + 지터 적용
function sleep(ms: number) {
return new Promise((r) => setTimeout(r, ms));
}
function isRetryableError(err: any) {
const code = err?.code ?? err?.cause?.code;
return code === "ECONNRESET" || code === "ETIMEDOUT" || code === "EAI_AGAIN";
}
export async function fetchWithRetry(url: string, init: RequestInit = {}, opts?: {
timeoutMs?: number;
retries?: number;
baseDelayMs?: number;
}) {
const timeoutMs = opts?.timeoutMs ?? 8000;
const retries = opts?.retries ?? 2;
const baseDelayMs = opts?.baseDelayMs ?? 200;
const method = (init.method ?? "GET").toUpperCase();
const canRetry = method === "GET" || method === "HEAD";
let attempt = 0;
while (true) {
attempt += 1;
try {
const res = await fetchWithTimeout(url, init, timeoutMs);
return res;
} catch (err: any) {
if (!canRetry || !isRetryableError(err) || attempt > retries + 1) {
throw err;
}
const jitter = Math.floor(Math.random() * 100);
const backoff = baseDelayMs * Math.pow(2, attempt - 1) + jitter;
await sleep(backoff);
}
}
}
재시도 설계는 레이트리밋과도 강하게 연결됩니다. 외부 API에 붙는 서비스라면 토큰버킷 기반으로 호출량을 제어하는 방식도 함께 고려하세요.
4단계: Undici Agent로 커넥션 풀과 타임아웃을 튜닝하기
Node.js fetch 는 Undici의 디스패처(dispatcher)를 주입할 수 있습니다. 여기서 커넥션 풀, keep-alive, 단계별 타임아웃을 조정하면 ECONNRESET/ETIMEDOUT 재발을 크게 줄일 수 있습니다.
아래는 “외부 API 호출용”으로 자주 쓰는 보수적 설정 예시입니다.
import { Agent, setGlobalDispatcher } from "undici";
const agent = new Agent({
// 동시에 너무 많은 소켓을 열지 않도록 제한
connections: 50,
// keep-alive 유휴 소켓을 너무 오래 들고 있지 않기
keepAliveTimeout: 10_000,
keepAliveMaxTimeout: 30_000,
// 단계별 타임아웃 (밀리초)
connectTimeout: 3_000,
headersTimeout: 8_000,
bodyTimeout: 20_000,
});
setGlobalDispatcher(agent);
설정 가이드
connectTimeout이 잦게 터지면- 네트워크 경로/방화벽/NAT 문제 가능성
- DNS 지연(
EAI_AGAIN)이 섞이면 리졸버/캐시 이슈
headersTimeout이 잦게 터지면- 서버가 요청을 받았지만 응답 헤더를 늦게 보내는 상태(서버 과부하, upstream 병목)
keepAliveTimeout을 너무 길게 두면- 중간 장비 idle timeout과 불일치로 “죽은 소켓 재사용”이 늘어
ECONNRESET이 증가할 수 있음
- 중간 장비 idle timeout과 불일치로 “죽은 소켓 재사용”이 늘어
팁
- 특정 호스트만 다르게 튜닝하고 싶다면 전역 디스패처 대신 요청별로
dispatcher: agent를 넘기는 방식도 가능합니다.
5단계: HTTP/2, TLS, 프록시 환경에서의 함정
프록시 또는 사내망 egress를 통과할 때
- 프록시가 연결을 중간에서 끊거나(특히 idle) 헤더 크기/바디 크기 제한을 걸 수 있습니다.
- 해결책
- 프록시 idle timeout과 클라이언트 keep-alive 정책을 맞추기
- 프록시를 경유하는 트래픽은
connections상한을 더 낮추기(프록시 단에서 병목이 되기 쉬움)
NAT 게이트웨이/클라우드 egress
- 대량 동시 요청 시 ephemeral port 고갈, NAT 테이블 포화로 connect 지연이 생기며
ETIMEDOUT이 늘 수 있습니다. - 증상
- 특정 시간대(배치/크론)나 특정 Pod 스케일아웃 시점에 집중
- 해결책
- 동시성 제한(토큰버킷/세마포어)
- 커넥션 재사용 최적화(keep-alive)
- Pod 단위 egress 분산 또는 NAT 용량 증설
6단계: 서버가 느린 경우(진짜 원인이 upstream 처리 지연)
ETIMEDOUT은 클라이언트 문제처럼 보이지만, 실제로는 upstream이 느려서 헤더/바디가 늦게 오기도 합니다.
- 확인할 것
- 서버 측 p95/p99 응답시간
- 서버의 스레드/이벤트루프 블로킹
- DB 쿼리 지연, 락/데드락, 커넥션 풀 고갈
특히 서버가 K8s 위라면, OOM 직전 GC 스톨이나 CPU throttling으로 응답이 밀리면서 클라이언트에서는 타임아웃으로 관측됩니다.
타임아웃 설계 자체도 분산 시스템의 핵심입니다. “클라이언트만 타임아웃을 짧게”가 아니라, 전체 호출 체인에서 데드라인을 전파해야 장애 전파를 막을 수 있습니다.
- 내부 링크: gRPC 타임아웃 지옥 탈출 - 데드라인 전파 설계
7단계: 실전 체크리스트(원인별 빠른 처방)
ECONNRESET이 주로 발생한다면
- keep-alive 유휴 시간 불일치 가능성
- Undici
keepAliveTimeout을 더 짧게 - 중간 장비(LB, 프록시, NAT)의 idle timeout 확인
- Undici
- 서버가 커넥션을 자주 끊는지 확인
- 서버 로그에서 연결 종료 패턴
- 서버의 최대 keep-alive 요청 수/시간 설정
- 대량 동시 요청이면
connections를 제한하고 호출 동시성을 제어
ETIMEDOUT이 주로 발생한다면
- connect 단계인지, headers 단계인지 분리
- Undici
connectTimeout,headersTimeout을 분리 설정
- Undici
- DNS 문제(
EAI_AGAIN)가 섞이면- 리졸버/캐시/네트워크 정책 점검
- 특정 시간대에만 발생하면
- NAT 포화, 배치 트래픽 폭증, 레이트리밋 재시도 폭주 가능성
8단계: 추천 베이스라인 구성(복붙용)
아래 구성은 “외부 API를 안정적으로 호출하는 Node 서비스”에서 흔히 쓰는 기본형입니다.
- Undici Agent로 단계별 타임아웃/풀 제한
- 요청 전체 타임아웃(Abort)
- 멱등 GET만 재시도
- 로그에 코드/지연시간/시도횟수 기록
import { Agent } from "undici";
const dispatcher = new Agent({
connections: 50,
connectTimeout: 3_000,
headersTimeout: 8_000,
bodyTimeout: 20_000,
keepAliveTimeout: 10_000,
keepAliveMaxTimeout: 30_000,
});
export async function robustFetch(url: string, init: RequestInit = {}) {
const res = await fetchWithRetry(url, {
...init,
// 요청별 dispatcher 지정(전역 설정이 부담스러울 때)
dispatcher,
}, {
timeoutMs: 10_000,
retries: 2,
baseDelayMs: 200,
});
// 5xx는 애플리케이션 레벨에서 별도 처리(재시도 정책 분리 권장)
return res;
}
마무리: “네트워크 에러”를 시스템 설계 문제로 다루기
ECONNRESET과 ETIMEDOUT은 단순한 예외가 아니라, 커넥션 생명주기(keep-alive), 중간 장비 정책(idle timeout), 동시성/레이트리밋, 서버 처리 지연이 만나서 만들어지는 결과인 경우가 많습니다.
- 관측을 먼저(어느 단계에서 실패했는지)
- 타임아웃을 단계별로(Undici)
- 재시도는 멱등성과 백오프로(폭주 방지)
- 커넥션 풀/keep-alive는 중간 장비와 정합성 있게
이 4가지만 지켜도 fetch 기반 호출의 실패율이 눈에 띄게 내려가고, 장애가 나도 원인 추적 시간이 줄어듭니다.