본문으로 건너뛰기
2026. 7. 21

취소는 에러인가요? 비동기 에러의 최후 방어선과 AbortController

관측성 대시보드(Sentry)를 열었더니 상위 에러 목록의 절반이 AbortError: The operation was aborted 였습니다. 사용자가 페이지를 빠르게 옮겨다니면서 진행 중이던 fetch가 취소된 것뿐인데, 이 "정상적인 취소"가 진짜 장애와 똑같은 취급을 받으며 알림을 울리고 있었습니다.

취소하려고 AbortController를 붙였을 뿐인데, 왜 그 결과가 에러 리포트에 쌓이는 걸까요. 거슬러 올라가 보니 궁금증은 두 개의 질문으로 모였습니다.

취소는 에러일까요? 그리고 우리가 미처 처리하지 못한 에러는 대체 어디로 흘러가는 걸까요?


이 글에서 다루는 내용

우리가 짜는 에러 처리는 대부분 try/catch.catch()라는 그물로 이뤄집니다. 그런데 콜 스택이 바뀌거나 Promise를 기다리지 않으면 이 그물과 에러가 서로 만나지 못합니다. 이 글은 그렇게 새어나간 에러를 런타임이 어디에서 관측하는지부터 정리합니다. 브라우저에는 error·unhandledrejection이 있고, Node.js에는 uncaughtException·unhandledRejection이 있습니다.

그다음 AbortController를 깊이 들여다봅니다. 취소를 다루는 이 표준이 왜 하필 앞의 "비동기 에러 채널"을 타고 흐르는지, 그래서 정상적인 취소가 어떻게 진짜 장애로 오인되는지, 그리고 이 둘을 구분해 최후 방어선을 설계하는 실무 패턴까지 이어집니다. 처음 접하는 분도 흐름을 따라올 수 있도록 예제와 함께 직관적으로 설명하겠습니다.





서론: 에러는 어떻게 새어나가는가

에러 처리라고 하면 위험해 보이는 코드를 try로 감싸거나 Promise 뒤에 .catch()를 붙이는 모습을 떠올립니다. 여기서 먼저 바로잡을 것이 있습니다. try/catch가 비동기 에러를 전혀 못 잡는 것은 아닙니다. 같은 try 안에서 await한 Promise의 거부는 잡지만, 현재 콜 스택과 연결되지 않은 콜백이나 기다리지 않은 Promise는 잡지 못합니다.


try/catch가 잡지 못하는 에러

가장 흔한 오해부터 보겠습니다. 아래 코드에서 바깥 catch 블록은 실행되지 않습니다.

try {
setTimeout(() => {
throw new Error("1초 뒤에 터집니다");
}, 1000);
} catch (error) {
// 절대 실행되지 않는다.
// setTimeout의 콜백은 1초 뒤 '비어 있는' 콜 스택에서 실행되고,
// 그때 이 try 블록은 이미 사라진 지 오래다.
console.log("여기서 잡힐 거라 기대하지만…", error);
}

try/catch콜 스택을 따라 전파되는 예외를 잡습니다. 그런데 setTimeout의 콜백이 실행될 때는 바깥 try가 이미 끝난 뒤입니다. 콜백 안에서 던진 예외가 올라가도 앞서 사라진 try를 다시 만날 수 없습니다. 이벤트 핸들러, setInterval, requestAnimationFrame 콜백을 바깥 try에 넣었을 때도 같습니다.

비동기의 또 다른 얼굴인 Promise도 마찬가지입니다.

async function loadUser() {
throw new Error("네트워크 실패");
}

// await도 .catch()도 없이 그냥 호출만 한다.
loadUser(); // 이 거부(rejection)는 아무도 받지 않는다.

async 함수에서 던진 에러는 호출자에게 곧바로 튀어나오지 않고 거부된 Promise(rejected promise)에 담깁니다. 그 Promise를 await 하지도, .catch()를 붙이지도 않은 채 호출하면 거부를 받을 코드가 없습니다. 이런 Promise를 떠다니는 프로미스(floating promise)라고 부릅니다.

반대로 await로 연결하면 평범한 try/catch가 다시 작동합니다.

try {
await loadUser();
} catch (error) {
// await가 Promise의 거부를 다시 throw하므로 여기에서 잡힌다.
console.error(error);
}

그물을 빠져나간 에러는 어디로 가는가

