본문으로 건너뛰기
개발 머꼬
개발 노트JavaScript
hohyeon.dev19

취소한 줄 알았던 fetch가 계속 살아 있어서 응답이 뒤늦게 도착한 이유

  • #Async
  • #Engineering Note
  • #JavaScript

문제 발생

검색어를 빠르게 입력할 때마다 요청이 쌓여서, 늦게 도착한 예전 응답이 최신 결과를 덮어썼습니다. 타임아웃도 걸어 뒀는데 서버 로그에는 요청이 끝까지 처리된 흔적이 남았습니다.

const withTimeout = (promise, ms) =>
  Promise.race([promise, new Promise((_, r) => setTimeout(() => r(new Error("timeout")), ms))]);

await withTimeout(fetch(url), 3000);   // 요청 자체는 계속 진행된다

원인 분석

프로미스는 취소할 수 없습니다. Promise.race는 "둘 중 먼저 끝난 쪽의 결과를 쓴다"일 뿐, 진 쪽을 중단시키지 않습니다. 그래서 위 코드는 기다리기를 그만둘 뿐 네트워크 요청은 그대로 살아 있습니다. 서버는 계속 일하고, 대역폭도 계속 쓰고, 응답이 도착하면 그때부터의 처리도 그대로 실행됩니다.

AbortController는 이 문제를 위한 표준 도구입니다. signal을 전달받은 쪽이 실제로 작업을 중단합니다. fetch는 이 신호를 이해하므로 진행 중인 요청을 끊고 AbortError로 거부합니다.

해결 방안

  1. 요청마다 컨트롤러를 만들고, 새 요청 전에 이전 것을 끊습니다.
let controller;

async function search(query) {
  controller?.abort();                       // 이전 요청 취소
  controller = new AbortController();
  try {
    const res = await fetch(`/search?q=${encodeURIComponent(query)}`, {
      signal: controller.signal,
    });
    return await res.json();
  } catch (error) {
    if (error.name === "AbortError") return;  // 취소는 오류가 아니다
    throw error;
  }
}
  1. 타임아웃은 AbortSignal.timeout() 한 줄이면 됩니다. 직접 setTimeout으로 만들 필요가 없습니다.
await fetch(url, { signal: AbortSignal.timeout(3000) });
  1. 취소와 타임아웃을 함께 쓰려면 신호를 합칩니다.
const signal = AbortSignal.any([controller.signal, AbortSignal.timeout(3000)]);
await fetch(url, { signal });
  1. AbortError를 실패로 보고하지 않습니다. 사용자가 화면을 떠나서 취소된 것까지 오류 로그에 쌓이면 실제 장애를 가립니다. 취소는 정상 흐름입니다.
  2. 컴포넌트가 사라질 때도 끊습니다. React라면 effect 정리 함수에서 abort()를 호출합니다 — 이미 언마운트된 컴포넌트에 상태를 쓰는 문제까지 함께 없어집니다.
useEffect(() => {
  const controller = new AbortController();
  load({ signal: controller.signal });
  return () => controller.abort();
}, [query]);
  1. fetch 외에도 쓸 수 있습니다. addEventListenersignal 옵션, Web Streams, 그리고 직접 만든 비동기 함수에도 같은 신호를 넘겨 중단 지점을 만들 수 있습니다.

공식 문서

마지막 수정

좋아요북마크

댓글0

아직 댓글이 없어요. 첫 의견을 편하게 남겨 보세요.