[번역] 자바스크립트에서 프로미스를 취소하는 방법

eunbinn·2024년 7월 18일
31

FrontEnd 번역

목록 보기
34/38
post-thumbnail
post-custom-banner

Promise.withResolversAbortController를 사용하여 자바스크립트에서 취소 가능한 작업 만들기

원문: https://webdeveloper.beehiiv.com/p/cancel-promises-javascript

자바스크립트에서 요청을 취소하는 방법은 이미 알고 계실 것입니다. XHR은 xhr.abort()를, fetch는 signal을 사용하면 됩니다. 하지만 일반적인 프로미스는 어떻게 취소할 수 있을까요?

현재 자바스크립트의 프로미스는 일반적인 프로미스를 취소하는 API를 제공하지 않습니다. 따라서 이 글에서는 프로미스의 결과를 폐기 또는 무시하는 방법을 설명해보려 합니다.

첫 번째 방법: New Promise.withResolvers() 사용하기

이제 Promise.withResolvers()라는 새로운 API를 사용할 수 있습니다. 이 함수는 새 Promise 객체와 이를 이행(resolve)하거나 거부(reject)할 수 있는 두 개의 함수가 포함된 객체를 반환합니다.

코드로 보면 다음과 같습니다.

let resolve, reject;
const promise = new Promise((res, rej) => {
  resolve = res;
  reject = rej;
});

이제는 이렇게 할 수 있습니다.

const { promise, resolve, reject } = Promise.withResolvers();

따라서 이를 활용해서 cancel 메서드를 노출할 수 있습니다.

const buildCancelableTask = <T>(asyncFn: () => Promise<T>) => {
  let rejected = false;
  const { promise, resolve, reject } = Promise.withResolvers<T>();

  return {
    run: () => {
      if (!rejected) {
        asyncFn().then(resolve, reject);
      }

      return promise;
    },

    cancel: () => {
      rejected = true;
      reject(new Error("CanceledError"));
    },
  };
};

아래 테스트 코드와 함께 사용할 수 있습니다.

const sleep = (ms: number) => new Promise((res) => setTimeout(res, ms));

const ret = buildCancelableTask(async () => {
  await sleep(1000);
  return "Hello";
});

(async () => {
  try {
    const val = await ret.run();
    console.log("val: ", val);
  } catch (err) {
    console.log("err: ", err);
  }
})();

setTimeout(() => {
  ret.cancel();
}, 500);

여기서 작업이 최소 1000ms가 걸리도록 설정했지만 500ms 이내에 취소하므로 다음과 같은 결과를 보실 수 있습니다.

image

정말 작업을 취소했다기 보다, 조기 거부(early rejection)를 수행했다는 점을 알아두세요. 원래의 asyncFn()은 이행되거나 거부될 때까지 계속 실행되나 Promise.withResolvers<T>()로 생성된 프로미스는 이미 거부되었기 때문에 영향이 없는 것입니다.

두번째 방법: AbortController 사용하기

페칭 요청을 취소하는 것과 비슷하게 리스너를 통해 조기 거부하도록 구현할 수 있습니다. 다음과 같습니다.

const buildCancelableTask = <T>(asyncFn: () => Promise<T>) => {
  const abortController = new AbortController();

  return {
    run: () =>
      new Promise()<T>((resolve, reject) => {
        const cancelTask = () => reject(new Error("CanceledError"));

        if (abortController.signal.aborted) {
          cancelTask();
          return;
        }

        asyncFn().then(resolve, reject);

        abortController.signal.addEventListener("abort", cancelTask);
      }),

    cancel: () => {
      abortController.abort();
    },
  };
};

첫번째 방법과 유사하지만 AbortController를 사용했다는 점이 다릅니다. 여기서 다른 리스너를 사용할 수도 있지만, AbortController는 cancel을 여러 번 호출해도 'abort' 이벤트가 한 번만 트리거된다는 장점이 있습니다.

이 코드를 기반으로 더 확장해 취소 가능한 페칭을 구현할 수 있습니다. 이는 이전 요청 결과를 버리고 최신 요청 결과를 사용하는 순차적 요청과 같은 시나리오에서 유용할 수 있습니다.

const buildCancelableFetch = <T>(
  requestFn: (signal: AbortSignal) => Promise<T>
) => {
  const abortController = new AbortController();

  return {
    run: () =>
      new Promise<T>((resolve, reject) => {
        if (abortController.signal.aborted) {
          reject(new Error("CanceledError"));
          return;
        }

        requestFn(abortController.signal).then(resolve, reject);
      }),

    cancel: () => {
      abortController.abort();
    },
  };
};
const ret = buildCancelableFetch(async (signal) => {
  return fetch("http://localhost:5000", { signal }).then((res) => res.text());
});
(async () => {
  try {
    const val = await ret.run();
    console.log("val: ", val);
  } catch (err) {
    console.log("err: ", err);
  }
})();
setTimeout(() => {
  ret.cancel();
}, 500);

