Menu

자바스크립트 async 에러 처리: try/catch와 Promise 완벽 정리

async/await에서 try/catch를 어떻게 써야 하는지, Promise의 .catch는 언제 붙이고, 왜 에러가 조용히 사라지는지까지 — 실전에서 꼭 알아야 할 내용만 정리했습니다.

비동기 코드의 에러는 동기 에러와 다르게 동작합니다

동기 자바스크립트에서는 에러가 던져지면 호출 스택을 타고 올라가면서 try/catch에 잡히거나, 그렇지 않으면 프로그램이 그대로 죽습니다. 하지만 비동기 코드에서는 이 모델이 통하지 않습니다. 네트워크 요청이 실패하는 시점이 되면, 그 요청을 시작했던 함수는 이미 리턴된 뒤거든요. 거슬러 올라갈 호출 스택 자체가 없는 겁니다.

Promise는 이 문제를 에러 전용 채널을 따로 두는 방식으로 해결합니다. Promise는 값과 함께 이행(fulfill) 되거나, 이유와 함께 거부(reject) 되거나 둘 중 하나입니다. 여기서 reject가 바로 비동기 세계에서의 throw에 해당하죠. 이 문서에서 다루는 내용은 결국 하나입니다. 이 rejection이 어딘가로 증발해 버리지 않고, 여러분이 통제할 수 있는 자리에 확실히 도착하게 만드는 것.

index.js
Output
Click Run to see the output here.

try/catch 블록은 아무 일 없이 끝나버리고, 거절은 50ms 뒤에야 발생합니다. 이미 try 블록이 종료된 뒤죠. 아무도 이 에러를 잡지 못합니다. 바로 이게 함정입니다.

await와 함께라면 try/catch가 다시 통한다

promise를 await하는 순간, 거절(rejection)은 async 함수 안에서 throw된 에러로 바뀝니다. 그래서 바깥을 감싸고 있는 try/catch가 동기 코드의 throw처럼 자연스럽게 잡아냅니다.

index.js
Output
Click Run to see the output here.

가장 먼저 손이 가야 할 패턴이 바로 이겁니다. await는 비동기 세계를 우리에게 익숙한 try/catch 형태로 다시 연결해 주거든요. 실패할 수 있는 await 호출은 try 블록 안에 넣고, 에러는 catch에서 처리하면 됩니다.

한 가지 짚고 넘어갈 점: try/catch가 잡아주는 건 오직 await가 붙은 호출뿐입니다. await 없이 프로미스를 그냥 날려 보내면 에러는 그대로 빠져나갑니다.

가장 흔한 버그: await를 빼먹는 경우

async 함수를 호출할 때 await를 붙이지 않거나 프로미스를 반환하지도 않으면, rejection이 바깥의 try/catch를 그냥 지나쳐 버립니다:

index.js
Output
Click Run to see the output here.

try 블록 자체는 무사히 끝나버립니다. 거부(rejection)는 다음 틱에서 발생하는데, 그때는 이미 잡아줄 곳이 없죠. 그래서 콘솔에 "unhandled promise rejection" 경고가 뜨게 됩니다.

해결 방법은 언제나 똑같습니다. 해당 호출에 await을 붙이거나, Promise를 return해서 호출한 쪽이 await할 수 있게 넘겨주면 됩니다.

index.js
Output
Click Run to see the output here.

.catch()는 같은 동전의 반대쪽

async/await를 쓰지 않고도 .catch()를 체이닝해서 rejection을 처리할 수 있습니다:

index.js
Output
Click Run to see the output here.

.catch(fn)은 사실상 .then(undefined, fn)의 축약형입니다. 체인 앞쪽에서 발생한 어떤 rejection이든 받아서 처리해 주죠. 체인 끝에 붙는 .catch()는 async 버전의 최상위 try/catch라고 보면 됩니다. rejection이 "unhandled" 상태로 넘어가기 전 마지막 방어선인 셈이죠.

두 스타일을 섞어 써도 전혀 문제없습니다. 함수 내부에서는 async/await로 작성하고, 호출하는 쪽에서 .catch()를 붙이는 패턴이 자주 쓰입니다:

index.js
Output
Click Run to see the output here.

fetch는 HTTP 에러에서 reject되지 않는다

이건 누구나 한 번쯤은 당하는 함정이다. fetch는 네트워크 레벨의 실패 — DNS 조회 실패, 연결 거부, 요청 취소 같은 상황에서만 reject된다. 404나 500 응답은 엄연히 "성공한 fetch"로 취급된다. Promise는 정상적으로 resolve되고, 다만 okfalse인 응답 객체를 돌려줄 뿐이다.

index.js
Output
Click Run to see the output here.

catch 블록에서 HTTP 에러까지 잡고 싶다면, res.ok를 직접 확인해서 에러를 던져줘야 합니다:

index.js
Output
Click Run to see the output here.

이런 코드를 두 번 이상 쓰고 있다면, 헬퍼 함수로 빼둘 가치가 있는 보일러플레이트다.

Promise.all은 바로 실패하고, Promise.allSettled는 그렇지 않다

Promise.all은 프라미스 배열을 받아서 결과 배열로 resolve된다. , 하나라도 reject되면 그 에러와 함께 즉시 reject된다. 나머지 프라미스는 계속 실행되지만, 그 결과는 그냥 버려진다.

