뒤로가기
프론트엔드에서 AbortController 제대로 쓰기

March 6, 2023

javascriptreactfrontend

검색 인풋에 "react"를 타이핑하면 요청이 다섯 번 나간다. "r", "re", "rea", "reac", "react". 네트워크 상태에 따라 "rea"의 응답이 "react"보다 늦게 도착할 수 있다. 그러면 사용자는 "react"를 검색했는데 "rea"의 결과를 보게 된다.

이게 race condition이다. 그리고 AbortController가 이걸 해결한다.

AbortController 기본#

AbortController는 진행 중인 비동기 작업을 취소하는 웹 표준 API다.

javascript
const controller = new AbortController();

fetch('/api/search?q=react', {
  signal: controller.signal,
});

// 요청을 취소하고 싶을 때
controller.abort();

abort()를 호출하면 해당 signal에 연결된 fetch가 AbortError를 던지며 중단된다. 브라우저가 실제로 네트워크 요청을 취소하기 때문에 대역폭도 절약된다.

패턴 1: 검색 자동완성의 race condition 방지#

이전 요청을 새 요청이 시작될 때 취소하면 된다.

typescript
function useSearch(query: string) {
  const [results, setResults] = useState([]);

  useEffect(() => {
    if (!query) {
      setResults([]);
      return;
    }

    const controller = new AbortController();

    fetch(`/api/search?q=${encodeURIComponent(query)}`, {
      signal: controller.signal,
    })
      .then((res) => res.json())
      .then(setResults)
      .catch((err) => {
        if (err.name !== 'AbortError') throw err;
        // AbortError는 의도적 취소이므로 무시
      });

    return () => controller.abort();
  }, [query]);

  return results;
}

useEffect의 cleanup 함수에서 abort()를 호출한다. query가 바뀔 때마다 이전 요청이 취소되고 새 요청만 살아남는다. "rea"의 응답이 "react"보다 늦게 도착하는 문제가 원천적으로 해결된다.

Info

AbortError는 정상적인 취소이므로 catch에서 걸러줘야 한다. 안 그러면 에러 모니터링에 불필요한 노이즈가 쌓인다.

패턴 2: 버튼 중복 클릭 방지#

"저장" 버튼을 빠르게 두 번 클릭하면 같은 요청이 두 번 나간다. 데이터가 두 번 생성될 수 있다. 보통 버튼을 disable 시키는데, AbortController로도 처리할 수 있다.

typescript
function useMutation<T>(mutationFn: (signal: AbortSignal) => Promise<T>) {
  const controllerRef = useRef<AbortController | null>(null);
  const [isPending, setIsPending] = useState(false);

  const mutate = useCallback(async () => {
    // 이전 요청이 진행 중이면 취소
    controllerRef.current?.abort();

    const controller = new AbortController();
    controllerRef.current = controller;
    setIsPending(true);

    try {
      const result = await mutationFn(controller.signal);
      return result;
    } catch (err) {
      if (err instanceof Error && err.name === 'AbortError') return;
      throw err;
    } finally {
      setIsPending(false);
    }
  }, [mutationFn]);

  return { mutate, isPending };
}
typescript
// 사용
const { mutate: savePost, isPending } = useMutation((signal) =>
  fetch('/api/posts', {
    method: 'POST',
    body: JSON.stringify(postData),
    signal,
  })
);

두 번 클릭해도 첫 번째 요청이 취소되고 두 번째만 실행된다. 물론 서버 사이드 멱등성 처리도 필요하지만, 클라이언트에서 1차 방어를 하는 셈이다.

패턴 3: 타임아웃 구현#

fetch에는 기본 타임아웃이 없다. AbortSignal.timeout()으로 간단하게 구현할 수 있다.

typescript
// 5초 안에 응답이 없으면 취소
const response = await fetch('/api/data', {
  signal: AbortSignal.timeout(5000),
});

직접 구현할 필요 없이 한 줄이다. 수동으로 AbortController와 setTimeout을 조합하는 것보다 깔끔하다.

여러 조건을 조합하고 싶으면 AbortSignal.any()를 쓴다:

typescript
const controller = new AbortController();

const response = await fetch('/api/data', {
  signal: AbortSignal.any([
    controller.signal,           // 수동 취소
    AbortSignal.timeout(5000),   // 5초 타임아웃
  ]),
});

사용자가 취소 버튼을 누르거나, 5초가 지나거나, 둘 중 하나라도 발생하면 요청이 취소된다.

패턴 4: 페이지 이동 시 진행 중인 요청 정리#

SPA에서 페이지를 이동하면 이전 페이지의 컴포넌트가 언마운트된다. 그런데 진행 중이던 fetch는 그대로 살아있고, 응답이 도착하면 이미 없는 컴포넌트의 state를 업데이트하려고 한다.

React 18부터는 경고가 사라졌지만, 불필요한 네트워크 요청이 계속 진행되는 건 여전히 낭비다.

typescript
function useData(url: string) {
  const [data, setData] = useState(null);

  useEffect(() => {
    const controller = new AbortController();

    fetch(url, { signal: controller.signal })
      .then((res) => res.json())
      .then(setData)
      .catch((err) => {
        if (err.name !== 'AbortError') throw err;
      });

    return () => controller.abort(); // 언마운트 시 요청 취소
  }, [url]);

  return data;
}

TanStack Query를 쓰고 있다면 내부적으로 AbortController를 처리해주니까 직접 관리할 필요가 없다. queryFnsignal이 자동으로 전달된다:

typescript
useQuery({
  queryKey: ['search', query],
  queryFn: ({ signal }) =>
    fetch(`/api/search?q=${query}`, { signal }).then((res) => res.json()),
});

정리#

AbortController는 화려한 기술이 아니다. 하지만 제대로 쓰면 race condition, 중복 요청, 메모리 낭비를 깔끔하게 해결한다. 특히 검색, 자동완성, 무한 스크롤처럼 요청이 빈번한 UI에서는 사실상 필수다.

fetch를 쓸 때 signal을 넘기는 습관만 들여도 대부분의 문제를 예방할 수 있다.