검색 요청에 타임아웃을 걸고 싶은데, 동시에 사용자가 "취소" 버튼도 누를 수 있어야 한다. 중단 사유가 두 개인 셈이다.
예전엔 AbortController 하나를 만들고 거기에 setTimeout으로 abort()를 걸고, 취소 버튼에서도 같은 컨트롤러를 abort() 했다. 신호가 한 곳에 섞이니 "타임아웃이었나, 사용자가 눌렀나"를 나중에 구분할 수가 없다.
신호를 합치는 AbortSignal.any()
AbortSignal.any([signal1, signal2, ...])는 입력 신호 중 하나라도 중단되면 함께 중단되는 새 신호를 만든다. 각 사유를 독립된 신호로 두고 묶기만 하면 된다.
function createSearchController() {
// 사용자가 직접 취소할 수 있는 신호
const userCancel = new AbortController();
// 10초 타임아웃 신호
const timeout = AbortSignal.timeout(10_000);
// 둘 중 하나라도 발동하면 중단되는 합친 신호
const signal = AbortSignal.any([userCancel.signal, timeout]);
return { signal, cancel: () => userCancel.abort() };
}
사용하기
중요한 건 중단 사유를 구분할 수 있다는 점이다. 타임아웃이 발동하면 TimeoutError, 사용자가 abort()를 호출하면 AbortError로 던져진다.
const { signal, cancel } = createSearchController();
cancelButton.onclick = cancel;
try {
const res = await fetch(`/api/search?q=${query}`, { signal });
render(await res.json());
} catch (err) {
if (err.name === 'TimeoutError') {
showMessage('검색이 너무 오래 걸립니다. 잠시 후 다시 시도하세요.');
} else if (err.name === 'AbortError') {
// 사용자가 직접 취소 — 조용히 무시
} else {
throw err;
}
}
React에서
useEffect cleanup과 묶으면 언마운트 시 중단까지 자연스럽게 처리된다.
useEffect(() => {
const controller = new AbortController();
const signal = AbortSignal.any([
controller.signal,
AbortSignal.timeout(8_000),
]);
fetch(url, { signal })
.then((r) => r.json())
.then(setData)
.catch((e) => {
if (e.name !== 'AbortError' && e.name !== 'TimeoutError') setError(e);
});
return () => controller.abort(); // 언마운트 시 중단
}, [url]);
한 가지 주의점. any()로 만든 신호는 입력 신호 중 하나라도 이미 중단된 상태면 즉시 중단된다. 그리고 합친 신호는 입력 신호들을 약하게 참조하므로 메모리 누수 걱정은 하지 않아도 된다.
중단 사유가 여러 개일 때 AbortController 하나에 억지로 몰아넣지 말자. 각 사유를 독립된 신호로 만들고 AbortSignal.any()로 묶으면, 코드도 깔끔해지고 어느 쪽이 발동했는지도 에러 타입으로 바로 구분된다.