index.js
Output
Click Run to see the output here.

여러 작업 중 하나라도 실패하면 전체 작업이 의미가 없어지는 상황이라면 fail-fast 방식이 맞습니다. 반대로 "이 업로드 5개 다 시도해보고, 어떤 게 성공했고 어떤 게 실패했는지 알려줘"처럼 각 작업의 결과를 모두 받아봐야 한다면 Promise.allSettled를 쓰면 됩니다:

index.js
Output
Click Run to see the output here.

allSettled은 절대 reject되지 않습니다. 각 결과는 {status: "fulfilled", value} 아니면 {status: "rejected", reason} 둘 중 하나로 나옵니다.

다시 던지기와 좁은 범위의 catch

모든 에러를 같은 핸들러에서 처리할 필요는 없습니다. 흔히 쓰는 패턴은 일단 잡은 뒤 내용을 확인하고, 예상하지 못한 에러라면 다시 던지는 방식입니다:

index.js
Output
Click Run to see the output here.

catch (err) {}처럼 빈 catch 블록으로 모든 에러를 삼켜버리면 진짜 버그를 놓치게 됩니다. 의미 있게 처리할 수 있는 에러만 잡고, 나머지는 다시 throw 하세요.

unhandledrejection 이벤트는 최후의 안전망이다

아무리 꼼꼼하게 코드를 짜도, 어디선가 놓친 에러는 반드시 새어 나옵니다. 다행히 Node.js와 브라우저 모두 아무도 잡지 못한 Promise rejection을 가로챌 수 있는 전역 훅을 제공합니다.

// 브라우저
window.addEventListener("unhandledrejection", event => {
    console.error("처리되지 않음:", event.reason);
    event.preventDefault(); // 기본 콘솔 경고 억제
});

// Node.js
process.on("unhandledRejection", reason => {
    console.error("처리되지 않음:", reason);
});

이건 제대로 된 에러 처리를 대체하는 게 아니라, 마지막 안전망으로 로그나 텔레메트리를 남기는 용도입니다. 최신 Node.js에서는 처리되지 않은 rejection이 발생하면 기본적으로 프로세스가 죽는데, 프로덕션에서는 보통 그게 오히려 맞는 동작입니다. 에러를 로깅하고, 프로세스는 깔끔하게 종료된 뒤 재시작하게 두세요.

실전 체크리스트

실패할 가능성이 있는 뭔가를 건드리는 async 함수를 작성할 때는 스스로에게 이렇게 물어보세요.

  • 위험한 await는 전부 try/catch 안에 있는가? 아니면 반환된 프로미스를 호출한 쪽에서 .catch()로 받고 있는가?
  • 실제로 await를 붙였는가, 아니면 실수로 fire-and-forget 해버렸는가?
  • fetch의 경우, 응답을 믿기 전에 res.ok를 확인하고 있는가?
  • 병렬로 실행할 때 Promise.all이 맞는 선택인가, 아니면 Promise.allSettled가 필요한 상황인가?
  • 최상위 .catch()unhandledrejection 핸들러가 있어서 어떤 에러도 조용히 사라지지 않도록 보장하고 있는가?

이 다섯 가지만 챙기면, async 코드가 이벤트 루프 속으로 에러를 흘려보내며 당신을 당황시키는 일이 사라집니다.

다음: ES 모듈

이것으로 async 챕터의 에러 처리까지 마무리됐습니다. 다음에는 자바스크립트 코드를 여러 파일로 나눠 구성하는 방법, 즉 importexport, 그리고 모든 현대 프로젝트의 근간이 되는 모듈 시스템을 살펴보겠습니다.

자주 묻는 질문

async 함수 안에서 에러는 어떻게 처리하나요?

await 호출을 try/catch로 감싸면 됩니다. awaited된 Promise가 reject되면 throw된 에러처럼 동작해서 catch 블록으로 잡힙니다. 아니면 에러를 그대로 흘려보내고, 호출하는 쪽에서 반환된 Promise에 .catch()를 붙여서 처리해도 됩니다.

try/catch를 썼는데 왜 에러가 안 잡히죠?

대부분 await을 안 붙였기 때문입니다. async 함수를 await 없이 (또는 Promise를 return 하지 않고) 호출하면, 그 안에서 발생한 reject는 바깥 try/catch를 그대로 빠져나갑니다. 에러를 잡고 싶은 Promise에는 반드시 await을 걸거나 return 하세요.

Promise가 reject됐는데 아무도 안 잡으면 어떻게 되나요?

unhandled rejection이 발생합니다. Node.js에서는 unhandledRejection 이벤트가 발생하고, 최신 버전에서는 기본적으로 프로세스가 종료됩니다. 브라우저에서는 window.onunhandledrejection이 발생하면서 콘솔에 경고가 찍히고요. 어느 쪽이든 .catch()를 붙이거나 await 주변을 try/catch로 감싸는 게 안전합니다.

Promise.all은 에러를 어떻게 처리하나요?

Promise.all은 입력 중 하나라도 reject되는 순간 바로 reject됩니다. 나머지 Promise는 계속 실행되긴 하지만 결과는 버려져요. 실패 여부와 상관없이 모든 결과를 받고 싶다면 Promise.allSettled를 쓰세요. {status, value} 또는 {status, reason} 형태의 배열로 resolve됩니다.

Coddy로 코딩 배우기

시작하기