이렇게 새어나간 에러가 조용히 사라지는 것은 아닙니다. 호스트 환경은 애플리케이션 코드가 처리하지 않은 예외와 Promise 거부를 전역에서 관측할 기회를 줍니다. 이 글에서는 그 자리를 최후 착지점이라고 부르겠습니다. 다만 이곳은 원래 작업으로 되돌아가 복구하는 catch가 아니라, 놓친 실패를 기록하고 종료 정책을 적용하는 관측 지점입니다.

  • 동기(그리고 콜백)의 세계: 콜 스택을 타고 올라가다 아무 catch도 못 만난 예외. 이건 uncaught 에러가 됩니다.
  • 비동기 Promise의 세계: 호스트가 처리 여부를 확인할 때까지 거부 핸들러가 연결되지 않은 Promise. 이건 unhandled rejection이 됩니다.

플랫폼별로는 이렇게 대응됩니다.

세계브라우저Node.js
동기 & 콜백error 이벤트 (window.onerror)process.on('uncaughtException')
비동기 Promiseunhandledrejection 이벤트process.on('unhandledRejection')

이 네 개의 착지점이 이 글의 무대입니다. 그리고 미리 한 가지를 흘려두자면, 뒤에서 다룰 AbortController의 취소는 표의 아래쪽 행, 즉 비동기 Promise의 거부 채널을 그대로 타고 흐릅니다. 취소가 에러 취급을 받는 지점이 바로 여기입니다.


Recap

try/catch는 현재 콜 스택의 예외와 같은 블록에서 await한 Promise 거부를 잡습니다. 나중에 실행되는 콜백의 예외와 아무도 기다리지 않는 Promise 거부는 그 범위를 벗어납니다. 애플리케이션이 놓친 둘은 각각 error·uncaughtExceptionunhandledrejection·unhandledRejection에서 마지막으로 관측됩니다.




동기 에러의 최후 착지점: uncaught error

콜 스택을 끝까지 타고 올라간 예외가 아무 catch도 만나지 못하면 브라우저는 전역 error 이벤트를 발생시킵니다. 이것이 동기 세계의 최후 착지점입니다.


window.onerror 와 addEventListener('error')

이 착지점에 접근하는 방법은 두 가지입니다. 오래된 window.onerror 프로퍼티와, 표준 이벤트 리스너 방식입니다.

// 방법 1: 기존 onerror 프로퍼티.
// 인자가 풀어 헤쳐진(positional) 형태로 넘어온다.
window.onerror = (message, source, lineno, colno, error) => {
console.log("잡힌 메시지:", message);
console.log("실제 에러 객체:", error); // 스택 추적은 여기에 있다
return true; // true를 반환하면 콘솔의 기본 에러 출력을 억제한다
};

// 방법 2: 표준 이벤트 리스너. ErrorEvent 객체 하나로 넘어온다.
window.addEventListener("error", (event) => {
console.log("메시지:", event.message);
console.log("파일:", event.filename, event.lineno, event.colno);
console.log("에러 객체:", event.error);
event.preventDefault(); // onerror의 `return true`에 해당
});

두 방법의 차이는 취향 이상입니다. onerror는 인자가 다섯 개로 풀어 헤쳐져 넘어오는 반면, addEventListener("error")ErrorEvent 객체 하나로 정보를 담아 줍니다. 기본 동작(콘솔에 빨간 에러를 찍는 것)을 막고 싶으면 onerror에서는 return true, 리스너에서는 event.preventDefault()를 씁니다.


버블링하지 않는 리소스 로딩 에러

error 이벤트에는 함정이 하나 있습니다. <img>, <script>, <link> 같은 요소가 리소스 로딩에 실패했을 때도 error 이벤트가 나지만, 이 이벤트는 버블링하지 않습니다(does not bubble). 그래서 window에 그냥 리스너를 달아 두면 이런 로딩 실패는 잡히지 않습니다. 캡처 단계(capture phase)에서 가로채야 합니다.

// 세 번째 인자 true(캡처 단계)가 없으면
// 이미지·스크립트 로딩 실패는 window까지 올라오지 못한다.
window.addEventListener(
"error",
(event) => {
if (event.target instanceof Element) {
console.log("리소스 로딩 실패:", event.target);
}
},
true, // 캡처 단계에서 가로챈다
);
정보

스크립트가 다른 출처(cross-origin)에서 로드됐고 CORS 헤더가 없으면 onerror의 메시지는 보안상 "Script error."로만 넘어오고 상세 정보가 가려집니다. 외부 도메인의 스크립트까지 관측하려면 <script>crossorigin 속성을 붙이고 서버가 적절한 CORS 헤더를 응답해야 합니다.


