취소한 줄 알았던 fetch가 계속 살아 있어서 응답이 뒤늦게 도착한 이유
문제 발생
검색어를 빠르게 입력할 때마다 요청이 쌓여서, 늦게 도착한 예전 응답이 최신 결과를 덮어썼습니다. 타임아웃도 걸어 뒀는데 서버 로그에는 요청이 끝까지 처리된 흔적이 남았습니다.
const withTimeout = (promise, ms) =>
Promise.race([promise, new Promise((_, r) => setTimeout(() => r(new Error("timeout")), ms))]);
await withTimeout(fetch(url), 3000); // 요청 자체는 계속 진행된다원인 분석
프로미스는 취소할 수 없습니다. Promise.race는 "둘 중 먼저 끝난 쪽의 결과를 쓴다"일 뿐, 진 쪽을 중단시키지 않습니다. 그래서 위 코드는 기다리기를 그만둘 뿐 네트워크 요청은 그대로 살아 있습니다. 서버는 계속 일하고, 대역폭도 계속 쓰고, 응답이 도착하면 그때부터의 처리도 그대로 실행됩니다.
AbortController는 이 문제를 위한 표준 도구입니다. signal을 전달받은 쪽이 실제로 작업을 중단합니다. fetch는 이 신호를 이해하므로 진행 중인 요청을 끊고 AbortError로 거부합니다.
해결 방안
- 요청마다 컨트롤러를 만들고, 새 요청 전에 이전 것을 끊습니다.
let controller;
async function search(query) {
controller?.abort(); // 이전 요청 취소
controller = new AbortController();
try {
const res = await fetch(`/search?q=${encodeURIComponent(query)}`, {
signal: controller.signal,
});
return await res.json();
} catch (error) {
if (error.name === "AbortError") return; // 취소는 오류가 아니다
throw error;
}
}- 타임아웃은
AbortSignal.timeout()한 줄이면 됩니다. 직접setTimeout으로 만들 필요가 없습니다.
await fetch(url, { signal: AbortSignal.timeout(3000) });- 취소와 타임아웃을 함께 쓰려면 신호를 합칩니다.
const signal = AbortSignal.any([controller.signal, AbortSignal.timeout(3000)]);
await fetch(url, { signal });AbortError를 실패로 보고하지 않습니다. 사용자가 화면을 떠나서 취소된 것까지 오류 로그에 쌓이면 실제 장애를 가립니다. 취소는 정상 흐름입니다.- 컴포넌트가 사라질 때도 끊습니다. React라면 effect 정리 함수에서
abort()를 호출합니다 — 이미 언마운트된 컴포넌트에 상태를 쓰는 문제까지 함께 없어집니다.
useEffect(() => {
const controller = new AbortController();
load({ signal: controller.signal });
return () => controller.abort();
}, [query]);fetch외에도 쓸 수 있습니다.addEventListener의signal옵션,Web Streams, 그리고 직접 만든 비동기 함수에도 같은 신호를 넘겨 중단 지점을 만들 수 있습니다.
댓글0
댓글을 남기려면 로그인이 필요해요. 로그인
아직 댓글이 없어요. 첫 의견을 편하게 남겨 보세요.