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

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

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

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

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


이 글에서 다루는 내용

우리가 짜는 에러 처리는 대부분 try/catch.catch()라는 그물로 이뤄집니다. 그런데 이 그물을 유유히 빠져나가는 에러가 있습니다. 이 글은 그렇게 새어나간 에러가 결국 도착하는 런타임의 최후 착지점(브라우저의 error·unhandledrejection, Node의 uncaughtException·unhandledRejection)을 먼저 정리합니다.

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





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

에러 처리라고 하면 우리는 보통 이런 그림을 떠올립니다. 위험해 보이는 코드를 try로 감싸고 catch로 받아내거나 Promise 뒤에 .catch()를 이어 붙이는 것입니다. 문제는 이 그물이 생각보다 촘촘하지 않다는 데 있습니다. 동기적으로, 지금 이 순간의 콜 스택에서 던져진 에러만 이 그물에 걸립니다.


try/catch가 잡지 못하는 에러

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

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

try/catch콜 스택을 따라 위로 전파되는 예외를 잡습니다. 그런데 setTimeout의 콜백은 1초 뒤 이벤트 루프가 완전히 새로운 콜 스택에서 실행합니다. 그 시점에 try 블록은 이미 실행을 마치고 스택에서 내려간 상태라 콜백이 던진 에러는 위로 전파될 try를 만나지 못합니다. 이벤트 핸들러 콜백, setInterval, requestAnimationFrame 안에서 던진 에러도 모두 같은 이유로 바깥 try/catch를 통과해 버립니다.

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

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

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

async 함수에서 던진 에러는 예외로 전파되는 대신 거부된 Promise(rejected promise)가 됩니다. 그런데 그 Promise를 await 하지도, .catch()를 붙이지도 않고 그냥 호출만 하면(이런 걸 떠다니는 프로미스(floating promise)라고 부릅니다) 거부를 받아낼 핸들러가 어디에도 없습니다.


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

여기서 핵심은 이렇게 새어나간 에러가 조용히 사라지지 않는다는 것입니다. 런타임에는 그물을 통과한 에러가 마지막으로 굴러떨어지는 최후 착지점(last resort handler)이 준비되어 있습니다. 그리고 이 착지점은 에러의 성격에 따라 두 갈래로 나뉩니다.

  • 동기(그리고 콜백)의 세계 — 콜 스택을 타고 올라가다 아무 catch도 못 만난 예외. 이건 uncaught 에러가 됩니다.
  • 비동기 Promise의 세계 — 마이크로태스크 큐에서 아무 핸들러도 못 만난 거부. 이건 unhandled rejection이 됩니다.

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

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

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


Recap

try/catch.catch()는 만능 그물이 아니라, 지금 이 콜 스택에서 던져진 동기 예외와 명시적으로 이어 붙인 Promise 거부만 잡습니다. setTimeout·이벤트 핸들러 콜백에서 던진 에러, 그리고 아무도 받지 않는 떠다니는 프로미스는 이 그물을 통과합니다. 통과한 에러는 사라지지 않고 런타임의 최후 착지점 — 동기는 uncaught error, 비동기는 unhandledrejection — 으로 굴러떨어집니다.




2. 동기 에러의 최후 착지점: 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 HTMLElement) {
console.log("리소스 로딩 실패:", event.target.src || event.target.href);
}
},
true // 캡처 단계에서 가로챈다
);
정보

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


Node.js의 uncaughtException

Node에는 대응하는 이벤트가 process에 있습니다.

process.on("uncaughtException", (error, origin) => {
// origin은 "uncaughtException" 또는 "unhandledRejection"
console.error("치명적:", error, "출처:", origin);
// 여기서 곧바로 프로세스를 되살리려 하면 안 된다.
cleanupAndFlushLogs();
process.exit(1); // 정리 후 반드시 종료한다
});

