C.W.K.
Stream
Lesson 05 of 07 · published

fetch + AbortController — 비행 중 취소

~11 min · fetch, abortcontroller, cancellation

Level 0React 입문자
0 XP0/54 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete
취소 안 된 모든 fetch 는 미래의 race condition. AbortController 가 사용자가 마음 바꿀 때 in-flight 요청 취소하는 표준, 무료, 라이브러리 없는 방식.

Race condition

사용자가 검색 박스에 타이핑. 각 키스트로크가 fetch 발화. Fetch 들이 순서 어긋나서 반환 — 'r' 의 응답이 're' 의 응답 후 도착. 취소 없으면 더 옛 응답이 이김 (시간상 늦게 land 하니까), UI 가 잘못된 쿼리 결과 보여줌. 고침은 대체된 요청 abort.

API

Controller 생성, 그 signal 을 fetch 에 전달, 취소하려면 controller.abort() 호출. Fetch promise 가 AbortError 로 reject. 그 에러 타입을 에러 처리에서 필터 (예상된 것, 예외 아님).

통합 자리

  • useEffect — 상단에 controller 생성, cleanup 함수 (effect 에서 반환) 에서 abort.
  • 커스텀 hook — 같은 패턴, 그냥 hook 안.
  • 이벤트 핸들러 — Controller 를 ref 에 저장해서 뒤따르는 핸들러 호출이 이전 거 abort 가능.

Timeout

Hard timeout 엔 AbortControllerAbortSignal.timeout(ms) 결합: fetch(url, { signal: AbortSignal.timeout(5000) }) 가 5초 후 abort. 수동 + timeout 엔 AbortSignal.any([ctrl.signal, AbortSignal.timeout(5000)]) — 둘 중 하나 발화 시 abort.

모든 fetch 는 취소 방법 필요. '취소 필요 없어' 는 보통 '아직 race condition 안 만났어'. 한 번 규율 잡아 — hook 의 모든 fetch 가 signal 받고, 모든 cleanup 이 abort. 막는 버그가 QA 가 재현 못 하는 그 종류.

Code

useEffect — fetch 라이프사이클의 AbortController·tsx
import { useEffect, useState } from "react";

function useSearchResults(query: string) {
  const [results, setResults] = useState<string[]>([]);

  useEffect(() => {
    if (!query) {
      setResults([]);
      return;
    }
    const ctrl = new AbortController();
    fetch(`/api/search?q=${encodeURIComponent(query)}`, { signal: ctrl.signal })
      .then((r) => r.json())
      .then(setResults)
      .catch((e) => {
        // AbortError 예상 — 비행 중 query 바뀜.
        if (e.name !== "AbortError") throw e;
      });
    return () => ctrl.abort();
  }, [query]);

  return results;
}
Ref-저장 controller 가진 이벤트 핸들러·tsx
import { useRef } from "react";

function useSave() {
  // 최신 controller — 이전 호출의 controller 가 abort 됨.
  const ctrlRef = useRef<AbortController | null>(null);

  const save = async (payload: object) => {
    // 이전 in-flight save abort.
    ctrlRef.current?.abort();
    const ctrl = new AbortController();
    ctrlRef.current = ctrl;
    try {
      const r = await fetch("/api/save", {
        method: "POST",
        body: JSON.stringify(payload),
        signal: ctrl.signal,
      });
      return await r.json();
    } catch (e) {
      if ((e as Error).name === "AbortError") return null; // 대체됨
      throw e;
    }
  };

  return save;
}
AbortSignal.timeout — hard 취소 deadline·ts
async function fetchWithTimeout<T>(url: string, ms: number): Promise<T> {
  const r = await fetch(url, { signal: AbortSignal.timeout(ms) });
  return r.json();
}

// AbortSignal.any — 여러 signal 결합 (사용자 취소 + timeout).
async function fetchUserCancellable<T>(
  url: string,
  userSignal: AbortSignal,
  ms: number
): Promise<T> {
  const r = await fetch(url, {
    signal: AbortSignal.any([userSignal, AbortSignal.timeout(ms)]),
  });
  return r.json();
}

External links

Exercise

useSearchResults 에 연결된 검색 input 빌드. useDebouncedValue (Track 3 lesson 4) 로 throttle. 빠른 타이핑이 깜빡이는 잘못된 결과 안 만드는지 확인 — AbortController 가 대체된 요청 취소, debounce 가 rate 감소. AbortSignal.any 통해 3초 timeout 추가 + 일부러 느린 endpoint 가 3초 후 timeout 에러 보이는지 증명.
Hint
옛 쿼리 결과 보이면 AbortController 셋업 잘못 — cleanup 안 도는 거나 controller 가 useEffect 밖에 생성 (render 간 같은 거).

Progress

Progress is local-only — sign in to sync across devices.
이 페이지에서 버그를 발견하셨거나 피드백이 있으세요?문제 신고
💛 by 똘이warm

댓글 0

🔔 답글 알림 (로그인 필요)
로그인댓글을 남기려면 로그인해 주세요.

아직 댓글이 없어요. 첫 댓글을 남겨보세요.