fetch에는 타임아웃 옵션이 없다. 네트워크가 느리거나 서버가 응답을 안 주면 요청이 그냥 매달려 있는다. 로딩 스피너가 30초씩 돌아가는 화면을 만들고 싶지 않으면 직접 끊어줘야 한다.
예전엔 AbortController를 만들고 setTimeout으로 취소를 걸었다.
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 5000);
try {
const res = await fetch('/api/data', { signal: controller.signal });
return await res.json();
} finally {
clearTimeout(timer); // 이걸 빼먹으면 타이머가 남는다
}
동작은 하는데 컨트롤러 만들고, 타이머 걸고, finally에서 clearTimeout까지 챙겨야 한다. 이 정리를 빼먹으면 요청이 성공해도 타이머가 5초 동안 살아 있다.
AbortSignal.timeout()
AbortSignal.timeout(ms)는 지정한 시간 뒤에 자동으로 abort되는 signal을 바로 만들어준다. 컨트롤러도, 타이머 정리도 필요 없다.
try {
const res = await fetch('/api/data', {
signal: AbortSignal.timeout(5000),
});
return await res.json();
} catch (err) {
if (err.name === 'TimeoutError') {
console.log('5초 안에 응답이 없었음');
}
throw err;
}
타임아웃으로 끊기면 err.name이 TimeoutError로 온다. 사용자가 직접 취소한 AbortError와 구분되기 때문에, "시간 초과"와 "취소됨"을 다른 메시지로 보여줄 수 있다.
AbortSignal.any()로 신호 합치기
타임아웃도 걸고 싶은데, 컴포넌트가 언마운트되면 사용자 취소도 걸고 싶을 때가 있다. 이럴 때 AbortSignal.any()로 여러 signal을 하나로 묶는다. 둘 중 하나라도 abort되면 요청이 끊긴다.
function fetchWithTimeout(url, { signal, timeout = 5000 } = {}) {
const signals = [AbortSignal.timeout(timeout)];
if (signal) signals.push(signal);
return fetch(url, { signal: AbortSignal.any(signals) });
}
React에서 쓰면 이런 모양이 된다.
useEffect(() => {
const controller = new AbortController();
fetchWithTimeout('/api/data', { signal: controller.signal })
.then((res) => res.json())
.then(setData)
.catch((err) => {
if (err.name !== 'AbortError') setError(err);
});
return () => controller.abort();
}, []);
언마운트되면 controller.abort()가 걸리고, 5초가 지나면 타임아웃이 걸린다. 두 신호를 따로 관리할 필요 없이 any()가 알아서 먼저 도착한 쪽으로 끊는다.
정리
AbortSignal.timeout()과 AbortSignal.any()는 지금 모든 최신 브라우저와 Node 18+에서 쓸 수 있다(Baseline). fetch 타임아웃 유틸을 직접 만들거나 axios의 timeout 옵션을 쓰려고 라이브러리를 붙이던 걸, 이제 플랫폼 기능만으로 끝낼 수 있다. setTimeout/clearTimeout 짝을 손으로 맞추다 타이머를 흘리는 실수도 자연스럽게 사라진다.