브라우저와 결정적으로 다른 점은 Node 공식 문서가 uncaughtException복구 수단으로 쓰지 말라고 못 박는다는 것입니다. 예외가 여기까지 올라왔다는 건 애플리케이션이 이미 정의되지 않은 상태(undefined state)에 빠졌다는 뜻입니다. 리소스를 정리하고 로그를 남긴 뒤 프로세스를 종료하고, 재시작은 프로세스 매니저(pm2, systemd 등)에 맡기는 것이 정석입니다. 이 착지점은 "복구"가 아니라 "우아하게 죽기 위한" 자리입니다.


Recap

콜 스택을 끝까지 통과한 동기 예외는 브라우저의 전역 error 이벤트로 착지합니다. window.onerror(인자 다섯 개, return true로 억제)와 addEventListener("error")(ErrorEvent 하나, preventDefault()로 억제) 중 하나로 접근하며, 버블링하지 않는 리소스 로딩 에러는 캡처 단계에서 잡아야 합니다. Node의 uncaughtException은 같은 역할이지만, 복구가 아니라 정리 후 종료를 위한 최후의 자리라는 점이 다릅니다.




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

이제 반대편, 비동기 Promise의 세계입니다. 거부된 Promise가 마이크로태스크 큐를 다 소진할 때까지 아무 .catch()(또는 try/catch로 감싼 await)도 만나지 못하면, unhandledrejection 이벤트가 발생합니다. 1번 절의 떠다니는 프로미스가 최종적으로 도착하는 곳입니다.


브라우저의 unhandledrejection

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

error 이벤트와 구조가 닮았습니다. event.reason에 거부 사유가, event.promise에 거부된 Promise가 담기고, event.preventDefault()로 콘솔의 기본 경고를 억제합니다. 차이는 여기 담기는 게 "던져진 예외"가 아니라 "거부된 Promise"라는 점뿐입니다.


덜 알려진 짝, rejectionhandled

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

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

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

이 경우 먼저 unhandledrejection이 발생했다가, 3초 뒤 핸들러가 붙는 순간 rejectionhandled 이벤트가 발생합니다. 관측성 도구가 "처리되지 않은 거부"를 성급히 리포팅했다가 나중에 정정해야 하는 상황을 다루라고 스펙이 마련해 둔 장치입니다. 실무에서 자주 쓰진 않지만, unhandledrejection이 "지금 이 순간 아무도 안 잡았다"는 스냅숏일 뿐 영구적인 판정이 아니라는 사실을 보여 줍니다.


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

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

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

여기엔 알아 둘 만한 역사가 있습니다. 예전 Node는 처리되지 않은 거부를 그저 경고만 찍고 넘어갔습니다. 그런데 이 관대함이 오히려 삼켜진 에러(swallowed error)를 방치하는 원인이 되자, Node 15부터 기본 동작이 바뀌어 처리되지 않은 거부는 동기 예외처럼 프로세스를 종료(crash) 시킵니다. 즉 오늘날의 Node에서 떠다니는 프로미스는 서버를 통째로 내릴 수 있는 실질적인 위험입니다. 필요하다면 --unhandled-rejections=warn 같은 플래그로 동작을 조정할 수 있지만, 기본값을 존중하고 거부를 제대로 처리하는 편이 낫습니다.


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

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

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

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

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

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


Recap

거부된 Promise가 아무 핸들러도 만나지 못하면 브라우저는 unhandledrejection을, Node는 process.on('unhandledRejection')을 발생시킵니다. event.reason·event.promise로 사유에 접근하고 preventDefault()로 억제하며, 뒤늦게 핸들러가 붙으면 rejectionhandled가 이를 정정합니다. Node 15부터는 처리되지 않은 거부가 프로세스를 종료시키므로 더 위험해졌고, 근본 예방책은 떠다니는 프로미스를 만들지 않는 것 — await, void ...catch(), no-floating-promises 린트입니다.




