자바스크립트 비동기 에러 올바르게 처리하기: try/catch가 못 잡는 경우부터 Promise.all vs allSettled까지
try/catch로 감쌌는데 에러가 새어 나가고, 404인데 fetch는 성공했다고 한다. await 빠뜨린 try/catch, return vs return await, fetch의 res.ok, Promise.all과 allSettled, 반복문 속 await, 타임아웃과 요청 취소까지 비동기 에러 처리의 함정을 직접 실행해보며 정리했다.
try/catch로 감쌌는데 왜 에러가 새어 나갈까?
데이터를 불러오는 함수를 try/catch로 감쌌다. 그런데 에러가 잡히지 않는다.
async function load() {
throw new Error("서버 에러");
}
try {
load();
console.log("try 블록 통과");
} catch (e) {
console.log("잡힘", e.message); // 실행되지 않는다
}
try 블록 통과
Uncaught (in promise) Error: 서버 에러
async 함수는 에러를 던지지 않고, 실패한(rejected) Promise를 돌려준다. try 블록은 Promise를 받자마자 끝나버리고, 에러는 나중에 Promise 안에서 터지기 때문에 catch가 잡을 기회가 없다.
try {
await load(); // ✅ await가 있어야 rejected Promise가 에러로 바뀌어 catch로 온다
} catch (e) {
console.log("잡힘", e.message); // "잡힘 서버 에러"
}
비동기 에러를 try/catch로 잡으려면 반드시 await가 있어야 한다. await가 rejected Promise를 "던져진 에러"로 바꿔주는 다리 역할을 한다.
비동기 코드의 에러 처리는 대부분 이런 "있는 줄 알았는데 없는" 함정에서 무너진다. 하나씩 보자.
함정 1. return과 return await는 다르다
try 안에서 Promise를 그냥 return하면, 그 Promise가 실패해도 이 함수의 catch는 실행되지 않는다.
async function a1() {
try {
return load(); // ❌ Promise를 그대로 넘기고 함수는 끝남
} catch (e) {
return "대체 값";
}
}
async function a2() {
try {
return await load(); // ✅ 여기서 기다리므로 실패하면 catch로 온다
} catch (e) {
return "대체 값";
}
}
await a1(); // Error: 서버 에러 (catch가 무시됨)
await a2(); // "대체 값"
try 블록 안에서 Promise를 반환할 때는 return await를 쓰자. try 밖이라면 return과 return await의 결과는 같다.
함정 2. fetch는 404에서도 성공한다
fetch가 실패(reject)하는 건 네트워크 자체가 안 될 때뿐이다. 서버가 404나 500을 돌려줘도 fetch는 "응답을 잘 받았다"며 성공으로 처리한다.
| 상황 | fetch 결과 | res.ok |
|---|---|---|
| 200 OK | 성공 | true |
| 404 Not Found | 성공 | false |
| 500 Server Error | 성공 | false |
| 인터넷 끊김, 잘못된 주소, CORS 차단 | 실패 (TypeError) | - |
그래서 상태 코드는 직접 확인해야 한다.
async function getJSON(url) {
const res = await fetch(url);
if (!res.ok) {
throw new Error(`요청 실패: ${res.status}`);
}
return res.json();
}
이 확인이 빠지면 404 페이지의 HTML을 res.json()으로 읽다가 엉뚱한 SyntaxError가 나서, 진짜 원인(404)을 찾기 어려워진다.
함정 3. Promise.all은 하나만 실패해도 전부 실패한다
여러 요청을 동시에 보낼 때 쓰는 Promise.all은 하나라도 실패하면 즉시 실패한다. 나머지가 성공했어도 그 결과는 받을 수 없다.
| 메서드 | 언제 끝나나 | 결과 |
|---|---|---|
| Promise.all | 모두 성공하거나, 하나라도 실패하면 즉시 | 성공 값 배열 또는 첫 에러 |
| Promise.allSettled | 모두 끝날 때까지 기다림 | 각각의 { status, value / reason } |
| Promise.any | 하나라도 성공하면 | 첫 성공 값 (전부 실패하면 에러) |
| Promise.race | 하나라도 끝나면 (성공이든 실패든) | 가장 먼저 끝난 결과 |
아래 미리보기에서 버튼을 눌러보자. A(0.6초), B(0.3초, 실패), C(0.9초) 세 요청을 동시에 보낸다.
Promise.all을 누르면 B가 실패한 0.3초에 바로 catch로 넘어가지만, A와 C의 요청은 그 뒤에도 계속 진행돼서 완료된다. Promise.all은 요청을 취소하지 않고, 결과만 버린다.
어떤 걸 써야 할까?
| 상황 | 추천 |
|---|---|
| 전부 있어야 의미가 있다 (상품 정보 + 가격 + 재고) | Promise.all |
| 일부가 실패해도 나머지는 보여준다 (대시보드 위젯 여러 개) | Promise.allSettled |
| 여러 서버 중 가장 먼저 응답한 곳을 쓴다 | Promise.any |
| 타임아웃과 경쟁시킨다 | Promise.race (요즘은 AbortSignal.timeout이 더 낫다) |
함정 4. 반복문 속 await
for...of + await는 하나씩 기다린다
// 각 요청이 0.2초라면 총 0.6초
for (const id of [1, 2, 3]) {
users.push(await getUser(id));
}
// 동시에 보내면 총 0.2초
const users = await Promise.all([1, 2, 3].map(getUser));
서로 의존하지 않는 요청이라면 map + Promise.all로 동시에 보내는 게 훨씬 빠르다. 위 데모의 "순차"와 "병렬" 버튼으로 시간 차이를 확인할 수 있다. 반대로 앞 요청의 결과가 다음 요청에 필요하거나, 서버에 한꺼번에 부담을 주면 안 될 때는 순차가 맞다.
forEach + async는 기다리지 않는다
const result = [];
[1, 2, 3].forEach(async (id) => {
result.push(await getUser(id));
});
console.log(result); // [] ← 아무것도 기다리지 않고 바로 넘어감
forEach는 콜백이 돌려주는 Promise를 무시한다. 그래서 반복은 바로 끝나고, 에러가 나도 바깥의 try/catch가 잡지 못한다.
forEach 안에서는 await를 쓰지 말자. 순서대로 기다려야 하면 for...of, 동시에 보내도 되면 map + Promise.all이다.
함정 5. 끝나지 않는 요청: 타임아웃과 취소
fetch에는 기본 타임아웃이 없다. 서버가 응답하지 않으면 로딩 스피너가 계속 돈다. AbortSignal.timeout()으로 제한 시간을 걸 수 있다.
try {
const res = await fetch("/api/report", { signal: AbortSignal.timeout(5000) });
} catch (e) {
if (e.name === "TimeoutError") {
console.log("5초 안에 응답이 없어요");
}
}
사용자가 페이지를 떠났거나 검색어를 바꿔서 이전 요청이 필요 없어졌을 때는 AbortController로 직접 취소한다.
let controller;
async function search(keyword) {
controller?.abort(); // 이전 검색 요청 취소
controller = new AbortController();
try {
const res = await fetch(`/api/search?q=${keyword}`, { signal: controller.signal });
return await res.json();
} catch (e) {
if (e.name === "AbortError") return; // 일부러 취소한 건 에러가 아님
throw e;
}
}
이렇게 하면 "늦게 도착한 이전 검색 결과가 최신 결과를 덮어쓰는" 버그도 같이 막을 수 있다.
에러를 어디서, 어떻게 처리할까
원인을 잃지 않고 다시 던지기: cause
낮은 단계의 에러를 잡아서 더 이해하기 쉬운 에러로 바꿀 때, 원래 에러를 cause에 담아두면 디버깅할 때 원인을 따라갈 수 있다.
try {
JSON.parse(text);
} catch (err) {
throw new Error("설정 파일을 읽지 못했습니다", { cause: err });
}
// e.message → "설정 파일을 읽지 못했습니다"
// e.cause → SyntaxError: ...
잡아서 삼키지 말자
// ❌ 에러를 잡고 아무것도 안 하면, 실패했다는 사실이 사라진다
try {
await save();
} catch (e) {}
catch에서는 셋 중 하나를 해야 한다. 사용자에게 알리거나, 대체 값으로 복구하거나, 다시 던지거나. 처리할 수 없는 곳에서는 잡지 말고 위로 올려 보내서, 처리할 수 있는 한 곳(화면 단, API 호출 계층)에서 모아서 다루는 게 좋다.
마지막 안전망: unhandledrejection
아무도 잡지 않은 Promise 에러는 전역 이벤트로 받을 수 있다. 에러 모니터링 도구에 보내는 용도로 쓴다.
window.addEventListener("unhandledrejection", (event) => {
sendToMonitoring(event.reason); // 모니터링 서비스로 전송 (직접 만든 함수)
});
Node.js에서는 process.on("unhandledRejection", ...)이다. 이건 어디까지나 놓친 에러를 기록하는 용도이지, 에러 처리를 대신하는 곳이 아니다.
정리: 왜 알아두면 좋은가
| 함정 | 올바른 처리 |
|---|---|
| try/catch가 비동기 에러를 못 잡음 | await를 붙인다 |
| try 안의 return promise | return await promise |
| fetch가 404/500에서도 성공 | if (!res.ok) throw ... |
| 하나 실패하면 전부 잃는 Promise.all | 일부 실패를 허용하면 allSettled |
| for...of + await가 느림 | 독립적인 요청은 map + Promise.all |
| forEach + async가 안 기다림 | for...of 또는 Promise.all |
| 끝나지 않는 요청 | AbortSignal.timeout(ms) |
| 필요 없어진 요청 | AbortController로 취소 |
| 원인을 잃는 에러 변환 | new Error(msg, { cause }) |
비동기 에러는 동기 에러와 달리 조용히 사라지기 쉽다. await 하나, res.ok 한 줄, forEach 대신 for...of처럼 작은 차이가 "에러가 났는데 아무도 모르는" 상황과 "실패를 제대로 알리고 복구하는" 상황을 가른다. 비동기 코드를 쓸 때는 "이 Promise가 실패하면 그 에러는 어디로 가는가?" 를 한 번씩 물어보자.