콜백에서 Promise로
이전 글에서 콜백의 네 가지 문제를 살펴봤습니다.
- 에러 처리의 반복
- 제어 역전
- 흐름 추적의 어려움
- 병렬 처리의 복잡성
Promise 는 ES2015에서 도입된 비동기 처리 패턴으로, 이 문제들을 언어 수준에서 해결합니다.
Promise란
Promise는 미래에 완료될 비동기 작업의 결과를 나타내는 객체 입니다. "지금은 값이 없지만, 나중에 값을 주겠다는 약속"입니다.
const promise = new Promise((resolve, reject) => {
// 비동기 작업 수행
setTimeout(() => {
resolve("완료된 값");
}, 1000);
});new Promise에 전달하는 함수를 executor 라고 합니다. executor는 즉시 실행되며, 두 개의 콜백을 인자로 받습니다.
resolve(value), 작업 성공 시 호출reject(reason), 작업 실패 시 호출
세 가지 상태
Promise는 세 가지 상태 중 하나입니다.
| 상태 | 의미 | 전환 |
|---|---|---|
| pending | 아직 완료되지 않음 | 초기 상태 |
| fulfilled | 성공적으로 완료 | resolve() 호출 시 |
| rejected | 실패 | reject() 호출 또는 에러 발생 시 |
핵심 규칙: 한 번 fulfilled 또는 rejected가 되면 다시는 바뀌지 않습니다. 이것을 settled 라고 합니다.
const p = new Promise((resolve, reject) => {
resolve("첫 번째");
resolve("두 번째"); // 무시됨
reject("에러"); // 무시됨
});
// p는 "첫 번째"로 fulfilled, 이후 호출은 모두 무시이것이 콜백의 "제어 역전" 문제를 해결합니다. 콜백이 몇 번 호출되든 Promise의 상태는 한 번만 전환됩니다.
then, catch, finally
Promise의 결과를 사용하려면 then , catch , finally 메서드를 사용합니다.
then
promise.then(
(value) => { /* fulfilled 시 실행 */ },
(reason) => { /* rejected 시 실행 (선택) */ }
);.then()은 새로운 Promise를 반환 합니다. 이것이 체이닝의 기반입니다.
catch
promise.catch((reason) => {
console.error("에러:", reason);
});.catch(fn)은 .then(undefined, fn)의 축약형입니다. 체인 어디에서든 발생한 에러를 잡습니다.
다만 실전에서 중요한 차이가 있습니다. .then(onFulfilled).catch(onRejected)는 onFulfilled 내부에서 발생한 에러도 잡지만, .then(onFulfilled, onRejected)에서 onRejected는 같은 .then의 onFulfilled에서 발생한 에러를 잡지 못합니다.
finally
promise.finally(() => {
// 성공이든 실패든 항상 실행
hideLoadingSpinner();
});.finally()는 결과에 관계없이 실행되며, 값을 변경하지 않고 그대로 전달합니다.
체이닝
Promise의 진짜 힘은 체이닝 (chaining) 에 있습니다. .then()이 새 Promise를 반환하므로, 비동기 작업을 평탄하게 연결할 수 있습니다.
콜백 방식과 비교해보겠습니다.
// 콜백 지옥
getUser(userId, (err, user) => {
if (err) return handleError(err);
getOrders(user.id, (err, orders) => {
if (err) return handleError(err);
getOrderDetails(orders[0].id, (err, details) => {
if (err) return handleError(err);
displayResult(details);
});
});
});
// Promise 체이닝
getUser(userId)
.then(user => getOrders(user.id))
.then(orders => getOrderDetails(orders[0].id))
.then(details => displayResult(details))
.catch(handleError);들여쓰기 없이 위에서 아래로 읽힙니다. 에러 처리도 .catch() 하나로 충분합니다.
체이닝의 규칙
.then() 콜백의 반환값에 따라 다음 Promise의 상태가 결정됩니다.
// 1. 일반 값을 반환 → 그 값으로 fulfilled
promise.then(v => v * 2); // fulfilled(값 * 2)
// 2. Promise를 반환 → 그 Promise를 따라감
promise.then(v => fetch("/api")); // fetch의 Promise를 기다림
// 3. 아무것도 반환하지 않음 → undefined로 fulfilled
promise.then(v => { console.log(v); }); // fulfilled(undefined)
// 4. throw → rejected
promise.then(v => { throw new Error("!"); }); // rejected(Error)에러 전파
Promise 체인에서 에러는 .catch()를 만날 때까지 자동으로 전파됩니다. 매 단계에서 if (err) 체크를 할 필요가 없습니다.
에러 전파의 규칙을 정리하면:
.then()콜백에서 throw하거나, rejected Promise를 반환하면 → 다음 Promise가 rejected- rejected 상태는
.catch()를 만날 때까지.then()을 건너뛰며 전파 .catch()가 에러를 처리하면 → 반환된 Promise는 fulfilled (체인 복구).catch()안에서 다시 throw하면 → 다음.catch()로 전파
fetchData()
.then(process) // 에러 시 건너뜀
.then(transform) // 에러 시 건너뜀
.catch(err => {
console.error(err);
return fallbackData; // 복구, 이후 체인은 정상 진행
})
.then(display); // fallbackData로 실행됨Promise 조합
여러 Promise를 조합하는 정적 메서드가 있습니다.
Promise.all
모든 Promise가 fulfilled되면 결과 배열을 반환합니다. 하나라도 rejected되면 즉시 rejected됩니다.
const [user, orders] = await Promise.all([
fetch("/user/1").then(r => r.json()),
fetch("/orders?userId=1").then(r => r.json()),
]);
// 두 요청이 병렬로 실행됨, 콜백의 카운터 패턴이 필요 없음콜백에서 카운터 변수와 완료 체크로 구현하던 병렬 처리가 한 줄로 해결됩니다.
Promise.allSettled
모든 Promise가 settled (fulfilled 또는 rejected) 될 때까지 기다립니다. 하나가 실패해도 나머지 결과를 잃지 않습니다.
const results = await Promise.allSettled([
fetch("/api/a"),
fetch("/api/b"),
fetch("/api/c"),
]);
results.forEach(result => {
if (result.status === "fulfilled") {
console.log("성공:", result.value);
} else {
console.log("실패:", result.reason);
}
});Promise.race
가장 먼저 settled된 Promise의 결과를 반환합니다.
const result = await Promise.race([
fetch("/api/data"),
new Promise((_, reject) =>
setTimeout(() => reject(new Error("타임아웃")), 5000)
),
]);
// 5초 안에 응답이 없으면 타임아웃 에러Promise.any
가장 먼저 fulfilled 된 Promise의 결과를 반환합니다. 모두 rejected되면 AggregateError로 rejected됩니다.
const fastest = await Promise.any([
fetch("https://cdn1.example.com/data"),
fetch("https://cdn2.example.com/data"),
fetch("https://cdn3.example.com/data"),
]);
// 가장 빠른 CDN의 응답을 사용콜백의 문제, Promise의 해결
| 콜백의 문제 | Promise의 해결 |
|---|---|
매번 if (err) 반복 | .catch() 하나로 에러 전파 |
| 제어 역전 (몇 번 호출될지 모름) | 상태가 한 번만 전환 |
| 중첩으로 흐름 추적 어려움 | 체이닝으로 평탄한 구조 |
| 병렬 처리에 카운터 필요 | Promise.all로 한 줄 해결 |
Promise의 한계
Promise가 콜백의 문제를 해결했지만, 여전히 아쉬운 점이 있습니다.
function loadDashboard(userId) {
return getUser(userId)
.then(user => {
return getOrders(user.id)
.then(orders => {
// user와 orders 둘 다 필요한 경우
// 스코프를 위해 중첩이 발생
return getShippingInfo(orders[0].id)
.then(shipping => ({ user, orders, shipping }));
});
});
}여러 단계의 결과를 동시에 사용해야 하면 중첩이 다시 나타납니다. 또한 .then() 체인은 동기 코드와 모양이 다르기 때문에 읽기에 자연스럽지 않습니다.
다음 단계
Promise의 체이닝을 더 자연스럽게 쓸 수 있도록 ES2017에서 async/await 가 도입되었습니다. 다음 글에서는 async/await가 Promise 위에서 어떻게 동기 코드처럼 비동기를 작성할 수 있게 하는지 살펴보겠습니다.