DEV BOX

'26/11/11 UPDATE

WEB / FETCH API / FIELD NOTES

fetch에 타임아웃을 걸었는데 왜 res.json()에서 멈출까

fetch()는 머리글이 오면 끝나므로 그 직후에 타이머를 지우면 본문 읽기는 시간 제한 밖에 놓입니다. Node.js v24.21.0과 Chromium 152에서 같은 코드를 실행해 멈춘 본문, 달라지는 오류 이름, 전체 시간과 청크 간격 제한의 차이를 살펴봅니다.

fetch()에 시간 제한을 걸어 두었는데 화면이 계속 로딩 중이라면, 타이머를 언제 지웠는지부터 보세요. fetch()의 약속은 머리글이 도착하면 끝나고, 본문은 그 뒤에 따로 읽습니다. 이 글을 읽고 나면 본문까지 덮는 시간 제한을 쓰고, 오류 이름에 기대지 않고 시간 초과를 가려내고, 전체 시간과 청크 간격 중 무엇을 제한할지 고를 수 있습니다.

예제는 2026-11-01 macOS 27.0.1에서 실행했습니다. 같은 파일(cases.mjs)을 Node.js v24.21.0(undici 7.29.1)과 Claude 데스크톱 앱의 내장 브라우저(Chromium 152.0.7977.167)에서 돌렸고, 서버는 localhost:4392의 로컬 서버입니다. /stall-headers는 답하지 않고, /stall-body는 머리글과 본문 앞부분만 보낸 뒤 멈추며, /trickle은 2초에 걸쳐 나누어 보냅니다. 제한은 모두 500ms입니다. 동작은 Fetch 표준과 MDN, undici 문서로 확인했습니다. Google Chrome, Safari, Firefox와 실제 인터넷 구간은 실행하지 않았습니다.

타이머를 fetch() 직후에 지우면 본문은 무방비입니다

이런 코드를 봅시다. 응답이 왔으니 타이머를 지웁니다.

async function clearedEarly(url, ms, mark) {
  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(), ms);
  const res = await fetch(url, { signal: controller.signal });
  clearTimeout(timer);
  mark('fetch resolved');
  return await res.json();
}
Node.js v24.21.0 출력. A: fetch resolved at 1 ms, still pending at 3007 ms, 서버가 닫은 뒤 3011 ms에 TypeError: terminated. B: 502 ms에 AbortError. C: AbortSignal.timeout은 /stall-body, /stall-headers, /trickle 모두 502 ms에 TimeoutError.
타이머를 fetch() 직후에 지우면 본문이 멈췄을 때 3초가 지나도 끝나지 않았습니다. Node.js에서 실행한 실제 출력입니다.

/stall-body에서 fetch()는 1ms에 끝났고, res.json()은 3초가 지나도 끝나지 않았습니다. 실험 코드가 서버에 연결을 닫게 한 뒤에야 거부되었습니다. Fetch 표준에서 fetch()의 약속은 응답 처리 단계에서 이행되고, 본문 끝을 처리하는 단계는 따로 있습니다. 응답 상태와 머리글만 있으면 약속이 끝난다는 뜻입니다. 코드를 다음처럼 고치면 타이머가 본문 읽기까지 살아 있어, 502ms에 AbortError로 끝났습니다.

async function clearedAfterBody(url, ms, mark) {
  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(), ms);
  try {
    const res = await fetch(url, { signal: controller.signal });
    mark('fetch resolved');
    return await res.json();
  } finally {
    clearTimeout(timer);
  }
}

AbortSignal.timeout은 전체 시간을 제한합니다

타이머를 직접 관리하지 않으려면 AbortSignal.timeout(ms)을 fetch()에 넘깁니다. 같은 코드가 머리글이 오지 않는 경우와 본문이 멈춘 경우 모두 502ms에 끝났습니다. 다만 이 제한은 전체 시간입니다. 데이터가 계속 오던 /trickle도 502ms에 끊겼습니다. 큰 파일이나 스트리밍 응답에 짧은 값을 주면 정상 응답을 끊게 됩니다. MDN은 이 시간이 경과 시간이 아니라 활성 시간 기준이라 뒤로·앞으로 캐시에 들어간 문서나 멈춘 워커에서는 사실상 멈춘다고 적습니다.

