Promise 오류 처리
1. Promise 기본 개념
Promise는 비동기 작업의 결과를 담는 객체이고, 결과가 한 번 정해지면 바뀌지 않습니다.
then과 catch는 그 결과를 이어받아 항상 새 Promise를 만들어 돌려줍니다.
1.1 Promise의 상태
Promise는 아직 끝나지 않은 비동기 작업의 결과를 담기 위한 객체입니다. 만들어지는 시점에는 결과가 없고, 작업이 끝나면 성공한 값이나 실패한 이유가 채워집니다. 지금 어느 단계에 있는지를 상태로 구분합니다.
| 상태 | 의미 | 가지고 있는 것 |
|---|---|---|
| pending | 아직 결과가 정해지지 않음 | 없음 |
| fulfilled | 작업이 성공함 | value |
| rejected | 작업이 실패함 | reason (보통 Error 객체) |
fulfilled와 rejected를 묶어 settled(확정)라고 부릅니다. 한 번 settled 된 Promise는 상태도 결과도 다시 바뀌지 않습니다.
1.2 resolve / reject
new Promise()에 넘기는 함수를 이 문서에서는 Promise 생성 함수(executor)라고 부릅니다.
Promise가 만들어질 때 바로 실행되고, 상태를 정하는 resolve와 reject 두 함수를 인자로 받습니다.
const p = new Promise((resolve, reject) => {
resolve("ok"); // pending -> fulfilled
reject(new Error("fail")); // 이미 settled 라서 무시됩니다
});
resolve든 reject든 먼저 호출된 쪽이 결과를 정합니다. 그 뒤의 호출은 오류 없이 조용히 무시됩니다.
1.3 then / catch
p.then(onFulfilled, onRejected);
p.catch(onRejected); // p.then(undefined, onRejected) 와 같습니다
then은 성공 콜백과 실패 콜백을 받고, catch는 실패 콜백만 받는 단축 표현입니다.
둘 다 원래 Promise를 바꾸지 않고 새 Promise를 만들어 반환합니다.
콜백이 값을 반환하면 새 Promise는 그 값으로 fulfilled 되고, 콜백이 throw 하면 그 오류로 rejected 됩니다.
fetchUser()
.then((user) => user.id) // user.id 로 fulfilled 된 새 Promise 를 반환합니다
.then((id) => console.log(id)); // 그 새 Promise 에 붙은 then 입니다
체이닝이 가능한 이유도, 뒤에서 볼 오류 전달도 이 규칙 하나에서 나옵니다.
2. Promise에서 오류 발생
Promise를 rejected로 만드는 방법은 reject()와 throw 두 가지입니다.
어느 Promise가 rejected 되는지는 throw 한 위치에 따라 다릅니다.
2.1 reject()
Promise 생성 함수가 받은 reject를 직접 호출하는 방식입니다.
실패를 명시적으로 알릴 때 씁니다.
new Promise((resolve, reject) => {
reject(new Error("명시적으로 거부"));
});
2.2 throw
Promise 생성 함수 안에서 throw 하면 Promise가 그 오류를 잡아 reject를 호출한 것처럼 처리합니다. 결과는 2.1과 같습니다.
new Promise(() => {
throw new Error("throw 도 rejected"); // reject(new Error(...)) 와 같습니다
});
둘의 차이는 쓸 수 있는 자리에 있습니다.
reject는 함수라서 setTimeout 콜백처럼 나중에 실행되는 코드 안에서도 호출할 수 있습니다.
throw는 Promise 생성 함수가 동기적으로 실행되는 동안 일어난 것만 Promise가 잡습니다.
2.3 then 내부에서 발생한 오류
이번에는 Promise 생성 함수가 아니라 then 콜백 안에서 throw 해 봅니다.
퀴즈. p와 p2 중 어느 쪽이 rejected 일까요?
const p = Promise.resolve(1);
const p2 = p.then(() => {
throw new Error("콜백 안에서 throw");
});
정답 보기
p2만 rejected 되고, p는 그대로 fulfilled 입니다.
p는 이미 1로 settled 된 Promise라서 바뀔 수 없습니다.
then은 콜백의 실행 결과를 담을 새 Promise p2를 만들어 두고, 콜백이 값을 반환하면 그 값으로 fulfilled, throw 하면 그 오류로 rejected 시킵니다.
그래서 콜백 안의 throw는 항상 그 콜백을 감싼 then이 반환한 Promise로 갑니다.
catch 콜백도 마찬가지입니다. catch는 then의 단축 표현이므로, 그 안에서 throw 하면 catch가 반환한 새 Promise가 rejected 됩니다.
| throw 한 위치 | rejected 되는 Promise |
|---|---|
| Promise 생성 함수 안 | 지금 만들고 있는 그 Promise |
| then 콜백 안 | 그 then이 반환한 새 Promise |
| catch 콜백 안 | 그 catch가 반환한 새 Promise |
곁가지: Promise가 잡지 못하는 throw
throw는 Promise가 지켜보는 코드 안에서 일어나야 rejected로 바뀝니다.
setTimeout 콜백처럼 Promise 생성 함수가 끝난 뒤에 실행되는 코드에서 throw 하면 Promise는 그 사실을 알 수 없습니다.
new Promise((resolve, reject) => {
setTimeout(() => {
throw new Error("아무도 잡지 못합니다"); // 여기서는 reject(new Error(...)) 를 호출해야 합니다
}, 0);
});
3. 오류 전달(Error Propagation)
rejected 상태는 실패 콜백이 없는 then을 그대로 지나쳐 가장 가까운 catch에 도착합니다.
catch가 값을 반환하면 체인은 fulfilled로 돌아오고, 다시 throw 하면 다음 catch로 넘어갑니다.
아래 예시의 fetchUser()는 성공하면 사용자 객체로 fulfilled 되는 함수라고 가정합니다.
3.1 Promise Chain에서 오류가 어떻게 전달되는지
2장에서 본 것처럼 then 콜백에서 throw 하면 그 then이 반환한 Promise가 rejected 됩니다. 그 다음 then에 실패 콜백이 없으면 어떻게 될까요? then은 콜백을 실행하지 않고, 자기가 반환한 Promise를 같은 이유로 rejected 시킵니다. 이 일이 catch를 만날 때까지 반복되기 때문에 오류가 체인을 따라 내려가는 것처럼 보입니다.
퀴즈. 콘솔에 무엇이 찍힐까요?
fetchUser()
.then((user) => {
console.log("A");
return user;
})
.then(() => {
console.log("B");
throw new Error("B 에서 실패");
})
.then(() => console.log("C"))
.then(() => console.log("D"))
.catch((err) => console.log("E:", err.message));
출력 결과 보기
A
B
E: B 에서 실패
B에서 throw 된 순간부터 C, D의 콜백은 실행되지 않습니다. 각 then은 rejected를 그대로 넘기기만 하고, catch(E)가 처음으로 그 오류를 받습니다. 동기 코드에서 throw가 가장 가까운 catch 블록으로 점프하는 것과 같은 모양입니다.
3.2 catch를 만나면 어떻게 되는지
catch 콜백이 정상적으로 값을 반환하면, catch가 반환한 새 Promise는 그 값으로 fulfilled 됩니다. 오류는 여기서 소비되고, 체인은 다시 정상 흐름으로 돌아옵니다.
퀴즈. 마지막 then은 실행될까요?
fetchUser()
.then(() => {
throw new Error("실패");
})
.catch(() => ({ name: "guest" }))
.then((user) => console.log(user.name));
출력 결과 보기
guest
실행됩니다.
catch가 { name: "guest" }를 반환했으니 뒤의 then은 그 값을 성공 결과로 받습니다.
오류가 났다는 사실은 catch 이후로는 남지 않습니다.
그래서 catch를 어디에 두느냐가 곧 오류 처리 범위를 정하는 일입니다.
| catch 위치 | 잡는 범위 | 뒤의 then |
|---|---|---|
| 체인 중간 | 그 지점까지의 오류 | 복구된 값으로 계속 실행 |
| 체인 끝 | 앞의 모든 단계 | 없음, 한 곳에서 마무리 |
3.3 catch에서 다시 throw하면 어떻게 되는지
catch도 then처럼 새 Promise를 반환하므로, 콜백 안에서 throw 하면 그 Promise가 rejected 됩니다. 오류는 소비되지 않고 다음 catch까지 다시 전달됩니다. 로그만 남기고 처리는 호출한 쪽에 맡기거나, 저수준 오류를 의미 있는 오류로 감쌀 때 이 방식을 씁니다.
퀴즈. 어떤 줄이 실행될까요?
Promise.reject(new Error("404"))
.catch((err) => {
console.error("로그:", err.message);
throw new Error("사용자 조회 실패", { cause: err });
})
.then(() => console.log("정상 흐름"))
.catch((err) => console.log(err.message));
출력 결과 보기
로그: 404
사용자 조회 실패
"정상 흐름"은 찍히지 않습니다. 첫 catch가 다시 throw 했기 때문에 rejected가 유지되고, 중간 then은 3.1과 같은 이유로 건너뜁니다.
곁가지: 끝까지 아무도 잡지 않으면
체인 끝까지 catch가 없으면 Unhandled Promise Rejection이 됩니다. 브라우저는 콘솔에 오류를 출력하고, Node.js는 버전과 실행 옵션에 따라 경고를 내거나 프로세스를 종료합니다. 어느 쪽이든 의도한 동작은 아니므로 체인 끝에는 catch를 두는 편이 안전합니다.
4. async/await에서의 오류 처리
await는 rejected Promise를 만나면 그 이유를 그 자리에서 throw 합니다.
비동기 오류가 동기 예외로 바뀌기 때문에 try/catch로 잡을 수 있습니다.
4.1 await에서 rejected Promise가 어떻게 처리되는지
await는 Promise가 settled 될 때까지 기다렸다가, fulfilled면 value를 돌려주고 rejected면 reason을 throw 합니다.
then의 성공 콜백과 실패 콜백이 하던 일을 await 한 줄과 try/catch가 나눠 맡는 셈입니다.
반대 방향도 성립합니다. async 함수는 항상 Promise를 반환하고, 함수 안에서 throw 된 오류는 그 Promise의 rejected가 됩니다. then 콜백 안의 throw가 새 Promise를 rejected 시키는 것과 같은 규칙입니다.
async function load() {
throw new Error("실패");
}
load().catch((err) => console.log(err.message)); // 실패
4.2 try/catch
async function load() {
try {
const user = await fetchUser(); // rejected 면 여기서 throw
console.log(user.name); // 실행되지 않음
} catch (err) {
console.error(err.message); // 여기서 잡힙니다
} finally {
console.log("완료"); // 성공, 실패 모두 실행
}
}
await 줄에서 throw가 일어나므로 그 아래 줄은 실행되지 않고 catch 블록으로 넘어갑니다.
finally는 성공하든 실패하든 실행됩니다.
여기서 한 가지 주의할 점이 있습니다.
try 안에 await가 없으면 throw도 일어나지 않습니다.
퀴즈. fetchUser가 실패하면 catch 블록이 실행될까요?
async function load() {
try {
return fetchUser();
} catch (err) {
console.error("잡았다");
}
}
정답 보기
실행되지 않습니다.
return fetchUser()는 rejected Promise를 값으로 반환할 뿐, try 블록 안에서는 아무 throw도 일어나지 않습니다.
그 Promise는 load()가 반환하는 Promise로 그대로 이어지고, 오류는 호출한 쪽에서 처리하게 됩니다.
같은 코드에 await를 붙이면 결과가 달라집니다.
async function load() {
try {
return await fetchUser();
} catch (err) {
console.error("잡았다"); // 실행됩니다
}
}
await가 rejected를 그 자리에서 throw로 바꾸기 때문에 catch 블록이 잡습니다.
return await를 항상 써야 한다는 뜻은 아닙니다.
이 함수 안에서 try/catch로 직접 처리하고 싶다면 await가 필요하다는 뜻이고, 오류를 호출한 쪽에 넘길 생각이라면 return fetchUser()로 두어도 됩니다.
4.3 Promise의 catch와 비교
| 구분 | .catch() | try/catch + await |
|---|---|---|
| 잡는 범위 | 앞 체인 전체 | try 블록 안의 await와 동기 코드 |
| 복구 후 흐름 | 반환값이 다음 then으로 | catch 블록 다음 코드로 |
| 일부만 처리 | 중간에 catch 삽입 | await 단위로 try 분리 |
| 동기 오류와 혼용 | 체인 안에서만 | 동기 예외도 같은 catch에서 처리 |
두 방식은 같은 규칙 위에서 동작하므로 섞어 써도 됩니다.
async 함수 안에서는 try/catch를 기본으로 쓰되, 특정 Promise 하나에 기본값을 주는 것처럼 범위가 좁은 처리는 .catch()가 더 짧습니다.
async function load() {
// 실패해도 기본값으로 계속 진행합니다
const user = await fetchUser().catch(() => ({ name: "guest" }));
console.log(user.name);
}
정리
이 문서의 내용은 결국 하나의 흐름입니다.
then과catch는 항상 새 Promise를 반환합니다.- 콜백 안에서 throw 하면 그 새 Promise가 rejected 됩니다.
- rejected는 실패 콜백이 없는 then을 지나쳐 다음 단계로 전달됩니다.
- catch가 값을 반환하면 fulfilled로 복구되고, 다시 throw 하면 다음 catch로 넘어갑니다.
await는 rejected를 만나면 그 자리에서 throw 합니다.- 그래서 async 함수 안에서는 try/catch로 처리할 수 있습니다.