AbortSignal.timeout
네트워크 요청을 보내면 항상 응답이 온다는 보장이 없다. 서버가 죽었거나 네트워크가 불안정하면 요청이 영원히 대기 상태에 빠질 수 있다. 이런 상황을 방지하려면 "일정 시간이 지나면 요청을 포기하는" 타임아웃 로직이 필요하다.
전통적으로 JavaScript에서 fetch 요청에 타임아웃을 거는 건 꽤 번거로운 작업이었다. AbortSignal.timeout()은 이 문제를 한 줄로 해결하기 위해 등장한 정적 메서드다.
타임아웃 없는 fetch의 문제
기본 fetch는 타임아웃 옵션이 없다. 서버가 응답하지 않으면 브라우저가 자체적으로 연결을 끊을 때까지(보통 수 분) 그냥 기다린다.
// 서버가 응답하지 않으면 아무 일도 일어나지 않는다
const response = await fetch("/api/data");
사용자 입장에서는 로딩 스피너가 영원히 돌아가는 것이고, 이건 끔찍한 UX다.
기존 방식: AbortController + setTimeout
AbortSignal.timeout()이 나오기 전에는 AbortController와 setTimeout을 직접 조합해야 했다.
async function fetchWithTimeout(url, timeoutMs) {
const controller = new AbortController();
const timeoutId = setTimeout(() => {
controller.abort();
}, timeoutMs);
try {
const response = await fetch(url, { signal: controller.signal });
return response;
} finally {
clearTimeout(timeoutId); // 응답이 오면 타이머 정리
}
}
동작은 하지만 몇 가지 문제가 있다.
- 보일러플레이트가 많다 — AbortController 생성, setTimeout 설정, clearTimeout 정리를 매번 해야 한다.
- 에러 타입이 모호하다 —
controller.abort()로 취소하면AbortError가 발생하는데, 사용자가 직접 취소한 건지 타임아웃인지 구분이 안 된다. - 정리 누락 위험 —
clearTimeout을 빼먹으면 이미 완료된 요청에 대해 abort가 호출될 수 있다.
AbortSignal.timeout()
AbortSignal.timeout()은 지정한 밀리초가 지나면 자동으로 중단(abort)되는 시그널을 생성한다.
const response = await fetch("/api/data", {
signal: AbortSignal.timeout(5000), // 5초 후 자동 중단
});
끝이다. AbortController 생성도 없고, setTimeout도 없고, clearTimeout 정리도 없다. 한 줄이면 된다.
핵심 특징
TimeoutError를 발생시킨다. 기존 controller.abort()는 AbortError(DOMException)를 던지지만, AbortSignal.timeout()은 TimeoutError(DOMException)를 던진다. 이 차이 덕분에 타임아웃과 사용자 취소를 구분할 수 있다.
try {
const response = await fetch("/api/data", {
signal: AbortSignal.timeout(5000),
});
} catch (error) {
if (error.name === "TimeoutError") {
console.log("요청 시간 초과");
} else if (error.name === "AbortError") {
console.log("사용자가 취소함");
} else {
console.log("네트워크 오류:", error.message);
}
}
active time 기준이다. 타이머는 "경과 시간"이 아니라 "활성 시간" 기준으로 동작한다. 브라우저 탭이 비활성화되거나 bfcache에 들어가면 타이머가 일시정지된다. 사용자가 탭을 떠났다가 돌아와도 남은 시간이 올바르게 적용되기 때문에 예상치 못한 타임아웃이 발생하지 않는다.
GC 친화적이다. 내부적으로 브라우저가 타이머 수명을 관리하므로 메모리 누수 걱정이 없다. setTimeout + AbortController 조합에서 발생할 수 있는 정리 누락 문제가 원천적으로 없다.
AbortSignal.any()와 조합하기
실제 앱에서는 타임아웃뿐 아니라 사용자가 직접 취소할 수도 있어야 한다. 예를 들어 파일 다운로드에 "취소" 버튼과 5분 타임아웃을 동시에 걸고 싶을 수 있다. 이때 AbortSignal.any()를 사용한다.
const userCancelController = new AbortController();
// 취소 버튼 클릭 시
cancelButton.addEventListener("click", () => {
userCancelController.abort();
});
// 타임아웃 시그널
const timeoutSignal = AbortSignal.timeout(5 * 60 * 1000); // 5분
// 둘 중 하나라도 발생하면 중단
const combinedSignal = AbortSignal.any([
timeoutSignal,
userCancelController.signal,
]);
try {
const response = await fetch("/api/large-file", {
signal: combinedSignal,
});
} catch (error) {
if (error.name === "TimeoutError") {
showToast("다운로드 시간이 초과되었습니다");
} else if (error.name === "AbortError") {
showToast("다운로드가 취소되었습니다");
}
}
AbortSignal.any()는 인자로 받은 시그널 중 하나라도 abort되면 자신도 abort된다. 결합된 시그널의 reason은 가장 먼저 abort된 시그널의 reason을 따르므로, TimeoutError와 AbortError를 정확히 구분할 수 있다.
fetch 이외의 활용
AbortSignal은 fetch 전용이 아니다. signal을 받는 모든 API에서 사용할 수 있다.
EventListener에 타임아웃 걸기
// 10초 안에 클릭하지 않으면 리스너 자동 해제
element.addEventListener("click", handler, {
signal: AbortSignal.timeout(10000),
});
addEventListener의 세 번째 인자로 signal을 전달하면, 시그널이 abort될 때 리스너가 자동으로 제거된다. removeEventListener를 직접 호출할 필요가 없다.
Node.js에서의 활용
Node.js 16+에서도 AbortSignal.timeout()을 지원한다. fs, stream, child_process 등 다양한 API에서 사용 가능하다.
import { readFile } from "fs/promises";
// 3초 안에 파일을 읽지 못하면 중단
const content = await readFile("/path/to/large-file", {
signal: AbortSignal.timeout(3000),
});
import { setTimeout as delay } from "timers/promises";
// AbortSignal로 취소 가능한 딜레이
await delay(5000, null, {
signal: AbortSignal.timeout(2000), // 2초 후 타임아웃
});
커스텀 비동기 함수에 적용
직접 만든 비동기 함수도 signal 파라미터를 받도록 설계하면 동일한 패턴을 사용할 수 있다.
async function pollStatus(url, intervalMs, signal) {
while (!signal?.aborted) {
const response = await fetch(url, { signal });
const data = await response.json();
if (data.status === "complete") {
return data;
}
// signal이 abort되면 이 대기도 취소된다
await new Promise((resolve, reject) => {
const timer = setTimeout(resolve, intervalMs);
signal?.addEventListener(
"abort",
() => {
clearTimeout(timer);
reject(signal.reason);
},
{ once: true }
);
});
}
throw signal.reason;
}
// 30초 타임아웃으로 폴링
const result = await pollStatus("/api/job/123", 2000, AbortSignal.timeout(30000));
주의사항
생성된 시그널은 재사용하지 않는다
AbortSignal.timeout()이 반환하는 시그널은 한 번 abort되면 되돌릴 수 없다. 반복 요청에 같은 시그널을 재사용하면 이미 abort된 시그널 때문에 즉시 실패한다.
// ❌ 잘못된 패턴
const signal = AbortSignal.timeout(5000);
// 첫 번째 요청이 5초 안에 완료돼도,
// 두 번째 요청 시점에 시그널이 이미 만료됐을 수 있다
const res1 = await fetch("/api/a", { signal });
const res2 = await fetch("/api/b", { signal }); // 남은 시간만큼만 적용됨
// ✅ 올바른 패턴: 요청마다 새 시그널 생성
const res1 = await fetch("/api/a", { signal: AbortSignal.timeout(5000) });
const res2 = await fetch("/api/b", { signal: AbortSignal.timeout(5000) });
AbortSignal.any()와 메모리 누수
AbortSignal.any()로 결합한 시그널은 내부적으로 원본 시그널들을 참조한다. 장시간 실행되는 루프에서 매번 새로운 AbortSignal.any()를 생성하면 이전 시그널들이 GC되지 못할 수 있다.
// ⚠️ 주의: 루프 안에서 any()를 반복 생성
while (true) {
const signal = AbortSignal.any([
AbortSignal.timeout(5000),
globalController.signal,
]);
await fetch("/api/poll", { signal });
await delay(1000);
}
이 경우 globalController가 살아 있는 한 매 반복마다 생성된 timeout 시그널이 globalController.signal의 abort 리스너로 등록되어 누적된다. 해결 방법은 각 반복에서 별도의 AbortController를 사용하고 완료 시 직접 abort하는 것이다.
while (true) {
const iterController = new AbortController();
const signal = AbortSignal.any([
AbortSignal.timeout(5000),
globalController.signal,
iterController.signal,
]);
try {
await fetch("/api/poll", { signal });
} finally {
iterController.abort(); // 이 반복의 리스너들을 정리
}
await delay(1000);
}
에러 핸들링 순서
AbortSignal.timeout()의 에러를 잡을 때는 TimeoutError → AbortError → 기타 순서로 분기하는 것이 좋다.
try {
await fetch(url, { signal: AbortSignal.timeout(5000) });
} catch (error) {
if (error instanceof DOMException) {
switch (error.name) {
case "TimeoutError":
// 타임아웃 처리
break;
case "AbortError":
// 사용자 취소 처리
break;
default:
throw error;
}
} else {
// TypeError (네트워크 오류) 등
throw error;
}
}
Node.js에서는 DOMException 대신 일반 Error 계열이 사용될 수 있으므로 error.name으로 비교하는 것이 더 안전하다.
브라우저 지원
AbortSignal.timeout()은 2022년 중반부터 모든 주요 브라우저에서 지원된다.
| 기능 | Chrome | Firefox | Safari | Node.js |
|---|---|---|---|---|
AbortSignal.timeout() | 103+ | 100+ | 16.4+ | 17.3+ |
AbortSignal.any() | 116+ | 124+ | 17.4+ | 20+ |
AbortSignal.any()는 상대적으로 최신이므로, 폴리필이 필요하다면 직접 구현할 수 있다.
// AbortSignal.any() 간이 폴리필
function abortSignalAny(signals) {
const controller = new AbortController();
for (const signal of signals) {
if (signal.aborted) {
controller.abort(signal.reason);
return controller.signal;
}
signal.addEventListener(
"abort",
() => controller.abort(signal.reason),
{ once: true, signal: controller.signal }
);
}
return controller.signal;
}
실전 유틸리티 패턴
타임아웃과 재시도를 조합한 fetch 래퍼를 만들면 프로젝트 전체에서 재사용할 수 있다.
async function resilientFetch(url, options = {}) {
const {
timeoutMs = 10000,
retries = 3,
retryDelayMs = 1000,
signal: externalSignal,
...fetchOptions
} = options;
for (let attempt = 0; attempt <= retries; attempt++) {
const signals = [AbortSignal.timeout(timeoutMs)];
if (externalSignal) signals.push(externalSignal);
const signal = AbortSignal.any(signals);
try {
const response = await fetch(url, { ...fetchOptions, signal });
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
return response;
} catch (error) {
const isLastAttempt = attempt === retries;
const isUserCancel =
error.name === "AbortError" && externalSignal?.aborted;
if (isUserCancel || isLastAttempt) {
throw error;
}
// 타임아웃이나 네트워크 오류면 재시도
console.warn(`Attempt ${attempt + 1} failed: ${error.message}`);
await new Promise((r) => setTimeout(r, retryDelayMs * (attempt + 1)));
}
}
}
// 사용 예시
const controller = new AbortController();
const data = await resilientFetch("/api/data", {
timeoutMs: 5000,
retries: 2,
signal: controller.signal,
});
이 패턴은 요청별 타임아웃, 외부 취소 신호, 자동 재시도를 모두 하나의 함수로 통합한다. AbortSignal.timeout()과 AbortSignal.any()가 없었다면 이 정도의 깔끔한 구현은 어려웠을 것이다.
정리
AbortSignal.timeout()은 AbortController + setTimeout 조합을 한 줄로 대체하며, TimeoutError로 타임아웃과 사용자 취소를 구분할 수 있다AbortSignal.any()와 조합하면 타임아웃 + 사용자 취소 + 외부 시그널을 하나의 signal로 합칠 수 있다- fetch 외에도 addEventListener, Node.js fs/stream, 커스텀 비동기 함수 등 signal을 받는 모든 API에서 활용 가능하다
관련 문서
- Canvas captureStream - captureStream과 AbortSignal 조합 활용
- Intersection Observer - 브라우저 네이티브 비동기 관찰 API
- Axios 인터셉터 - HTTP 클라이언트 레벨의 요청 제어