비동기 요청을 취소할 수 있는 방법

이나리·2022년 6월 25일
1

문제의 코드

function Categories() {
  const [_, setSearchParams] = useSearchParams();
  const categories = ['카테고리01', '카테고리02', '카테고리03', '카테고리04'];
  const onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    setSearchParams({ category: e.target.value });
  };
  return (
    <div>
      {categories.map(category => (
        <label key={category}>
          <input type="radio" name="category" value={category} onChange={onChange} />
          {category}
          </label>
       ))}
    </div>
  );
}

function App() {
  const { data, setData } = useState<Data | null>(null);
  const [searchParams] = useSearchParams();
  const category = searchParams.get('category') || '';

  useEffect(() => {
    axios
      .get('url', { params: { category } })
      .then((response: Data) => {
        setData(response.data);
      })
      .catch((error: unknown) => {
        console.error(error);
      });
  }, [category]);

  return (
    <div>
      <Categories />
      </form>
      {state.data &&
        state.data.map(data => (
          <RenderDataItem key={data.id} data={data} />
        ))}
      {state.error && <ErrorMessage />}
    </div>
  );
}

위의 코드는 n개의 카테고리 버튼이 있고, 각각의 버튼을 클릭할 때마다 해당 카테고리와 관련된 데이터를 새로 가져옵니다.

이 코드는 실제로 잘 동작하지만, 렌더링과 관련된 문제를 갖고 있는데요.
어떤 문제가 발생했을까요?

코드 분석

제 코드가 실행됐을 때 하나의 상황을 가정해보겠습니다.

사용자가 A 카테고리를 클릭했다면, A 카테고리에 대한 데이터를 가져오겠죠?
그런데 이 A 카테고리 데이터를 가져오기도 전에, 사용자가 B 카테고리를 클릭합니다.
또, B 카테고리 데이터를 가져오기도 전에, C 카테고리를 클릭해버립니다.

이제 화면에는 어떤 카테고리 데이터가 렌더링될까요?

C 카테고리 데이터가 렌더링됩니다. 그러나 그 이전에 A, B 카테고리 데이터도 같이 렌더링됩니다.
카테고리가 변경될 때마다 요청을 보내고, 받아온 데이터로 setState 를 실행하기 때문에 렌더링될 화면이 계속 바껴버립니다. 이것은 제가 의도한 동작이 아닙니다.

한가지 문제가 더 있습니다. 바로, 모든 비동기 데이터 요청이 발생한다는 점입니다.
요청을 보내고 네트워크 탭을 확인해보면, 보낸 모든 요청이 모두 성공적으로 이행됐음을 볼 수 있습니다.

마지막 카테고리 데이터만 렌더링하려고 하는데, n개의 요청이 모두 실행되고 이행된다는 점은 서버에도 무리를 주고, 요청을 낭비하기 때문에 좋지 않습니다.

setState 실행을 막을 수 있는 방법?

아래 코드는 리액트 공식문서의 예제를 참고하여 작성했습니다.

useEffect(() => {
  let shouldSetState = true;
  fetchData(category)
    .then(response => {
      if (!shouldSetState) return;
      setState();
    })
    .catch(error => {
      if (!shouldSetState) return;
      setState();
    });
  return () => {
    shouldSetState = false;
  };
}, [category]);

코드는 다음과 같이 동작합니다.

의존성 배열 값이 변경될 때마다 기존 이펙트 함수의 리턴문이 실행되고, 컴포넌트가 렌더링되면서 새로운 이펙트 함수가 호출됩니다.
그 과정에서 각각의 이펙트 함수 스코프가 렌더링마다 생성됩니다.

각 이펙트 함수 스코프마다 shouldSetState 라는 변수가 존재하고, 이 변수는 언마운트될 때 항상 false 로 변경되기 때문에, 이전 요청의 데이터로 setState를 실행하려고 하면 shouldSetState 변수가 true 가 아니기 때문에 setState가 실행되지 않습니다.