Node.js의 uncaughtException

Node에는 대응하는 이벤트가 process에 있습니다. 관측만 필요하다면 uncaughtException보다 uncaughtExceptionMonitor가 안전합니다. 모니터는 기본 종료 동작을 바꾸지 않습니다.

import fs from "node:fs";
import process from "node:process";

process.on("uncaughtExceptionMonitor", (error, origin) => {
// origin은 "uncaughtException" 또는 "unhandledRejection"
fs.writeSync(process.stderr.fd, `${origin}: ${error.stack ?? error}\n`);
// 리스너가 끝나면 Node의 기본 동작대로 프로세스가 종료된다.
});

브라우저와 결정적으로 다른 점은 Node 공식 문서가 uncaughtException복구 수단으로 쓰지 말라고 못 박는다는 것입니다. 예외가 여기까지 올라왔다는 건 애플리케이션이 정의되지 않은 상태(undefined state)에 놓였을 수 있다는 뜻입니다. 특히 uncaughtException 리스너를 직접 달면 Node의 기본 종료 동작을 가로채므로, 종료 코드도 애플리케이션이 책임져야 합니다. 꼭 써야 한다면 동기식 정리만 하고 종료하며 재시작은 별도 프로세스 매니저에 맡깁니다. 비동기 로그 전송이 끝나기를 기대하며 프로세스를 계속 살려두는 자리도 아닙니다.


Recap

콜 스택을 끝까지 통과한 동기 예외는 브라우저의 전역 error 이벤트로 착지합니다. window.onerror에서는 return true, addEventListener("error")에서는 preventDefault()로 기본 보고를 억제할 수 있습니다. 버블링하지 않는 리소스 로딩 실패까지 보려면 캡처 단계 리스너가 필요합니다. Node에서는 uncaughtExceptionMonitor로 기본 종료를 바꾸지 않은 채 기록하고, uncaughtException을 복구 수단으로 사용하지 않습니다.




비동기 에러의 최후 착지점: unhandledrejection

이제 Promise의 세계입니다. 호스트는 Promise가 거부되는 순간 바로 "처리되지 않았다"고 확정하지 않습니다. 이미 이어진 Promise 반응과 마이크로태스크가 실행될 시간을 준 뒤에도 거부 핸들러가 없을 때 전역 보고 대상으로 올립니다. 브라우저에서는 unhandledrejection 이벤트가 발생하고, 앞 절의 떠다니는 프로미스가 이곳에 도착합니다. Node.js 문서는 이 기준을 "이벤트 루프 한 턴 안에 핸들러가 연결되지 않은 경우"라고 설명합니다.


브라우저의 unhandledrejection

window.addEventListener("unhandledrejection", (event) => {
// event.reason: Promise를 거부시킨 값 (보통 Error 객체)
// event.promise: 거부된 Promise 자체
console.log("처리되지 않은 거부:", event.reason);
event.preventDefault(); // 콘솔 경고 출력을 억제한다
});

error 이벤트와 구조가 닮았습니다. event.reason에는 거부 사유가, event.promise에는 거부된 Promise가 담깁니다. JavaScript는 throw new Error(...)뿐 아니라 어떤 값으로도 Promise를 거부할 수 있으므로 reason이 항상 Error라는 보장은 없습니다. event.preventDefault()를 호출하면 브라우저의 기본 콘솔 보고를 억제합니다.

교차 출처 스크립트의 거부

거부 사유로 데이터가 새어 나가는 일을 막기 위해, 다른 출처(cross-origin)의 스크립트에서 시작된 Promise 거부는 unhandledrejection 이벤트를 발생시키지 않을 수 있습니다. 전역 리스너 하나가 모든 비동기 실패를 빠짐없이 본다고 가정하면 안 됩니다.


덜 알려진 짝, rejectionhandled

흥미로운 형제가 하나 더 있습니다. 거부된 Promise에 .catch()뒤늦게 붙는 경우입니다.

const p = Promise.reject(new Error("아무도 안 잡는 중"));

// 몇 초 뒤에야 핸들러를 붙인다면?
setTimeout(() => {
p.catch(() => console.log("이제야 잡았습니다"));
}, 3000);

