← EonsFlow

fetch 에 타임아웃이 없으면 화면이 영구히 잠깁니다 — 헤더만 받고 끝나는 함정까지

2026년 8월 31일

일부 사용자 환경에서만 작업이 몇 분씩 멈춘다는 이야기를 들었습니다. 느린 게 아니라 끝나지 않는 상태였습니다. 응답 없는 요청 하나가 매달려 있었고, 그 뒤로 아무것도 진행되지 않았습니다.

fetch 는 기다리는 데 제한이 없습니다

fetch에는 기본 타임아웃이 없습니다. 서버가 연결만 잡아두고 응답을 주지 않으면 Promise는 계속 대기합니다. 문제는 이게 요청 하나에서 끝나지 않는다는 점입니다.

let busy = false;

async function run() {
  if (busy) return;      // 중복 실행 방지
  busy = true;
  const res = await fetch(url);   // 여기서 영원히 멈춘다
  // ...
  busy = false;          // 도달하지 못한다
}

busy영구히 true로 남습니다. 이후 사용자가 무엇을 눌러도 첫 줄에서 조용히 돌아갑니다. 화면에는 아무 에러도 없고 버튼만 안 먹습니다.

상태 플래그는 반드시 finally 에서 해제해야 합니다

위 코드는 예외가 나도 busy가 안 풀립니다. try / finally로 감싸는 건 타임아웃과 별개로 항상 해야 하는 일입니다. 이걸 안 해두면 증상이 엉뚱한 곳에서 나타나서 원인을 찾기가 훨씬 어려워집니다.

AbortController 로 시간을 끊습니다

async function fetchWithTimeout(url, options = {}, ms = 15000) {
  const ctrl = new AbortController();
  const timer = setTimeout(() => ctrl.abort(), ms);
  try {
    return await fetch(url, { ...options, signal: ctrl.signal });
  } finally {
    clearTimeout(timer);
  }
}

최신 브라우저라면 AbortSignal.timeout()으로 더 짧게 쓸 수도 있습니다.

const res = await fetch(url, { signal: AbortSignal.timeout(15000) });

그런데 이걸로 안 잡히는 경우가 있습니다

fetchWithTimeout에는 구멍이 있습니다. fetch의 Promise는 응답 헤더가 도착하는 순간 resolve됩니다. 본문은 아직 스트림으로 남아 있습니다.

그래서 헤더만 빠르게 주고 본문을 천천히 흘리거나 아예 멈추는 상대를 만나면, finally에서 clearTimeout이 이미 실행된 뒤입니다. 타이머는 꺼졌고 res.json()에서 다시 무한정 기다립니다. 중간에 낀 프록시나 보안 소프트웨어가 본문을 붙잡을 때 실제로 이렇게 됩니다.

해결은 본문까지 같은 시간 예산 안에서 읽는 것입니다.

async function fetchAll(url, options = {}, ms = 15000) {
  const ctrl = new AbortController();
  let timedOut = false;
  const timer = setTimeout(() => { timedOut = true; ctrl.abort(); }, ms);
  try {
    const res = await fetch(url, { ...options, signal: ctrl.signal });
    // 본문까지 타임아웃 안에서 읽는다
    const buf = await res.arrayBuffer();
    // 읽어둔 본문으로 Response 를 다시 만들어 돌려준다
    return new Response(buf, {
      status: res.status,
      statusText: res.statusText,
      headers: res.headers,
    });
  } catch (e) {
    if (timedOut) throw new Error(`요청이 ${ms}ms 안에 끝나지 않았습니다`);
    throw e;
  } finally {
    clearTimeout(timer);
  }
}

본문을 arrayBuffer()로 끝까지 읽은 뒤 Response를 새로 만들어 반환하므로, 호출하는 쪽 코드는 바꾸지 않아도 됩니다. 받은 쪽에서 .json()이든 .text()든 그대로 쓸 수 있습니다.

중단을 AbortError 로만 판정하면 샙니다

타임아웃을 감지하려고 흔히 이렇게 씁니다.

catch (e) {
  if (e.name === "AbortError") { /* 타임아웃 처리 */ }
}

요청 단계에서 끊으면 AbortError가 맞습니다. 그런데 본문을 읽는 도중에 끊으면 브라우저가 다른 에러로 바꿔 던지는 경우가 있습니다. 크롬에서는 TypeError: Failed to fetch로 오기도 합니다. 이러면 위 분기를 그냥 지나쳐서 "알 수 없는 네트워크 오류"로 처리됩니다.

끊은 시점받게 되는 에러
요청 대기 중AbortError
본문 스트림 수신 중TypeError로 오기도 함

그래서 위 예제에서는 timedOut 플래그를 따로 뒀습니다. 에러 종류를 추측하지 말고, 내가 끊었다는 사실을 직접 기억하는 편이 확실합니다.

중단 버튼도 같이 살아납니다

AbortController를 도입하면 사용자가 누르는 "중단" 기능도 같은 구조로 해결됩니다. 타임아웃 타이머 대신 버튼 핸들러에서 ctrl.abort()를 부르면 됩니다. 타임아웃이 없던 시절에는 중단 버튼조차 fetch에 걸린 지점까지 도달하지 못해 동작하지 않았습니다.

정리

이 글은 EonsFlow가 자동화 도구를 만들며 겪은 내용을 정리한 것입니다. "성공했는데 실제로는 아니었던" 사례로 색인 요청이 200을 주는데 색인이 안 됩니다사이트맵을 못 찾는 이유도 함께 보시면 좋습니다.