shouldSetState 변수가 함수 스코프마다 독립적으로 존재하기 때문에, 이펙트 함수 안에서 실행되는 비동기 함수의 실행 순서를 보장해주는 역할을 합니다.

이 방법을 사용하면, 컴포넌트가 언마운트된 후 setState가 실행되는 것을 방지할 수 있기 때문에 이전의 데이터 요청에 대한 setState 는 더이상 실행되지 않으므로, 불필요한 렌더링을 막을 수 있습니다.

비동기 요청의 취소

하지만 모든 것이 해결된 것은 아닌데요. 화면을 렌더링하는 것은 개선했지만, 비동기 요청은 계속해서 실행되고 있기 때문입니다.

요청에 사용된 axios는 프로미스 객체를 리턴하는데, 자바스크립트에서는 이 프로미스를 중단할 수 있는 방법을 제공하지 않기 때문에, 자체적으로 요청을 취소할 수 없습니다.

하지만, DOM API에 존재하는 AbortController 객체 를 이용하면 비동기 요청을 취소할 수 있습니다.

AbortController

이 객체는 하나 이상의 웹 요청을 중단할 수 있도록 하는 객체입니다.
이 객체를 이용해 abortController 객체 를 생성하면, 해당 인스턴스에 signal 프로퍼티가 존재합니다.
이 프로퍼티는 DOM 요청과 통신하는 AbortSignal 객체 를 리턴합니다.

AbortSignal 객체 는 DOM 요청과 통신하는 것은 물론, 보낸 요청을 취소할 수 있고, 요청의 취소 여부도 확인할 수 있습니다.

즉, AbortController 객체가 AbortSignal 객체를 리턴하면, 이 객체를 이용해서 DOM에 요청을 보낼 때 이를 취소할 수 있는 권한을 얻게 됩니다.

요청을 보낼 때, 요청 옵션 객체에 이를 전달하기만 하면 이 권한을 가질 수 있습니다.
abort 메서드를 통해 요청이 완료되기 전에 해당 요청을 취소할 수 있고, aborted 프로퍼티를 이용해 해당 요청이 취소됐는지도 확인할 수 있습니다.

코드의 적용

그렇다면, 요청을 언제 취소해야 할까요?

useEffect는 렌더링마다 항상 새로운 이펙트 함수가 생성되어 호출된다고 했었죠?
즉, 각각의 렌더링마다 다른 이펙트 함수 스코프를 갖고 있습니다.
바로 이 점을 이용하면 됩니다. setState의 실행을 막았던 방법과 동일합니다.

이펙트 함수 안에서 abortController 객체 를 생성하고, 이 객체의 signal 프로퍼티를 요청시 옵션으로 전달합니다.
이제 이 객체는 각각의 이펙트 함수마다 독립적으로 존재하므로, 각 요청에 대한 취소 권한을 가질 수 있게 됩니다.

카테고리가 변경될 때, 이펙트 함수의 cleanup 함수에서 abort 메서드를 실행하게 되면 요청이 완료되기 전에 요청을 취소함으로써, 이전에 요청한 데이터를 더이상 가져오지 않습니다.

useEffect(() => {
  const abortController = new AbortController();
  const signal = abortControll.signal;
  axios
    .get('url', {
  		params: { category },
    	signal,
  	})
    .then((response: Data) => {
      if (signal.aborted) return;
      setData(response.data);
    })
    .catch((error: unknown) => {
      if (singal.aborted) return;
      console.error(error);
    });

  return () => {
    abortController.abort();
  };
}, [category]);

이제 동작을 실행한 뒤, 네트워크 탭을 다시 확인해볼까요?

이전에 선택한 카테고리 데이터에 대한 요청이 모두 취소되고, 마지막으로 선택한 카테고리의 데이터만 가져오는 것을 확인할 수 있습니다.

0개의 댓글