이는 서버 측 처리 로직에는 영향을 미치지 않으며, 단지 브라우저가 요청을 폐기/취소하게 할 뿐이라는 점에 유의하세요. 즉, 사용자 정보를 업데이트하기 위해 POST 요청을 보내고 취소하더라도, 업데이트 요청 자체는 유효할 수 있습니다. 따라서 이 방법은 새 데이터를 가져오기 위해 GET 요청을 하는 시나리오에서 일반적으로 사용됩니다.

순차적 요청을 처리하는 간단한 리액트 훅 만들기

더 나아가서 순차적 요청을 처리하는 간단한 리액트 훅으로 캡슐화 할 수 있습니다.

import { useCallback, useRef } from "react";

const buildCancelableFetch = <T>(
  requestFn: (signal: AbortSignal) => Promise<T>
) => {
  const abortController = new AbortController();

  return {
    run: () =>
      new Promise<T>((resolve, reject) => {
        if (abortController.signal.aborted) {
          reject(new Error("CanceledError"));
          return;
        }

        requestFn(abortController.signal).then(resolve, reject);
      }),

    cancel: () => {
      abortController.abort();
    },
  };
};

function useLatest<T>(value: T) {
  const ref = useRef(value);
  ref.current = value;
  return ref;
}

export function useSequentialRequest<T>(
  requestFn: (signal: AbortSignal) => Promise<T>
) {
  const requestFnRef = useLatest(requestFn);
  const currentRequest = useRef<{ cancel: () => void } | null>(null);

  return useCallback(async () => {
    if (currentRequest.current) {
      currentRequest.current.cancel();
    }

    const { run, cancel } = buildCancelableFetch(requestFnRef.current);
    currentRequest.current = { cancel };

    return run().finally(() => {
      if (currentRequest.current?.cancel === cancel) {
        currentRequest.current = null;
      }
    });
  }, [requestFnRef]);
}

아래와 같이 사용할 수 있습니다.

import { useSequentialRequest } from "./useSequentialRequest";

export function App() {
  const run = useSequentialRequest((signal: AbortSignal) =>
    fetch("http://localhost:5000", { signal }).then((res) => res.text())
  );

  return <button onClick={run}>Run</button>;
}

이렇게 하면 버튼을 빠르게 여러 번 클릭했을 때 이전 요청은 취소되고 최신 요청 데이터만 가져올 수 있습니다.

image

순차적 요청을 처리하는 최적화된 리액트 훅 만들기

좀더 일반적인 상황의 순차적 요청을 처리하는 리액트 훅이 필요하다면, 위의 예제에는 여전히 개선의 여지가 있습니다.

  • 실제로 필요하기 전까지 고유한 AbortController를 사용하여 매번 생성하는 데 드는 비용을 줄일 수 있습니다.
  • 제네릭을 사용하여 어떤 인자든 전달할 수 있도록 요청 메서드를 만들 수 있습니다.

코드는 아래와 같습니다.

import { useCallback, useRef } from "react";

function useLatest<T>(value: T) {
  const ref = useRef(value);
  ref.current = value;
  return ref;
}

export function useSequentialRequest<Args extends unknown[], Data>(
  requestFn: (signal: AbortSignal, ...args: Args) => Promise<Data>
) {
  const requestFnRef = useLatest(requestFn);

  const running = useRef(false);
  const abortController = useRef<AbortController | null>(null);

  return useCallback(
    async (...args: Args) => {
      if (running.current) {
        abortController.current?.abort();
        abortController.current = null;
      }

      running.current = true;

      const controller = abortController.current ?? new AbortController();
      abortController.current = controller;

      return requestFnRef.current(controller.signal, ...args).finally(() => {
        if (controller === abortController.current) {
          running.current = false;
        }
      });
    },
    [requestFnRef]
  );
}

경쟁 상태(race condition)을 방지 하기 위해 finally 블록에서 현재 참조하고 있는 controllerabortController.current와 같은지 확인해야 합니다. 이렇게 하면 현재 진행중인 요청이 완료되었을 때만 상태가 업데이트 되도록 보장할 수 있습니다. 반대로 같지 않으면 finally 블록이 취소된 요청이라는 뜻이므로 running.current 상태를 수정해서는 안됩니다.

사용 방법은 다음과 같습니다.

import { useState } from "react";
import { useSequentialRequest } from "./useSequentialRequest";

export default function Home() {
  const [data, setData] = useState("");

  const run = useSequentialRequest(async (signal: AbortSignal, query: string) =>
    fetch(`/api/hello?query=${query}`, { signal }).then((res) => res.text())
  );

  const handleInput = async (queryStr: string) => {
    try {
      const res = await run(queryStr);
      setData(res);
    } catch {
      // ignore
    }
  };

  return (
    <>
      <input
        placeholder="Please input"
        onChange={(e) => {
          handleInput(e.target.value);
        }}
      />
      <div>Response Data: {data}</div>
    </>
  );
}

직접 구현을 확인해보실 수 있습니다. 인풋을 빠르게 입력해보시면 항상 최신 응답을 유지하면서 이전 요청을 취소하는 것을 확인하실 수 있습니다.

image

post-custom-banner

0개의 댓글