이 경우 먼저 unhandledrejection이 발생하고, 3초 뒤 핸들러가 붙으면 rejectionhandled가 뒤따릅니다. 관측 도구는 두 이벤트를 짝지어 아직 처리되지 않은 Promise 목록을 갱신할 수 있습니다. 실무 코드에서 뒤늦은 .catch()를 권장한다는 뜻은 아닙니다. unhandledrejection이 영원한 판정이 아니라 특정 시점의 관측이라는 뜻입니다.


Node.js와 "크래시로 바뀐" 역사

Node에도 대응 이벤트가 있습니다.

process.on("unhandledRejection", (reason, promise) => {
console.error("처리되지 않은 거부:", reason);
});

여기엔 알아 둘 만한 역사가 있습니다. 예전 Node는 처리되지 않은 거부에 경고만 남기고 실행을 이어갔습니다. Node 15부터 기본 모드가 throw로 바뀌었고, 현재도 별도의 unhandledRejection 핸들러가 없다면 그 거부를 잡히지 않은 예외로 올려 프로세스를 종료합니다. 핸들러나 --unhandled-rejections 옵션으로 동작을 바꿀 수 있지만, 운영 정책에 기대기보다 거부를 발생 지점 가까이에서 처리하는 편이 안전합니다.


근본 원인은 떠다니는 프로미스

unhandledrejection은 대부분 "핸들러 붙이는 걸 깜빡한" 결과입니다. 그래서 애초에 떠다니는 프로미스를 만들지 않는 것이 최선의 예방입니다.

// 나쁨: 반환값을 아무도 기다리지 않는다.
saveDraft();

// 좋음: 명시적으로 기다리고 처리한다.
await saveDraft();

// 의도적으로 기다리지 않을 때: 처리를 붙였음을 명시한다.
void saveDraft().catch(reportError);

TypeScript를 쓴다면 @typescript-eslint/no-floating-promises 린트 규칙이 이 실수를 컴파일 단계에서 잡아 줍니다. "일부러 기다리지 않는 것"과 "깜빡하고 안 기다린 것"을 void 연산자로 구분해 두면, 최후 착지점까지 굴러떨어지는 거부를 원천에서 크게 줄일 수 있습니다.


Recap

거부된 Promise에 한 턴 안으로 핸들러가 연결되지 않으면 브라우저는 unhandledrejection을, Node는 unhandledRejection 이벤트를 발생시킵니다. 브라우저에서는 event.reason·event.promise로 사유에 접근하고 preventDefault()로 기본 보고를 막을 수 있으며, 뒤늦게 핸들러가 붙으면 rejectionhandled가 발생합니다. 현재 Node의 기본 모드에서는 별도 핸들러가 없는 처리되지 않은 거부가 잡히지 않은 예외로 승격됩니다. 근본 예방책은 await, void ...catch(), no-floating-promises 규칙으로 떠다니는 프로미스를 만들지 않는 것입니다.




AbortController: 취소를 다루는 표준

여기까지가 "새어나간 에러가 도착하는 곳"이었습니다. 이제 무대의 반대편, 취소를 다루는 표준 도구 AbortController를 봅니다. 취소가 왜 앞의 비동기 에러 채널과 만나는지는 다음 절에서 다루고, 이 절에서는 도구 자체를 충분히 깊게 익혀 두겠습니다.


신호를 보내는 컨트롤러와 신호를 받는 signal

AbortController는 역할이 둘로 나뉜 한 쌍의 객체입니다.

const controller = new AbortController();
const signal = controller.signal; // 읽기 전용 신호: 소비자에게 넘긴다

fetch("/api/data", { signal }) // signal을 구독하는 쪽
.then((res) => res.json())
.catch((error) => {
if (error.name === "AbortError") {
console.log("요청이 취소되었습니다");
}
});

controller.abort(); // 취소를 명령하는 쪽

controller.abort()명령을 내리는 쓰기 측이고, signal은 그 명령을 구독하는 읽기 측입니다. 이렇게 권한을 분리해 두었기 때문에, 취소를 명령할 권한(controller)은 나만 쥐고 signal만 여러 소비자에게 안전하게 나눠 줄 수 있습니다.

signal 자체는 EventTarget이라 abort 이벤트를 직접 구독할 수도 있고, 현재 상태를 동기적으로 물어볼 수도 있습니다.

signal.aborted; // 이미 취소됐는지 (boolean)
signal.reason; // 취소 사유 (아래에서 설명)
signal.throwIfAborted(); // 취소됐다면 signal.reason을 즉시 throw 한다
signal.addEventListener("abort", () => {
// 취소 순간에 정리 로직을 실행한다
});