오류 이름은 어디서 끊겼는지에 따라 달랐습니다

브라우저에서 같은 파일을 돌리자 예상과 다른 결과가 나왔습니다.

Chromium 152 출력. A: 3143 ms에도 끝나지 않고 서버가 닫자 TypeError: Failed to fetch. C /stall-body: 957 ms에 AbortError: The user aborted a request. C /stall-headers: 952 ms에 TimeoutError: signal timed out. D 사용자 취소: 993 ms에 AbortError. F /stall-body: 1008 ms에 TimeoutError: signal timed out.
같은 코드라도 이 브라우저에서는 어디서 끊겼는지에 따라 오류 이름이 달랐습니다. 실제 서버 로그의 page 줄입니다.

머리글을 기다리다 끊긴 요청은 TimeoutError였지만, 본문을 읽다 끊긴 요청은 AbortError: The user aborted a request.였습니다. 사용자가 취소한 일이 없는데도 그렇습니다. Node.js에서는 두 경우 모두 TimeoutError였습니다. Fetch 표준은 중단 이유로 본문에 오류를 내도록 정하고 있어, 글쓴이가 읽기로는 TimeoutError가 나와야 할 자리입니다. 왜 다른지는 조사하지 않았고, 다른 브라우저와 다른 버전도 실행하지 않았으니 이 빌드에서 본 결과로만 읽어 주세요.

그래도 err.name === 'TimeoutError'만 확인하는 코드는 이 브라우저에서 본문 시간 초과를 놓칩니다. MDN의 AbortSignal.timeout 예제도 본문 읽기를 try 안에 두고 오류 이름으로 나누므로, 그대로 쓰면 이 경우에 AbortError 쪽으로 갑니다. 이름 대신 신호에 물어보면 두 환경이 같아졌습니다.

async function getJson(url, ms, mark) {
  const signal = AbortSignal.timeout(ms);
  try {
    const res = await fetch(url, { signal });
    mark('fetch resolved');
    return await res.json();
  } catch (err) {
    throw signal.aborted ? signal.reason : err;
  }
}

사용자 취소까지 받으려면 AbortSignal.any([userSignal, AbortSignal.timeout(ms)])로 묶습니다. 머리글을 기다리는 동안 먼저 일어난 쪽의 이름이 그대로 나왔습니다. 취소는 AbortError, 시간 초과는 TimeoutError입니다.

브라우저의 시각은 500ms가 아니라 대략 1000ms였습니다. 100ms에 걸어 둔 취소의 거부도 993ms에 나왔습니다. 실행하는 동안 브라우저 패널이 숨겨져 있었고, MDN의 setTimeout 문서는 브라우저가 비활성 탭의 타이머에 최소 지연을 둔다고 적습니다. 이것이 원인인지 따로 시험하지는 않았습니다. 다만 뒤에 있는 탭에서 시간 제한이 정확히 그 시각에 일어난다고 기대하지 않는 편이 안전합니다. 위 그림에서는 시각보다 오류 이름을 보아 주세요.

느린 응답을 살리려면 청크 사이 간격을 잽니다

내려받기처럼 오래 걸려도 괜찮고 멈추는 것만 문제라면, 전체 시간 대신 청크 사이 간격을 재면 됩니다. Fetch 표준의 요청 옵션에는 이런 항목이 없어 직접 읽습니다.

