블로그 목록
frontend

AbortSignal.any()로 타임아웃과 사용자 취소 한 번에 묶기 — 중단 신호 수동 관리 그만

검색 요청에 타임아웃을 걸고 싶은데, 동시에 사용자가 "취소" 버튼도 누를 수 있어야 한다. 중단 사유가 두 개인 셈이다.

예전엔 AbortController 하나를 만들고 거기에 setTimeout으로 abort()를 걸고, 취소 버튼에서도 같은 컨트롤러를 abort() 했다. 신호가 한 곳에 섞이니 "타임아웃이었나, 사용자가 눌렀나"를 나중에 구분할 수가 없다.

신호를 합치는 AbortSignal.any()

AbortSignal.any([signal1, signal2, ...])는 입력 신호 중 하나라도 중단되면 함께 중단되는 새 신호를 만든다. 각 사유를 독립된 신호로 두고 묶기만 하면 된다.

function createSearchController() {
  // 사용자가 직접 취소할 수 있는 신호
  const userCancel = new AbortController();

  // 10초 타임아웃 신호
  const timeout = AbortSignal.timeout(10_000);

  // 둘 중 하나라도 발동하면 중단되는 합친 신호
  const signal = AbortSignal.any([userCancel.signal, timeout]);

  return { signal, cancel: () => userCancel.abort() };
}

사용하기

중요한 건 중단 사유를 구분할 수 있다는 점이다. 타임아웃이 발동하면 TimeoutError, 사용자가 abort()를 호출하면 AbortError로 던져진다.

const { signal, cancel } = createSearchController();

cancelButton.onclick = cancel;

try {
  const res = await fetch(`/api/search?q=${query}`, { signal });
  render(await res.json());
} catch (err) {
  if (err.name === 'TimeoutError') {
    showMessage('검색이 너무 오래 걸립니다. 잠시 후 다시 시도하세요.');
  } else if (err.name === 'AbortError') {
    // 사용자가 직접 취소 — 조용히 무시
  } else {
    throw err;
  }
}

React에서

useEffect cleanup과 묶으면 언마운트 시 중단까지 자연스럽게 처리된다.

useEffect(() => {
  const controller = new AbortController();
  const signal = AbortSignal.any([
    controller.signal,
    AbortSignal.timeout(8_000),
  ]);

  fetch(url, { signal })
    .then((r) => r.json())
    .then(setData)
    .catch((e) => {
      if (e.name !== 'AbortError' && e.name !== 'TimeoutError') setError(e);
    });

  return () => controller.abort(); // 언마운트 시 중단
}, [url]);

한 가지 주의점. any()로 만든 신호는 입력 신호 중 하나라도 이미 중단된 상태면 즉시 중단된다. 그리고 합친 신호는 입력 신호들을 약하게 참조하므로 메모리 누수 걱정은 하지 않아도 된다.


중단 사유가 여러 개일 때 AbortController 하나에 억지로 몰아넣지 말자. 각 사유를 독립된 신호로 만들고 AbortSignal.any()로 묶으면, 코드도 깔끔해지고 어느 쪽이 발동했는지도 에러 타입으로 바로 구분된다.