여기에는 두 가지 계약이 숨어 있습니다. 첫째, signal은 한 번만 취소됩니다. abortedtrue가 된 뒤에는 되돌리거나 재사용할 수 없으므로 다음 작업에는 새 컨트롤러가 필요합니다. 둘째, abort()가 임의의 JavaScript 작업을 강제로 멈추지는 않습니다. fetch처럼 signal을 지원하는 API나 직접 만든 소비자가 이 신호를 관찰하고 중단해야 합니다.


abort()의 사유와 AbortError의 정체

abort()는 인자 없이 부를 수도, 사유(reason)를 담아 부를 수도 있습니다. 그리고 이 사유가 signal.reason이 됩니다.

controller.abort(); // 사유 없음 → signal.reason은 기본값인 AbortError DOMException
controller.abort(new Error("사용자가 페이지를 떠남")); // 사유를 직접 지정

인자 없이 취소할 때 생기는 기본 사유는 이름이 AbortErrorDOMException 입니다. 다만 abort(reason)에는 Error를 비롯한 임의의 값을 넣을 수 있습니다. 따라서 거부값의 이름만 보고 모든 취소를 분류하기보다 signal.abortedsignal.reason을 함께 보는 편이 정확합니다.

fetch는 취소되면 signal.reason으로 거부됩니다. 사유를 직접 지정했다면 거부값도 바로 그 객체이고, 생략했을 때만 기본 AbortError DOMException이 됩니다. 신호가 이미 취소된 상태에서 fetch를 시작해도 요청은 진행되지 않고 같은 사유로 거부됩니다.


timeout 과 any: 취소를 조합하기

최신 스펙은 컨트롤러를 손으로 만들지 않아도 되는 두 개의 정적 메서드를 제공합니다.

// 지정한 시간이 지나면 자동으로 취소되는 signal
const signal = AbortSignal.timeout(5000);
await fetch("/api/slow", { signal }); // 5초 안에 안 끝나면 취소

여기서 놓치기 쉬운 디테일이 있습니다. AbortSignal.timeout()이 만드는 사유는 AbortError가 아니라 TimeoutError DOMException입니다. 사용자 취소 신호와 조합해도 어떤 신호가 먼저 취소됐는지 구분할 수 있습니다.

여러 취소 조건을 하나로 묶고 싶을 때는 AbortSignal.any()를 씁니다.

const userCancel = new AbortController();

// 사용자가 취소하거나(userCancel), 8초가 지나거나(timeout)
// 둘 중 하나라도 발동하면 취소되는 결합 signal
const signal = AbortSignal.any([
userCancel.signal,
AbortSignal.timeout(8000),
]);

await fetch("/api/data", { signal });

결합된 signal은 입력 신호 중 가장 먼저 취소된 것의 사유를 그대로 물려받습니다. 그래서 위 코드에서 나중에 error.name을 보면 사용자가 끊었는지(AbortError) 타임아웃이었는지(TimeoutError)를 알 수 있습니다.

AbortSignal.any()AbortSignal.timeout()은 최신 브라우저에서는 널리 쓸 수 있지만 오래된 WebView와 런타임에는 없을 수 있습니다. 지원 범위를 확인하고, 필요하면 AbortControllersetTimeout으로 같은 정책을 구현합니다.


fetch 취소 그 너머: abort는 "정리 신호"다

AbortControllerfetch 취소용으로만 아는 경우가 많은데, 실은 훨씬 넓게 쓰입니다. 특히 addEventListenersignal 옵션을 사용하면 여러 리스너의 수명을 하나로 묶을 수 있습니다.

const controller = new AbortController();
const { signal } = controller;

// 여러 리스너에 같은 signal을 물려 둔다
window.addEventListener("resize", onResize, { signal });
window.addEventListener("scroll", onScroll, { signal });
document.addEventListener("keydown", onKeydown, { signal });

// 정리할 때: removeEventListener를 세 번 부를 필요 없이
controller.abort(); // 이 한 줄로 세 리스너가 한꺼번에 해제된다

abort는 단순히 "네트워크 요청을 취소한다"가 아니라, "이 작업 묶음은 이제 필요 없으니 정리(teardown)하라"는 신호입니다. fetch, 이벤트 리스너, 스트림처럼 signal을 이해하는 작업을 하나로 묶어 두면 정리 지점이 컨트롤러 하나로 모입니다.