4. 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", () => {
// 취소 순간에 정리 로직을 실행한다
});

abort()의 사유와 AbortError의 정체

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

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

여기서 자주 헷갈리는 지점을 짚고 가겠습니다. 취소로 발생하는 AbortError는 흔한 Error의 인스턴스가 아니라 DOMException 입니다. 그래서 error instanceof Error로는 걸러지지 않을 수 있고, 취소 여부를 판별할 때는 생성자가 아니라 이름으로 확인하는 것이 가장 견고합니다.

// 취약: DOMException은 환경에 따라 Error를 상속하지 않을 수 있다
if (error instanceof Error) { /* … */ }

// 견고: 이름으로 판별한다
if (error.name === "AbortError") { /* 취소된 것 */ }

fetch는 취소되면 signal.reason으로 거부됩니다. 즉 사유를 직접 지정했다면 fetch의 거부값도 그 사유가 됩니다. 사유를 지정하지 않았을 때만 기본값인 AbortError DOMException으로 거부됩니다.


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()은 비교적 최근에 표준에 들어왔습니다(브라우저는 2023~2024년경, Node는 18/20대에 안정화). 폭넓은 구형 환경을 지원해야 한다면 지원 범위를 확인하거나, setTimeout + controller.abort()로 직접 조합하는 폴백을 두는 편이 안전합니다.


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을 받는 다른 Web API들(스트림 등)까지 하나의 signal로 묶어 두면, 정리 시점에 컨트롤러 하나만 abort() 하면 됩니다. 이 성질은 다음 절의 React 예제에서 그대로 빛을 발합니다.


Recap

AbortController는 명령을 내리는 controller.abort(reason?)와 그것을 구독하는 읽기 전용 signal로 나뉩니다. 취소로 발생하는 AbortErrorError가 아니라 DOMException이므로 error.name으로 판별하고, fetchsignal.reason으로 거부됩니다. AbortSignal.timeout()은 구분 가능한 TimeoutError로 취소하며, AbortSignal.any()는 여러 취소 조건을 결합합니다. 무엇보다 abortfetch를 넘어 이벤트 리스너까지 아우르는 "정리 신호"로 이해할 때 그 진가가 드러납니다.




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

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


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

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

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

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

서두에서 대시보드를 뒤덮었던 AbortError의 정체가 바로 이것입니다. 정상적인 취소가 처리되지 않은 거부로 새어나가 진짜 장애처럼 보였던 겁니다. 그러니 질문에 답하자면 — 취소는 에러가 아닙니다. 다만 에러와 같은 통로를 지나갈 뿐입니다. 그래서 우리가 그 통로 끝에서 둘을 구분해 줘야 합니다.


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

구분의 도구는 이미 4번 절에서 마련해 뒀습니다. error.name입니다.

async function loadData(signal) {
try {
const res = await fetch("/api/data", { signal });
return await res.json();
} catch (error) {
// 내가 일부러 끊은 것 — 에러가 아니므로 조용히 흘려보낸다
if (error.name === "AbortError") return;

// 타임아웃 — 취소이긴 하지만 재시도해 볼 만한 사유다
if (error.name === "TimeoutError") {
return retryWithBackoff();
}

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

AbortError는 삼키고(정상 취소), TimeoutError는 재시도 후보로 다루고, 그 밖의 것만 진짜 에러로 위로 올려보냅니다. 더 세밀하게는 abort(reason)으로 직접 넣은 사유를 signal.reason으로 읽어, "사용자 이탈"·"검색어 변경"·"조건 초기화"처럼 취소의 맥락까지 구분할 수 있습니다.


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

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

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

fetch(`/api/search?q=${query}`, { signal: controller.signal })
.then((res) => res.json())
.then(setResults)
.catch((error) => {
// 이전 요청이 취소되며 나는 AbortError는 정상이므로 무시한다
if (error.name === "AbortError") return;
setError(error);
});

// query가 바뀌거나 컴포넌트가 사라지면 이전 요청을 취소한다
return () => controller.abort();
}, [query]);

