AbortController 타이핑
AbortController와 AbortSignal을 TypeScript로 다루는 법을 정리합니다. controller·signal의 타입, signal을 함수 옵션으로 받는 시그니처, AbortError를 catch에서 좁혀 구분하기, AbortSignal.timeout과 any 조합, 이벤트 리스너 자동 해제 패턴까지 실무 관점으로 다룹니다.
지난 글에서 비동기 값의 연속을 다루는 이터레이터를 봤다. 비동기 작업을 시작하는 법은 충분히 다뤘으니, 이번에는 그 반대 — 시작한 작업을 취소하는 법을 타이핑한다. 표준 도구는 AbortController다. fetch 요청 취소, 이벤트 리스너 정리, 타임아웃까지 하나의 인터페이스로 묶는데, TypeScript의 DOM·Node 타입에 이미 잘 정의되어 있어 타이핑은 비교적 매끄럽다. 다만 취소 에러를 구분하는 부분에 우리가 그동안 다룬 좁히기가 다시 등장한다.
AbortController와 AbortSignal
AbortController는 두 가지를 제공한다. 읽기 전용 핸들인 signal(타입 AbortSignal)과, 취소를 발동하는 abort() 메서드다. 작업을 시작하는 쪽에 signal을 건네주고, 취소하고 싶을 때 abort()를 부른다.
const controller = new AbortController();
const signal: AbortSignal = controller.signal;
// 어딘가에서 취소
controller.abort();
console.log(signal.aborted); // true
controller의 타입은 AbortController, signal은 AbortSignal로 모두 표준 라이브러리에 정의돼 있다. 핵심 설계는 권한 분리다. signal을 받은 쪽은 취소를 관찰만 할 수 있고, 취소를 발동하는 권한은 controller를 쥔 쪽에만 있다.
abort() 한 번이 그 signal을 구독하는 모든 소비처 — fetch, 이벤트 리스너, 커스텀 비동기 작업 — 에 동시에 전파된다.
signal을 함수 옵션으로 받기
취소 가능한 함수를 직접 만들 때는, 옵션 객체에 signal?: AbortSignal을 받는 게 표준 관용구다. fetch나 Node API들도 모두 이 형태를 따른다.
async function fetchUser(
id: number,
options?: { signal?: AbortSignal }
): Promise<User> {
const res = await fetch(`/api/user/${id}`, { signal: options?.signal });
return res.json();
}
const controller = new AbortController();
const promise = fetchUser(1, { signal: controller.signal });
// 필요하면 controller.abort()
signal을 옵셔널로 두면 호출하는 쪽이 취소가 필요할 때만 넘긴다. fetch의 두 번째 인자 RequestInit이 이미 signal?: AbortSignal | null 필드를 갖고 있어, 그대로 전달하면 타입이 맞아떨어진다.
취소 에러 구분하기 — catch는 unknown에서
signal로 취소된 fetch는 그냥 실패하지 않는다. AbortError라는 이름의 DOMException으로 reject된다. 문제는 지난 Promise 글에서 강조했듯, catch로 받는 e가 unknown이라는 점이다. 취소인지 진짜 네트워크 실패인지 구분하려면 좁혀야 한다.
try {
const user = await fetchUser(1, { signal });
} catch (e) {
if (e instanceof Error && e.name === "AbortError") {
// 사용자가 의도적으로 취소함 — 조용히 무시
return;
}
// 그 외는 진짜 에러 — 화면에 표시하거나 재시도
throw e;
}
e instanceof Error로 먼저 Error임을 보장한 뒤 e.name === "AbortError"로 취소를 가려낸다. instanceof 좁히기의 전형적인 응용이다. 취소를 에러로 취급해 사용자에게 보여주는 실수가 흔한데, 이 분기 하나로 막는다.
AbortSignal의 정적 헬퍼들
최신 환경에는 AbortSignal에 편리한 정적 메서드가 있고, 타입도 따라온다. AbortSignal.timeout(ms)은 일정 시간 후 자동으로 취소되는 signal을, AbortSignal.any([...])는 여러 signal 중 하나라도 취소되면 함께 취소되는 signal을 만든다.
// 5초 타임아웃
const res = await fetch("/api/slow", { signal: AbortSignal.timeout(5000) });
// 사용자 취소 OR 타임아웃 — 둘 중 먼저
const userController = new AbortController();
const combined: AbortSignal = AbortSignal.any([
userController.signal,
AbortSignal.timeout(5000),
]);
AbortSignal.timeout은 AbortSignal을, AbortSignal.any는 입력 배열을 받아 AbortSignal을 반환한다고 타입에 정의돼 있다. 타임아웃으로 취소되면 e.name이 "TimeoutError"라 위의 분기에서 AbortError와 또 구분할 수 있다.
이벤트 리스너 자동 해제
AbortController의 또 다른 깔끔한 용도는 이벤트 리스너 정리다. addEventListener의 옵션에 signal을 넘기면, abort() 시 리스너가 자동으로 제거된다. 여러 리스너를 등록해도 abort() 한 번으로 전부 해제되니, React useEffect의 cleanup 등에서 유용하다.
function setupListeners(signal: AbortSignal) {
window.addEventListener("resize", onResize, { signal });
window.addEventListener("scroll", onScroll, { signal });
// controller.abort() 한 번으로 둘 다 해제
}
addEventListener의 옵션 타입 AddEventListenerOptions에 signal?: AbortSignal 필드가 있어 그대로 들어맞는다. removeEventListener를 일일이 짝지어 부를 필요가 없어진다.
정리하면, AbortController는 signal(관찰용)과 abort()(발동용)로 권한을 나눈 취소 표준이다. 함수는 signal?: AbortSignal을 옵션으로 받고, 취소 에러는 catch에서 e.name === "AbortError"로 좁혀 구분한다. AbortSignal.timeout/any로 타임아웃과 다중 취소를, 이벤트 리스너 옵션의 signal로 자동 해제를 얻는다. 다음 글에서는 이렇게 만든 코드를 검증하는 테스트의 세계로 넘어가, Vitest와 Jest를 타이핑한다.
지난 글: 비동기 이터레이터 타이핑
다음 글: Vitest와 Jest 타이핑
읽어주셔서 감사합니다. 😊