직접 만드는 비동기 API도 같은 계약을 따를 수 있습니다. 시작할 때 이미 취소됐는지 확인하고, 작업 중에는 abort 이벤트를 한 번만 듣고, 끝났다면 리스너를 제거합니다.

function wait(ms, { signal } = {}) {
signal?.throwIfAborted();

return new Promise((resolve, reject) => {
const timer = setTimeout(finish, ms);

function finish() {
signal?.removeEventListener("abort", abort);
resolve();
}

function abort() {
clearTimeout(timer);
reject(signal.reason);
}

signal?.addEventListener("abort", abort, { once: true });
});
}

이 예제에서 빠지기 쉬운 줄은 첫 번째 throwIfAborted()입니다. 이미 취소된 signal에는 과거의 abort 이벤트가 다시 오지 않으므로, 이벤트 리스너만 붙이면 Promise가 영원히 끝나지 않을 수 있습니다.


Recap

AbortController는 취소를 명령하는 controller와 소비자에게 건네는 signal로 권한을 나눕니다. signal은 한 번 취소되면 재사용할 수 없고, 소비자가 신호를 관찰해야 실제 작업이 멈춥니다. 기본 사유는 AbortError, AbortSignal.timeout()의 사유는 TimeoutError이며 AbortSignal.any()는 먼저 취소된 신호의 사유를 물려받습니다. 직접 만든 API에서는 시작 시 throwIfAborted()와 작업 중 abort 리스너를 함께 구현해야 합니다.




교차점: 취소는 에러인가?

이제 앞의 세 절이 하나로 모입니다. 서두에서 던졌던 질문, "취소는 에러일까요?"에 답할 차례입니다.

취소는 Promise 관점에서는 거부로 흐릅니다. 그러나 제품 관점에서 정상적인 제어 흐름인지 운영 실패인지는 정책이 정합니다. 검색어가 바뀌어 이전 요청을 버리는 일과 결제 승인 요청이 타임아웃으로 끊기는 일을 같은 AbortError라는 이름만으로 묶을 수 없는 이유입니다.


취소는 에러 채널을 타고 흐른다

핵심은 이것입니다. fetchabort() 하면, 진행 중이던 그 fetch Promise가 거부됩니다. 취소는 별도의 조용한 경로가 아니라 실패와 같은 거부 채널을 공유합니다. 따라서 이 거부를 처리하지 않으면 앞에서 본 unhandledrejection으로 그대로 굴러떨어집니다.

const controller = new AbortController();
fetch("/api/data", { signal: controller.signal }); // .catch()가 없다!

controller.abort();
// → fetch가 AbortError로 거부됨
// → 아무도 안 잡음
// → unhandledrejection 발생 → 콘솔 경고, Sentry 알림…

서두에서 대시보드를 뒤덮었던 AbortError의 정체가 바로 이것입니다. 예상한 취소가 처리되지 않은 거부로 새어나가 진짜 장애처럼 보였습니다. 다만 취소가 언제나 정상이라는 뜻은 아닙니다. 검색어 변경으로 이전 요청을 버린 것은 정상 흐름이지만, 결제 승인 요청이 타임아웃으로 중단된 것은 운영상 실패일 수 있습니다. 거부의 이름만 보지 말고 누가 왜 취소했는지까지 분류해야 합니다.


정상 취소를 진짜 에러와 구분하기

error.name은 취소 계열을 알아보는 첫 단서일 뿐입니다. 앱이 예상한 취소에는 제품이 이해하는 사유를 넣고, 거부값이 실제 signal.reason과 같은지도 확인합니다. 이렇게 하면 네트워크 실패가 난 순간 우연히 signal도 취소된 경우를 정상 취소로 잘못 숨기지 않습니다.

class ExpectedCancellation extends Error {
constructor(message) {
super(message);
this.name = "ExpectedCancellation";
}
}

function isExpectedCancellation(error) {
return error instanceof ExpectedCancellation;
}

class RequestTimeoutError extends Error {
constructor(options) {
super("요청 시간이 초과되었습니다", options);
this.name = "RequestTimeoutError";
}
}

async function loadData(signal) {
try {
const res = await fetch("/api/data", { signal });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return await res.json();
} catch (error) {
// 호출자가 의도적으로 끊은 바로 그 사유만 정상 흐름으로 분류한다
if (
signal.aborted &&
error === signal.reason &&
isExpectedCancellation(error)
) {
return;
}

// 타임아웃은 별도 운영 실패다
if (
signal.aborted &&
error === signal.reason &&
error?.name === "TimeoutError"
) {
throw new RequestTimeoutError({ cause: error });
}

// 여기까지 왔다면 진짜 실패다: 위로 던진다
throw error;
}
}