async function readWithIdleLimit(url, ms, mark) {
  const controller = new AbortController();
  const expire = () => controller.abort(new DOMException(`no data for ${ms} ms`, 'TimeoutError'));
  let timer = setTimeout(expire, ms);
  try {
    const res = await fetch(url, { signal: controller.signal });
    mark('fetch resolved');
    const reader = res.body.getReader();
    const decoder = new TextDecoder();
    let text = '';
    for (;;) {
      clearTimeout(timer);
      timer = setTimeout(expire, ms);
      const { done, value } = await reader.read();
      if (done) return JSON.parse(text);
      text += decoder.decode(value, { stream: true });
    }
  } catch (err) {
    throw controller.signal.aborted ? controller.signal.reason : err;
  } finally {
    clearTimeout(timer);
  }
}
Node.js v24.21.0 출력. E /trickle: 2018 ms에 20개 항목으로 완료. E /stall-body: 502 ms에 TimeoutError: no data for 500 ms. 신호 없음: /stall-headers는 301.0 s에 TypeError: fetch failed, cause UND_ERR_HEADERS_TIMEOUT. /stall-body는 301.0 s에 TypeError: terminated, cause UND_ERR_BODY_TIMEOUT.
간격을 재면 간격이 제한 안에 드는 느린 응답은 살리고 멈춘 응답은 끊습니다. 신호가 없으면 Node.js는 약 5분을 기다렸습니다. 실제 출력입니다.

Node.js에서 /trickle은 2018ms에 20개 항목을 모두 받았고 /stall-body는 502ms에 끊겼습니다. 브라우저에서도 이름과 끝난 방식은 같았습니다(2023ms, 981ms). 이 코드는 전체 시간을 제한하지 않으므로, 조금씩 끝없이 오는 응답은 끊지 못합니다. 필요하면 AbortSignal.any로 전체 제한을 함께 겁니다. 이 조합은 실행하지 않았습니다.

아무것도 걸지 않으면 Node.js는 약 5분을 기다렸습니다

신호 없이 부른 Node.js fetch는 머리글을 기다리는 경우와 본문이 멈춘 경우 모두 301.0초에 거부되었습니다. 원인 코드는 각각 UND_ERR_HEADERS_TIMEOUT과 UND_ERR_BODY_TIMEOUT입니다. undici 7.29.1 문서의 기본값 headersTimeout과 bodyTimeout 300초와 맞습니다. 이 문서에 따르면 bodyTimeout은 전체 시간이 아니라 청크 사이 간격을 잽니다. 현재 undici 문서(main 브랜치)는 이 시간 초과가 정확한 밀리초에 일어나지 않을 수 있다고 덧붙입니다. 거부되기 전까지 사용자나 호출한 함수는 기다립니다. 브라우저에서는 신호 없이 끝까지 기다려 보지 않았습니다.

개발 노트

시간 제한이 어디까지 닿는가

제한 500ms로 실행한 세 방식

  • fetch() 직후에 타이머를 지운다

    clearTimeout(timer)

    • 머리글까지만 닿는다
    • 본문이 멈추자 3초가 지나도 끝나지 않았다
  • 전체 시간을 제한한다

    AbortSignal.timeout(ms)

    • 머리글과 본문 읽기까지 닿는다
    • 데이터가 흐르던 2초짜리 응답도 끊겼다
    • 실행 환경에 따라 오류 이름이 다를 수 있어 signal.reason을 본다
  • 청크 사이 간격을 제한한다

    await reader.read()

    • 청크가 올 때마다 타이머를 다시 건다
    • 2초짜리 응답은 끝까지 받았고 멈춘 응답은 끊겼다
    • 전체 시간은 따로 제한하지 않는다

2026-11-01 Node.js v24.21.0과 Chromium 152.0.7977.167에서 실행한 결과를 글쓴이가 정리했다. Google Chrome, Safari, Firefox는 실행하지 않았다.

세 방식이 닿는 범위를 나눈 그림입니다. 실행 결과를 글쓴이가 정리했습니다.

프로젝트에서 clearTimeout이 res.json()이나 res.text()보다 먼저 나오는 곳을 찾아보세요. 그런 곳은 타이머를 finally로 옮기고 readWithIdleLimit처럼 abort()에 이유를 넘기거나, AbortSignal.timeout으로 바꿉니다. 그런 다음 시간 초과 판단은 err.name 대신 signal.aborted와 signal.reason으로 하면 됩니다.

끝 · END OF NOTE
RSS 구독목록으로
COMMENTS BOX

이 기록에 대화를 더해 주세요.

궁금한 점, 다른 접근, 직접 해 본 결과를 나눠 주세요.

최신순

로그인 상태 확인 중…

댓글을 불러오는 중…