Promise.withResolvers
와 AbortController
를 사용하여 자바스크립트에서 취소 가능한 작업 만들기
원문: https://webdeveloper.beehiiv.com/p/cancel-promises-javascript
자바스크립트에서 요청을 취소하는 방법은 이미 알고 계실 것입니다. XHR은 xhr.abort()
를, fetch는 signal
을 사용하면 됩니다. 하지만 일반적인 프로미스는 어떻게 취소할 수 있을까요?
현재 자바스크립트의 프로미스는 일반적인 프로미스를 취소하는 API를 제공하지 않습니다. 따라서 이 글에서는 프로미스의 결과를 폐기 또는 무시하는 방법을 설명해보려 합니다.
이제 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 이내에 취소하므로 다음과 같은 결과를 보실 수 있습니다.
정말 작업을 취소했다기 보다, 조기 거부(early rejection)를 수행했다는 점을 알아두세요. 원래의 asyncFn()
은 이행되거나 거부될 때까지 계속 실행되나 Promise.withResolvers<T>()
로 생성된 프로미스는 이미 거부되었기 때문에 영향이 없는 것입니다.
페칭 요청을 취소하는 것과 비슷하게 리스너를 통해 조기 거부하도록 구현할 수 있습니다. 다음과 같습니다.
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>;
}
이렇게 하면 버튼을 빠르게 여러 번 클릭했을 때 이전 요청은 취소되고 최신 요청 데이터만 가져올 수 있습니다.
좀더 일반적인 상황의 순차적 요청을 처리하는 리액트 훅이 필요하다면, 위의 예제에는 여전히 개선의 여지가 있습니다.
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
블록에서 현재 참조하고 있는 controller
가 abortController.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>
</>
);
}
직접 구현을 확인해보실 수 있습니다. 인풋을 빠르게 입력해보시면 항상 최신 응답을 유지하면서 이전 요청을 취소하는 것을 확인하실 수 있습니다.