호출자는 controller.abort(new ExpectedCancellation("화면을 떠남"))처럼 예상한 취소라는 타입과 맥락을 함께 넘깁니다. 이때 fetch의 거부 이름도 AbortError가 아니라 ExpectedCancellation이 됩니다. abort(reason)의 reason이 기본값을 대체하기 때문입니다. 호출부는 같은 객체만 정상 흐름으로 끝내고, TimeoutError는 별도의 운영 실패로 올립니다. 타임아웃 재시도는 요청이 멱등한지, 서버에서 이미 처리됐을 가능성은 없는지 확인한 뒤 결정해야 합니다.


실전: React에서의 경쟁 상태와 정리

이 패턴이 가장 빛나는 곳은 React의 데이터 페칭입니다. 검색어가 빠르게 바뀌는 상황을 생각해 봅시다. A를 검색하고 곧바로 B를 검색하면 느린 A의 응답이 빠른 B의 응답보다 늦게 도착해 최신 결과를 덮어쓰는 경쟁 상태(race condition)가 생깁니다.

useEffect(() => {
const controller = new AbortController();

async function search() {
try {
const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`, {
signal: controller.signal,
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);

const nextResults = await res.json();
if (!controller.signal.aborted) setResults(nextResults);
} catch (error) {
if (
controller.signal.aborted &&
error === controller.signal.reason &&
isExpectedCancellation(error)
) {
return;
}
setError(error);
}
}

void search();

// query가 바뀌거나 컴포넌트가 사라지면 이전 요청을 취소한다
return () =>
controller.abort(
new ExpectedCancellation("검색어가 변경되거나 화면이 정리됨"),
);
}, [query]);

cleanup 함수 하나가 두 가지 문제를 함께 다룹니다. 이전 요청을 중단하고, JSON 변환 뒤에도 signal.aborted를 확인해 낡은 결과가 최신 상태를 덮어쓰지 못하게 합니다. catch에서는 cleanup이 남긴 바로 그 사유만 걸러 unhandledrejection 노이즈를 막습니다. 앞에서 설명한 "정리 신호"가 여기서 그대로 구현됩니다.


Error Boundary가 잡지 못하는 것

React를 쓰다 보면 "그럼 Error Boundary가 다 잡아 주지 않나?" 하는 의문이 들 수 있습니다. 그렇지 않습니다. Error Boundary는 하위 컴포넌트 트리의 렌더링과 일부 생명주기에서 발생한 에러를 처리하지만, 다음은 관할 밖입니다.

  • 이벤트 핸들러 안에서 던진 에러 (onClick 콜백 등)
  • setTimeout·fetch 같은 비동기 코드에서 던진 에러
  • 서버 사이드 렌더링 중의 에러
  • Error Boundary 자기 자신에서 던진 에러

즉 이 글이 다룬 전역 착지점(error·unhandledrejection)과 Error Boundary는 보호 범위가 다릅니다. Error Boundary는 React 트리의 렌더 실패를 사용자에게 안전한 대체 UI로 전환하는 장치이고, 전역 핸들러는 그 밖에서 처리되지 않은 콜백·Promise 실패를 관측하는 마지막 장치입니다.


Recap

fetch를 취소하면 Promise가 거부되므로 취소는 실패와 같은 채널을 공유하고, 방치하면 unhandledrejection으로 새어나갑니다. 그 의미는 제품이 정합니다. 예상한 취소에는 전용 사유를 넣고 호출부에서 error === signal.reason까지 확인해야 타임아웃과 실제 실패를 숨기지 않습니다. React useEffect의 cleanup도 같은 규약으로 이전 요청과 낡은 결과를 정리할 수 있습니다.




최후 방어선을 설계하기

지금까지의 조각을 모아 실제 방어선을 어떻게 세울지 정리하겠습니다. 핵심 원칙은 하나입니다. 전역 핸들러는 복구가 아니라 관측과 우아한 종료를 위한 최후의 자리이고, 정상 취소는 그 자리에서 조용히 걸러 내야 한다는 것입니다.


전역 핸들러에서 정상 취소를 걸러 내기

서두의 문제, 대시보드를 뒤덮은 AbortError는 먼저 호출부에서 처리해야 합니다. 그래도 전역 필터가 필요하다면 이름만 보지 말고, 앱이 예상한 취소라고 표시한 사유만 제외합니다.

// 취소를 명령하는 쪽에서 제품이 이해하는 사유를 함께 남긴다
controller.abort(new ExpectedCancellation("검색어가 변경됨"));

window.addEventListener("unhandledrejection", (event) => {
const reason = event.reason;

// 앱 정책상 예상한 취소만 제외한다. 타임아웃은 여기서 버리지 않는다.
if (isExpectedCancellation(reason)) {
event.preventDefault();
return;
}

reportToObservability(reason);
});

window.addEventListener("error", (event) => {
reportToObservability(event.error ?? event.message);
});

관측성 도구를 쓴다면 도구의 전송 직전 훅에서 같은 필터를 거는 편이 더 깔끔합니다. Sentry라면 beforeSend입니다.

Sentry.init({
beforeSend(event, hint) {
const original = hint?.originalException;
// 앱에서 예상한 취소로 표시한 경우만 폐기한다
if (isExpectedCancellation(original)) {
return null;
}
return event;
},
});

전역 필터는 호출부 처리를 대신하지 않습니다. 이미 처리된 거부는 이곳까지 오지 않고, 여기 도착한 예상 취소는 어딘가에서 놓친 것입니다. 따라서 필터링과 별개로 카운터를 남기면 취소량의 급증이나 호출부 누락도 볼 수 있습니다.


설계 원칙 정리

지금까지의 내용을 실무 지침으로 압축하면 다음과 같습니다.

  • 취소는 거부 채널을 타고, 그 의미는 정책이 정합니다. 사용자 이탈처럼 예상한 취소와 타임아웃 같은 운영 실패를 이름만 보고 한데 묶지 않습니다.
  • 실제 처리는 최대한 호출부 가까이에서. try/catch.catch()로 그 자리에서 잡는 것이 최선이고, 전역 핸들러는 어디까지나 그물을 빠져나간 것을 위한 최후의 안전망입니다.
  • 전역 핸들러는 복구가 아니라 관측·종료용. 브라우저에서는 리포팅에, Node에서는 기본 종료를 유지한 동기식 기록에 씁니다.
  • abort는 취소이자 정리 신호. fetch·이벤트 리스너·구독을 하나의 signal로 묶으면 정리 지점이 한 줄로 수렴하고 누수가 줍니다.
  • 떠다니는 프로미스를 만들지 않는다. await·void ...catch()·no-floating-promises 린트로 애초에 최후 착지점까지 굴러가는 거부를 줄입니다.

마지막으로 이 글 전체를 한 장의 지도로 정리하면 이렇습니다.

구분동기 & 콜백비동기 Promise
최후 착지점 (브라우저)error 이벤트unhandledrejection 이벤트
최후 착지점 (Node)uncaughtExceptionunhandledRejection
억제 방법preventDefault() / return truepreventDefault()
취소가 흐르는 곳-AbortError / TimeoutError
호출부 처리try/catch.catch() / try + await

Recap

전역 핸들러에서는 앱이 만든 ExpectedCancellation만 리포팅에서 제외하고, TimeoutError를 비롯한 운영 실패는 남깁니다. 호출부는 signal.reason과의 동일성까지 확인해 우연히 겹친 실패를 숨기지 않습니다. 실제 처리는 호출부 가까이에서 하고, 전역 핸들러는 누락을 관측하는 최후의 안전망으로 둡니다.




References

전역 에러 이벤트 (브라우저)

  1. MDN: Window error event
  2. MDN: GlobalEventHandlers.onerror
  3. MDN: Window unhandledrejection event
  4. MDN: Window rejectionhandled event
  5. HTML Standard: Unhandled promise rejections

전역 에러 이벤트 (Node.js)

  1. Node.js process: uncaughtException
  2. Node.js process: unhandledRejection
  3. Node.js: --unhandled-rejections flag

AbortController / AbortSignal

  1. MDN: AbortController
  2. MDN: AbortSignal
  3. MDN: AbortSignal.timeout()
  4. MDN: AbortSignal.any()
  5. DOM Standard: AbortController & AbortSignal

에러 처리 패턴

  1. React Docs: Error Boundaries (react.dev)
  2. typescript-eslint: no-floating-promises
  3. Jake Archibald: Effective fetching & aborting


좋은 사람들과 재미있는 일을 하며 열정적이고 즐겁게 살고 싶은 개발자