Fetch 요청이 멈추지 않게 만들기 — AbortSignal.timeout·사용자 취소·안전한 재시도

조회수 11

도입

fetch()는 HTTP 500에서 자동으로 예외를 던지지 않으며, 업무에 맞는 제한 시간도 대신 정해주지 않는다. 사용자는 이미 화면을 떠났는데 요청이 계속되거나, 실패한 POST가 재시도되어 중복 결제가 생길 수도 있다.

장애를 만드는 PHP API

운영 경로와 분리된 로컬 실습 서버에서 사용한다.

<?php
header('Content-Type: application/json; charset=utf-8');

$mode = $_GET['mode'] ?? 'ok';

if ($mode === 'slow') {
    sleep(8);
}

if ($mode === 'error') {
    http_response_code(500);
    echo json_encode(['error' => 'temporary_failure']);
    exit;
}

echo json_encode(['ok' => true, 'mode' => $mode]);
php -S 127.0.0.1:8081

시간 초과와 사용자 취소 결합

async function requestJson(url, {
  timeoutMs = 5000,
  userSignal,
} = {}) {
  const timeoutSignal = AbortSignal.timeout(timeoutMs);
  const signal = userSignal
    ? AbortSignal.any([timeoutSignal, userSignal])
    : timeoutSignal;

  const response = await fetch(url, {
    method: "GET",
    headers: { Accept: "application/json" },
    signal,
  });

  if (!response.ok) {
    throw new HttpError(response.status, await response.text());
  }

  return response.json();
}

class HttpError extends Error {
  constructor(status, body) {
    super(`HTTP ${status}`);
    this.name = "HttpError";
    this.status = status;
    this.body = body;
  }
}

버튼 취소는 별도의 controller로 연결한다.

const controller = new AbortController();
document.querySelector("#cancel").addEventListener("click", () => {
  controller.abort();
});

try {
  const data = await requestJson(
    "http://127.0.0.1:8081/api.php?mode=slow",
    { timeoutMs: 3000, userSignal: controller.signal }
  );
  console.log(data);
} catch (error) {
  if (error.name === "TimeoutError") {
    console.error("제한 시간 초과");
  } else if (error.name === "AbortError") {
    console.info("사용자가 취소함");
  } else if (error instanceof HttpError) {
    console.error("HTTP 오류", error.status);
  } else {
    console.error("네트워크 또는 파싱 오류", error);
  }
}

AbortSignal.timeout()의 시간은 단순 벽시계 경과가 아니라 활성 시간 기준이다. 호환 대상 브라우저가 오래됐다면 MDN 호환성 표를 확인하고 AbortController와 timer 기반 대체 구현을 준비한다.

재시도는 조건부로 제한하기

GET처럼 멱등인 요청 중 일시 장애에만 재시도한다. 인증 실패인 401, 권한 부족인 403, 잘못된 요청인 400은 대개 반복해도 해결되지 않는다.

const RETRYABLE_STATUS = new Set([408, 429, 500, 502, 503, 504]);

function sleep(ms, signal) {
  return new Promise((resolve, reject) => {
    const id = setTimeout(resolve, ms);
    signal?.addEventListener("abort", () => {
      clearTimeout(id);
      reject(signal.reason);
    }, { once: true });
  });
}

async function getJsonWithRetry(url, {
  attempts = 3,
  timeoutMs = 3000,
  signal,
} = {}) {
  let lastError;

  for (let attempt = 0; attempt < attempts; attempt += 1) {
    try {
      return await requestJson(url, { timeoutMs, userSignal: signal });
    } catch (error) {
      lastError = error;

      if (error.name === "AbortError") throw error;
      if (error.name === "TimeoutError" && attempt + 1 < attempts) {
        // 제한된 재시도
      } else if (
        error instanceof HttpError &&
        RETRYABLE_STATUS.has(error.status) &&
        attempt + 1 < attempts
      ) {
        // 제한된 재시도
      } else {
        throw error;
      }

      const base = 300 * (2 ** attempt);
      const jitter = Math.random() * 200;
      await sleep(base + jitter, signal);
    }
  }

  throw lastError;
}

타임아웃마다 새 signal을 생성해야 한다. 이미 abort된 signal을 다음 시도에 재사용하면 즉시 실패한다. POST를 재시도해야 하는 업무라면 서버에서 idempotency key와 중복 방지를 먼저 설계한다.

개발자 도구에서 검증

  1. Network 탭에서 느린 요청이 (canceled)로 끝나는지 확인한다.
  2. 오류 모드에서 실제 시도 횟수가 설정값을 넘지 않는지 센다.
  3. 사용자 취소 후 추가 요청이 발생하지 않는지 확인한다.
  4. 400과 401이 재시도되지 않는지 확인한다.
  5. Offline 모드에서 네트워크 오류 로그가 HTTP 오류와 구분되는지 확인한다.

점검 체크리스트

  • 모든 외부 요청에 업무 기준 제한 시간이 있는가?
  • 사용자 취소와 시간 초과를 구분하는가?
  • response.ok를 직접 검사하는가?
  • 재시도 대상 상태와 최대 횟수가 제한되어 있는가?
  • backoff에 jitter가 있는가?
  • 비멱등 요청의 중복 방지 장치가 있는가?

함께 읽기

참고 자료

확인일: 2026-07-29

한 줄 요약

Fetch 회복성은 제한 시간, 명시적 취소, HTTP 오류 판정, 멱등 요청에만 적용한 제한적 backoff 재시도로 만든다.

댓글 0

  • 첫 번째 댓글을 남겨보세요.