Promise는 비동기 작업의 최종 완료 또는 실패를 나타내는 객체로, 콜백 기반 비동기 처리의 한계를 해결하기 위해 ES6에서 도입되었습니다.
Promise
Promise는 영어로 '약속'이라는 뜻이다.
한국어로는 프로미스라고 읽으며, 자바스크립트에서 비동기 작업의 최종 결과를 나타내는 객체를 말한다.
즉, "지금은 결과를 줄 수 없지만, 나중에 완료되면 알려주겠다"는 약속을 객체로 표현한 것이다. API 호출, 파일 읽기, 타이머 같은 비동기 작업이 끝났을 때 그 결과(성공 또는 실패)를 전달받을 수 있는 구조다. Promise가 왜 필요해졌는지 이해하려면, 먼저 그 이전의 비동기 처리 방식이 어떤 문제를 가지고 있었는지 살펴봐야 한다.
콜백 패턴의 한계
콜백 지옥(Callback Hell)
Promise 이전에는 비동기 작업의 결과를 처리하기 위해 콜백 함수를 사용했다.
작업이 끝나면 콜백을 호출하고, 그 안에서 다음 작업을 시작하는 방식이다.
단일 비동기 작업이라면 문제가 없지만, 여러 비동기 작업이 순차적으로 이어지면 코드가 깊게 중첩된다.
getUser(userId, function (user) { // 유저 정보를 받아서
getOrders(user.id, function (orders) { // 주문 정보를 받고
getOrderDetail(orders[0].id, function (detail) { // 주문 상세 정보를 받고
getShipping(detail.shippingId, function (shipping) { // 배송 정보를 받고
console.log(shipping.status); // 배송 상태를 출력하고
// 계속 깊어진다...
});
});
});
});이런 코드를 콜백 지옥(Callback Hell) 또는 파멸의 피라미드(Pyramid of Doom) 라고 부른다. 들여쓰기가 깊어질수록 코드의 흐름을 파악하기 어렵고, 수정이나 디버깅이 극도로 까다로워진다.
에러 처리의 어려움
콜백 패턴의 또 다른 문제는 에러 처리가 일관되지 않다는 점이다.
Node.js에서는 (err, result) 형태의 error-first 콜백 패턴을 관례로 사용했지만,
이를 강제할 수 있는 언어적 장치가 없어서 각 단계마다 수동으로 에러를 확인해야 했다.
getUser(userId, function (err, user) {
if (err) { handleError(err); return; }
getOrders(user.id, function (err, orders) {
if (err) { handleError(err); return; }
getOrderDetail(orders[0].id, function (err, detail) {
if (err) { handleError(err); return; }
// 매 단계마다 에러 체크 반복...
});
});
});모든 단계에서 동일한 에러 처리 코드가 반복되고, 하나라도 빠뜨리면 에러가 조용히 무시된다. 비동기 흐름 전체를 아우르는 일관된 에러 처리 메커니즘이 필요했다.
Promise의 등장
이런 문제들을 해결하기 위해 ES6(2015)에서 Promise가 표준으로 도입되었다.
Promise는 콜백을 중첩하는 대신 .then()으로 체이닝하여 비동기 흐름을 평탄하게 만들고,
.catch()로 에러를 한 곳에서 일괄 처리할 수 있는 구조를 제공한다.
getUser(userId)
.then((user) => getOrders(user.id))
.then((orders) => getOrderDetail(orders[0].id))
.then((detail) => getShipping(detail.shippingId))
.then((shipping) => console.log(shipping.status))
.catch((err) => handleError(err)); // 에러 일괄 처리깊은 중첩 없이 위에서 아래로 읽히는 평탄한 구조가 되었고,
어디서 에러가 발생하든 마지막 .catch()에서 처리할 수 있다.
Promise의 상태와 사용법
세 가지 상태
Promise는 항상 다음 세 가지 상태 중 하나에 있다.
| 상태 | 설명 |
|---|---|
| pending | 초기 상태. 비동기 작업이 아직 완료되지 않음 |
| fulfilled | 작업이 성공적으로 완료됨. 결과값을 가짐 |
| rejected | 작업이 실패함. 실패 이유(에러)를 가짐 |
상태 전이는 pending → fulfilled 또는 pending → rejected 방향으로만 일어난다.
한 번 fulfilled나 rejected로 전이되면 다시는 바뀌지 않는다.
이를 settled(확정됨) 상태라고 하며, settled된 Promise의 값은 불변이다.
resolve(value)
pending ─────────────────> fulfilled (value)
│
└── reject(reason) ──> rejected (reason)생성: new Promise(executor)
Promise는 new Promise()로 생성하며, 인자로 executor 함수를 받는다.
executor는 resolve와 reject 두 개의 콜백을 매개변수로 받고, 작업을 수행한 뒤
성공 시 resolve(value)를, 실패 시 reject(reason)을 호출한다.
const promise = new Promise((resolve, reject) => {
// 이 내부가 excutor
const data = fetchSomething();
if (data) {
resolve(data); // fulfilled 상태로 전이
} else {
reject(new Error('fetch failed')); // rejected 상태로 전이
}
});executor는 Promise가 생성되는 즉시 동기적으로 실행된다.
resolve나 reject가 호출되기 전까지 Promise는 pending 상태를 유지한다.
소비: then, catch, finally
생성된 Promise의 결과를 사용하는 메서드는 세 가지다.
| 메서드 | 호출 시점 | 역할 |
|---|---|---|
.then(onFulfilled, onRejected) | fulfilled 또는 rejected | 성공/실패 핸들러 등록 |
.catch(onRejected) | rejected | 에러 핸들러 등록 (.then(null, onRejected)의 축약) |
.finally(onFinally) | settled (성공/실패 무관) | 정리 작업 (로딩 해제 등) |
.then()은 항상 새로운 Promise를 반환하기 때문에 체이닝이 가능하다.
체인 도중 어디서든 에러가 발생하면 가장 가까운 .catch()로 전달된다.
.finally()는 결과값을 받지 않으며, 성공·실패와 무관하게 항상 실행되어 정리 로직에 적합하다.
fetchUser(1)
.then((user) => {
console.log(user.name);
return fetchPosts(user.id);
})
.then((posts) => console.log(posts.length))
.catch((err) => console.error('에러 발생:', err.message))
.finally(() => hideLoadingSpinner());Promise의 내부 동작
마이크로태스크 큐
Event Loop에서 마이크로태스크 큐가 매크로태스크 큐보다 우선순위가 높다는 것을 살펴봤다.
Promise의 .then(), .catch(), .finally() 콜백은 마이크로태스크 큐에 등록된다.
이는 setTimeout(매크로태스크)보다 먼저 실행된다는 뜻이다.
console.log('1');
setTimeout(() => console.log('2'), 0);
Promise.resolve().then(() => console.log('3'));
console.log('4');
// 출력: 1 → 4 → 3 → 2'3'이 '2'보다 먼저 출력되는 이유는, Promise 콜백이 마이크로태스크 큐에 들어가고
setTimeout 콜백은 매크로태스크 큐에 들어가기 때문이다.
이벤트 루프는 콜 스택이 비면 마이크로태스크 큐를 전부 비운 뒤에야 매크로태스크를 하나 꺼낸다.
then 체이닝의 원리
.then()이 체이닝 가능한 이유는 항상 새로운 Promise를 반환하기 때문이다.
ECMAScript 명세에 따르면,
.then()은 내부적으로 새 Promise를 생성하고, 콜백의 반환값으로 그 Promise를 resolve한다.
Promise.resolve(1)
.then((v) => v + 1) // 새 Promise(fulfilled, 2) 반환
.then((v) => v * 3) // 새 Promise(fulfilled, 6) 반환
.then((v) => console.log(v)); // 6각 .then()이 반환한 값은 다음 .then()의 인자로 전달된다.
만약 콜백이 Promise를 반환하면, 해당 Promise가 settled될 때까지 다음 .then()의 실행이 지연된다.
이 구조 덕분에 비동기 작업을 순차적으로 연결하면서도 평탄한 코드를 유지할 수 있다.
정적 메서드와 async/await
Promise에는 여러 비동기 작업을 동시에 다루기 위한 정적 메서드가 있다.
| 메서드 | 동작 |
|---|---|
Promise.all(iterable) | 모두 fulfilled되면 결과 배열 반환. 하나라도 rejected되면 즉시 rejected |
Promise.race(iterable) | 가장 먼저 settled된 Promise의 결과를 반환 |
Promise.allSettled(iterable) | 모두 settled될 때까지 기다린 뒤 각각의 상태와 결과를 반환 |
Promise.any(iterable) | 가장 먼저 fulfilled된 결과를 반환. 모두 rejected되면 AggregateError |
ES2017에서 도입된 async/await는 Promise를 더 동기적인 문법으로 다룰 수 있게 해주는 구문이다.
async 함수는 항상 Promise를 반환하고, await는 Promise가 settled될 때까지 함수 실행을 일시 중단한다.
내부적으로는 .then() 체이닝과 동일하게 동작하므로, Promise의 문법적 설탕(syntactic sugar)이라고 볼 수 있다.
async function loadUserData(userId) {
try {
const user = await getUser(userId);
const orders = await getOrders(user.id);
const detail = await getOrderDetail(orders[0].id);
return detail;
} catch (err) {
handleError(err);
}
}콜백 지옥에서 시작해, Promise 체이닝을 거쳐, async/await에 이르기까지 — 자바스크립트의 비동기 처리는 가독성과 에러 처리의 일관성을 확보하는 방향으로 발전해 왔다.