cleanup 함수 하나가 두 가지 문제를 동시에 해결합니다. 첫째, 낡은(stale) 응답이 최신 상태를 덮어쓰는 경쟁 상태를 막습니다. 둘째, 취소로 생기는 거부를 .catch()가 곧바로 걸러 주므로 unhandledrejection 노이즈도 원천 차단됩니다. 4번 절에서 "abort는 정리 신호"라고 한 말이 여기서 그대로 구현됩니다 — 언마운트 시점의 정리와 취소가 같은 한 줄입니다.


Error Boundary가 잡지 못하는 것

React를 쓰다 보면 "그럼 Error Boundary가 다 잡아 주지 않나?" 하는 의문이 들 수 있습니다. 그렇지 않습니다. React의 Error Boundary는 렌더링 중에 던져진 에러만 잡습니다. 다음은 Error Boundary의 관할 밖입니다.

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

즉 이 글이 다룬 두 착지점(error·unhandledrejection)과 Error Boundary는 관할이 겹치지 않습니다. Error Boundary는 렌더 트리의 안전망이고, 전역 핸들러는 그 바깥의 콜백·비동기 세계를 위한 안전망입니다. 그래서 둘 다 필요합니다.


Recap

fetch를 취소하면 그 Promise가 거부되므로, 취소는 실패와 같은 거부 채널을 공유하며 방치하면 unhandledrejection으로 새어나갑니다. 따라서 취소는 에러가 아니라 "에러와 같은 통로를 지나가는 정상 흐름"이며, error.name으로 AbortError(삼킴)·TimeoutError(재시도)·나머지(진짜 에러)를 구분해 줘야 합니다. React useEffectcleanup에서 abort()를 부르면 경쟁 상태와 거부 노이즈를 한 번에 해결하고, 렌더 밖의 에러를 못 잡는 Error Boundary의 빈틈을 전역 핸들러가 메웁니다.




6. 최후 방어선을 설계하기

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


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

서두의 문제 — 대시보드를 뒤덮은 AbortError — 는 전역 핸들러에서 이렇게 해결합니다.

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

// 사용자가 페이지를 옮기며 취소된 요청은 장애가 아니다.
// 콘솔 경고와 리포팅을 함께 억제한다.
if (reason?.name === "AbortError" || reason?.name === "TimeoutError") {
event.preventDefault();
return;
}

reportToObservability(reason);
});

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

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

Sentry.init({
beforeSend(event, hint) {
const name = hint?.originalException?.name;
// 정상 취소는 리포팅하지 않는다 (null을 반환하면 이벤트가 폐기된다)
if (name === "AbortError" || name === "TimeoutError") return null;
return event;
},
});

이렇게 두면 진짜 장애만 대시보드에 남고, 취소는 조용히 흘러갑니다.


설계 원칙 정리

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

  • 취소는 에러가 아니다. 다만 에러와 채널을 공유할 뿐이므로, error.name으로 구분해 정상 취소는 조용히 흘려보냅니다.
  • 실제 처리는 최대한 호출부 가까이에서. 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

전역 핸들러에서는 reason.name으로 AbortError·TimeoutError를 걸러 정상 취소를 리포팅에서 제외하고(Sentry라면 beforeSend), 진짜 장애만 관측성으로 보냅니다. 실제 에러 처리는 호출부 가까이에서 하고 전역 핸들러는 최후의 안전망으로만 두며, 브라우저에서는 관측·안내에 Node에서는 정리 후 종료에 씁니다. 취소를 에러와 구분하고, abort를 정리 신호로 묶고, 떠다니는 프로미스를 없애는 것 — 이 세 가지가 새어나가는 에러를 다스리는 방어선의 뼈대입니다.




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: processuncaughtException
  2. Node.js: processunhandledRejection
  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


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