콜백 지옥에서 async/await까지: JavaScript Promise 깊이 이해하기
JavaScript Promise가 등장한 배경부터 상태 모델, then 체이닝, 정적 메서드, async/await와의 관계, 실무 패턴(재시도/타임아웃/취소)까지 한 번에 정리합니다.
들어가며
fetch().then(...)이나 await fetch()는 매일 쓰는 코드지만, “Promise가 정확히 무엇이고 왜 등장했는가”를 한 줄로 답하기는 의외로 어렵다. Promise는 단순한 비동기 도구가 아니라, 미래에 결정될 값을 객체로 다루기 위한 약속이다.
이 글에서는 Promise가 등장한 배경부터 내부 상태 모델, .then 체이닝의 동작 원리, 정적 메서드 비교, async/await와의 관계, 그리고 실무에서 자주 쓰는 패턴(재시도, 타임아웃, 취소)까지 한 번에 정리한다.
마이크로태스크 큐와 이벤트 루프 자체에 대한 설명은 JavaScript 이벤트 루프 글을 참고하자. 이 글에서는 Promise 관점에서 필요한 부분만 짧게 짚는다.
Promise는 왜 등장했는가
콜백 기반 비동기의 한계
Promise 이전의 JavaScript는 비동기 작업의 결과를 콜백 함수로 받았다.
getUser(userId, (err, user) => {
if (err) return handleError(err);
getOrders(user.id, (err, orders) => {
if (err) return handleError(err);
getOrderItems(orders[0].id, (err, items) => {
if (err) return handleError(err);
render(items);
});
});
});
이른바 콜백 지옥(Callback Hell) 이다. 들여쓰기가 깊어지는 것은 표면적인 문제고, 진짜 문제는 두 가지다.
- 에러 처리가 흩어진다. 모든 콜백마다
if (err)분기를 반복해야 한다. 한 번이라도 빼먹으면 에러가 조용히 사라진다. - 제어 역전(Inversion of Control) 문제. 내가 넘긴 콜백을
getUser가 언제, 몇 번, 어떤 순서로 호출할지 보장할 수 없다. 라이브러리가 콜백을 두 번 호출하거나, 동기적으로 즉시 호출하거나, 아예 호출하지 않을 수도 있다.
Promise의 해법
Promise는 비동기 결과를 값처럼 다룰 수 있는 객체로 만들어 이 문제를 해결한다.
getUser(userId)
.then((user) => getOrders(user.id))
.then((orders) => getOrderItems(orders[0].id))
.then((items) => render(items))
.catch(handleError);
핵심은 두 가지다.
- 합성(Composition) 이 가능하다.
.then이 새로운 Promise를 반환하므로 체이닝으로 평탄하게 이어진다. - 에러는 한 곳에서 처리한다. 체인 어디에서든 발생한 에러는 가장 가까운
.catch로 전파된다.
Promise의 3가지 상태
Promise는 항상 다음 3가지 상태 중 하나에 있다.
| 상태 | 의미 | 전이 |
|---|---|---|
| pending | 아직 결과가 결정되지 않음 | 초기 상태 |
| fulfilled | 작업이 성공적으로 완료됨 | pending → fulfilled |
| rejected | 작업이 실패함 | pending → rejected |
fulfilled와 rejected를 합쳐 settled 상태라고 부른다.
가장 중요한 규칙: 불변성
상태 전이는 단 한 번만 일어난다. 한 번 fulfilled가 되면 절대 rejected로 바뀌지 않고, 결정된 값(또는 에러)도 변하지 않는다.
const p = new Promise((resolve, reject) => {
resolve('첫 번째');
resolve('두 번째'); // 무시됨
reject(new Error('실패')); // 무시됨
});
p.then((v) => console.log(v)); // '첫 번째'
이 불변성 덕분에 Promise는 콜백의 “여러 번 호출되는” 문제를 구조적으로 차단한다. executor 함수에서 resolve/reject가 몇 번 호출되든, 외부에서 관찰되는 결과는 첫 번째 호출 한 번뿐이다.
.then 체이닝의 동작 원리
.then을 단순히 “다음에 실행할 콜백”이라고 이해하면 동작을 정확히 예측할 수 없다. 정확한 정의는 다음과 같다.
.then(onFulfilled, onRejected)은 항상 새로운 Promise를 반환하며, 콜백의 반환값에 따라 새 Promise의 운명이 결정된다.
콜백 반환값에 따른 3가지 분기
.then의 콜백이 무엇을 반환하느냐에 따라 새 Promise의 결과가 달라진다.
// 1. 일반 값 반환 → 새 Promise는 그 값으로 fulfill
Promise.resolve(1)
.then((v) => v + 1)
.then((v) => console.log(v)); // 2
// 2. Promise 반환 → 새 Promise는 반환된 Promise의 결과를 따라감
Promise.resolve(1)
.then((v) => Promise.resolve(v + 10))
.then((v) => console.log(v)); // 11
// 3. throw → 새 Promise는 reject 상태가 됨
Promise.resolve(1)
.then(() => {
throw new Error('실패');
})
.catch((e) => console.log(e.message)); // '실패'
3번 동작이 중요하다. .then 콜백 안에서 던진 에러는 다음 .catch로 전파된다. try/catch로 감쌀 필요가 없다.
.catch와 .finally
.catch(fn)은 .then(undefined, fn)의 단축 표기다. 별도 메서드가 아니라 .then의 특수 케이스라는 점이 중요하다.
// 동일한 동작
promise.then(undefined, handleError);
promise.catch(handleError);
.finally(fn)은 성공/실패와 무관하게 항상 실행되며, 콜백의 반환값을 무시한다. 로딩 상태 해제 같은 정리 작업에 적합하다.
let loading = true;
fetchData()
.then((data) => render(data))
.catch((err) => showError(err))
.finally(() => {
loading = false; // 성공/실패 모두 실행
});
마이크로태스크 큐와 Promise
Promise 콜백이 “언제” 실행되는지는 마이크로태스크 큐(Microtask Queue)로 결정된다. 자세한 동작은 이벤트 루프 글에 정리해두었고, 여기서는 Promise 관점에서 두 가지만 짚는다.
1. Promise 콜백은 항상 비동기로 실행된다
이미 settled된 Promise라도 .then 콜백은 동기적으로 실행되지 않는다.
console.log('1');
Promise.resolve().then(() => console.log('2'));
console.log('3');
// 1, 3, 2
이 규칙은 일관성을 위한 의도적 설계다. 만약 어떤 경우에는 동기, 어떤 경우에는 비동기로 실행된다면 호출자는 실행 순서를 예측할 수 없다.
2. 마이크로태스크는 Task Queue보다 우선한다
setTimeout(() => console.log('timeout'), 0);
Promise.resolve().then(() => console.log('promise'));
// promise, timeout
setTimeout은 Task Queue, Promise.then은 Microtask Queue로 들어간다. 이벤트 루프는 한 Task가 끝날 때마다 Microtask Queue를 모두 비운 뒤에야 다음 Task로 넘어간다. 그래서 항상 Promise가 먼저다.
정적 메서드: 여러 Promise 다루기
여러 Promise를 한 번에 다루는 4가지 정적 메서드는 동작이 미묘하게 다르다. 잘못 고르면 의도하지 않은 동작이 발생한다.
| 메서드 | 완료 조건 | 결과 |
|---|---|---|
Promise.all | 모두 fulfill | 결과 배열 / 하나라도 reject되면 즉시 reject |
Promise.allSettled | 모두 settle (성공/실패 무관) | {status, value/reason} 객체 배열 |
Promise.race | 가장 먼저 settle된 하나 | 그 Promise의 결과 (성공이든 실패든) |
Promise.any | 가장 먼저 fulfill된 하나 | 그 값 / 모두 reject되면 AggregateError |
언제 무엇을 쓰는가
Promise.all — 모든 작업이 성공해야 의미 있는 경우.
// 사용자 정보 + 주문 + 알림을 모두 가져와야 화면을 그릴 수 있다
const [user, orders, notifications] = await Promise.all([
fetchUser(),
fetchOrders(),
fetchNotifications(),
]);
하나라도 실패하면 전체가 실패한다. 예를 들어 fetchNotifications만 실패해도 user, orders는 버려진다.
Promise.allSettled — 일부 실패를 허용해야 하는 경우.
// 알림 가져오기는 실패해도 화면은 그려야 한다
const results = await Promise.allSettled([fetchUser(), fetchOrders(), fetchNotifications()]);
const user = results[0].status === 'fulfilled' ? results[0].value : null;
const notifications = results[2].status === 'fulfilled' ? results[2].value : [];
대시보드처럼 여러 위젯을 독립적으로 보여주는 화면에 적합하다.
Promise.race — 타임아웃 패턴.
const timeout = (ms) =>
new Promise((_, reject) => setTimeout(() => reject(new Error('Timeout')), ms));
// 5초 안에 응답이 없으면 실패
const data = await Promise.race([fetchData(), timeout(5000)]);
주의할 점은 race가 “이긴” 쪽만 결과로 쓰일 뿐, 진 쪽의 작업이 취소되는 게 아니다. fetchData는 계속 진행된다. 진짜 취소가 필요하다면 AbortController를 함께 써야 한다.
Promise.any — 여러 후보 중 가장 빠른 성공.
// 여러 미러 서버 중 가장 먼저 응답하는 것을 사용
const data = await Promise.any([
fetch('https://mirror1.example.com/data'),
fetch('https://mirror2.example.com/data'),
fetch('https://mirror3.example.com/data'),
]);
race와 달리 실패는 무시하고 첫 성공만 기다린다. 모두 실패하면 AggregateError로 묶여서 reject된다.
Q&A: Promise 안에 또 Promise가 있다면?
Promise를 다루다 보면 자연스럽게 떠오르는 궁금증들이 있다. “값으로 Promise를 또 넘기면 어떻게 되지?”, “이미 Promise인데 또 new Promise로 감싸도 되나?” 같은 것들이다. 이 절은 그런 질문에 대한 답을 모은 것이다.
핵심 한 줄 요약: Promise는 자동으로 풀린다(unwrapping). 단, reject만은 예외다.
Q1. .then 콜백이 Promise를 반환하면 어떻게 되나?
.then이 반환하는 새 Promise가 콜백이 반환한 Promise를 따라간다(adoption). 즉 그 Promise가 settle될 때까지 pending 상태로 기다렸다가, 결과(값 또는 에러)를 자기 결과로 채택한다.
Promise.resolve(1)
.then((v) => Promise.resolve(v + 10)) // Promise<Promise<11>> 이 아니라
.then((v) => console.log(v)); // 11 (자동 unwrap)
만약 자동 unwrap이 없었다면 Promise<Promise<Promise<...>>> 같은 중첩이 발생해서 매번 .then을 한 단계씩 더 호출해야 했을 것이다. 자동 unwrap 덕분에 비동기 체인이 항상 평탄하게 유지된다.
Q2. resolve()에 Promise를 넘기면 어떻게 되나?
마찬가지로 풀린다. outer는 즉시 fulfill되는 게 아니라, 넘긴 inner가 settle될 때까지 기다렸다가 그 결과를 채택한다.
const inner = new Promise((resolve) => setTimeout(() => resolve('done'), 1000));
const outer = new Promise((resolve) => resolve(inner));
outer.then((v) => console.log(v)); // 'done' (1초 뒤)
이 동작은 .then 메서드를 가진 객체(thenable)에도 적용된다. 그래서 서로 다른 라이브러리의 Promise 구현체끼리도 호환된다.
const thenable = {
then(resolve) {
resolve(42);
},
};
Promise.resolve(thenable).then((v) => console.log(v)); // 42
Q3. 이미 Promise를 반환하는 API를 new Promise로 다시 감싸도 되나?
되긴 하지만 거의 항상 안티패턴이다. 이걸 Promise constructor anti-pattern이라고 부른다.
// 나쁨
const fetchUser = (id) => {
return new Promise((resolve, reject) => {
fetch(`/api/users/${id}`)
.then((res) => res.json())
.then(resolve)
.catch(reject);
});
};
// 좋음
const fetchUser = (id) => fetch(`/api/users/${id}`).then((res) => res.json());
문제는 단순히 코드가 길어지는 게 아니다. .then 콜백 안에서 던진 에러가 reject로 자동 전파되지 않기 때문에, 실수로 .catch(reject)를 빼먹으면 에러가 조용히 사라진다. 이미 Promise를 반환하는 API라면 그대로 반환하거나 .then으로 변환해서 반환하면 된다.
Q4. Promise.all([...]) 안에서 new Promise를 또 쓰는 경우는?
이건 상황에 따라 정상이거나 안티패턴이다. 두 가지로 구분해야 한다.
정상 케이스 — 콜백 기반 API를 promisify해서 묶는 경우.
FileReader, Geolocation, 일부 라이브러리처럼 콜백/이벤트 기반으로 동작하는 API는 직접 new Promise로 감싸야 한다.
const readFile = (file) =>
new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.onerror = () => reject(reader.error);
reader.readAsText(file);
});
// 여러 파일을 병렬로 읽기
const contents = await Promise.all(files.map(readFile));
이 경우는 new Promise가 유일한 방법이다. 콜백 기반 API를 Promise로 변환하는 어댑터 역할을 하는 것이라 정당하다.
안티패턴 — 이미 Promise인 걸 또 감싸는 경우.
// 나쁨
Promise.all(
urls.map(
(url) =>
new Promise((resolve, reject) => {
fetch(url).then(resolve).catch(reject);
}),
),
);
// 좋음
Promise.all(urls.map((url) => fetch(url)));
fetch는 이미 Promise를 반환하므로 그대로 Promise.all에 넘기면 된다. Q3과 같은 문제다.
알아두면 좋은 것 — Promise.all/race/allSettled/any는 인자로 받은 iterable의 각 요소를 내부적으로 Promise.resolve()로 감싼다. 그래서 일반 값과 Promise를 섞어 넣어도 동작한다.
const [a, b, c] = await Promise.all([1, Promise.resolve(2), fetch('/api')]);
// a === 1, b === 2, c === Response
요약하면 이렇다.
- 콜백/이벤트 기반 API →
new Promise로 직접 감싸야 함 (정상) - 이미 Promise를 반환하는 함수 → 그대로 넘기거나
.then으로 변환 (감싸지 말 것)
Q5. reject()에 Promise를 넘기면?
여기가 비대칭이다. reject는 인자를 풀어주지 않는다. Promise를 넘기든 일반 값을 넘기든, 그 값 자체가 reason이 된다.
const p = Promise.reject(Promise.resolve('hello'));
p.catch((reason) => {
console.log(reason); // Promise 객체 자체 (값 'hello'가 아님)
console.log(reason instanceof Promise); // true
});
resolve는 풀어주는데 reject는 풀어주지 않는 비대칭은 의도된 설계다. Reject reason이 Promise라는 건 “이 Promise가 실패의 이유”라는 의미이므로, 그걸 또 풀어버리면 의미가 모호해진다.
실무에서 reject에 Promise를 직접 넘길 일은 거의 없지만, 디버깅 중에 reason이 이상해 보일 때 이 규칙을 떠올리면 도움이 된다.
async/await: Promise를 동기처럼
async/await는 Promise를 대체하지 않는다. Promise 위에 얹힌 문법 설탕이다.
// 동일한 동작
const data1 = fetchData().then((res) => res.json());
const data2 = await (await fetchData()).json();
async 함수는 항상 Promise를 반환한다. return 값은 Promise.resolve(값), throw는 Promise.reject(에러)와 같다.
가장 흔한 함정: 순차 실행
await를 그냥 나열하면 순차 실행이 된다.
// 나쁨: 3초 걸림 (1 + 1 + 1)
const user = await fetchUser(); // 1초
const orders = await fetchOrders(); // 1초 (user 받은 뒤 시작)
const notifications = await fetchNotifications(); // 1초
// 좋음: 1초 걸림 (병렬)
const [user, orders, notifications] = await Promise.all([
fetchUser(),
fetchOrders(),
fetchNotifications(),
]);
세 작업이 서로 의존하지 않는데도 순차로 기다리면 시간이 3배가 된다. 의존성이 없으면 Promise.all로 묶는다가 기본 원칙이다.
에러 전파
async 함수 내부의 throw나 await된 Promise의 reject는 함수가 반환하는 Promise를 reject시킨다.
const loadData = async () => {
const res = await fetch('/api/data');
if (!res.ok) throw new Error('Failed');
return res.json();
};
loadData().catch((err) => console.error(err)); // 정상 캐치
내부에서 try/catch로 잡거나, 외부에서 .catch 또는 try/await로 잡는다. 둘 다 안 잡으면 unhandled rejection이 된다.
Unhandled Rejection
처리되지 않은 reject는 그냥 사라지지 않는다.
const p = Promise.reject(new Error('어딘가 실패'));
// .catch를 붙이지 않으면 unhandled rejection 발생
브라우저는 unhandledrejection 이벤트를, Node.js는 process.on('unhandledRejection')을 발생시킨다. 운영 환경에서는 반드시 글로벌 핸들러를 두고 로깅·모니터링과 연결해야 한다.
// 브라우저
window.addEventListener('unhandledrejection', (event) => {
reportError(event.reason);
event.preventDefault(); // 기본 콘솔 출력 막기 (선택)
});
실무 패턴
1. 재시도(Retry) with Exponential Backoff
네트워크 에러는 일시적인 경우가 많다. 재시도를 점점 긴 간격으로 두면 서버 부하도 덜고 성공 확률도 높아진다.
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const retry = async (fn, { maxAttempts = 3, baseDelay = 1000 } = {}) => {
let lastError;
for (let attempt = 0; attempt < maxAttempts; attempt++) {
try {
return await fn();
} catch (err) {
lastError = err;
if (attempt < maxAttempts - 1) {
await sleep(baseDelay * 2 ** attempt); // 1s, 2s, 4s, ...
}
}
}
throw lastError;
};
// 사용
const data = await retry(() => fetch('/api/data').then((r) => r.json()), {
maxAttempts: 4,
});
실제 운영 코드에서는 재시도 가능한 에러만 재시도해야 한다. 4xx는 재시도해도 결과가 같으니 즉시 throw, 5xx와 네트워크 에러만 재시도하는 식이다.
2. 타임아웃
Promise.race 패턴을 함수로 추상화해두면 깔끔하다.
const withTimeout = (promise, ms) => {
const timeout = new Promise((_, reject) =>
setTimeout(() => reject(new Error(`Timeout ${ms}ms`)), ms),
);
return Promise.race([promise, timeout]);
};
const data = await withTimeout(fetchData(), 5000);
다시 강조: 이 패턴은 타임아웃을 알릴 뿐, 실제 작업을 멈추지는 않는다. 진짜 취소가 필요하면 다음 패턴을 쓴다.
3. 취소 with AbortController
fetch를 비롯해 여러 Web API는 AbortSignal을 받아 작업 자체를 취소할 수 있다.
const fetchWithTimeout = async (url, ms) => {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), ms);
try {
return await fetch(url, { signal: controller.signal });
} finally {
clearTimeout(timer); // 정상 응답 시 타이머 정리
}
};
try {
const res = await fetchWithTimeout('/api/data', 5000);
} catch (err) {
if (err.name === 'AbortError') {
console.log('요청이 취소됨');
}
}
AbortController는 React에서 컴포넌트 언마운트 시 진행 중인 요청을 정리하는 데도 유용하다. 의존성이 바뀌어 effect가 다시 실행될 때, 이전 요청을 취소하지 않으면 race condition이 생긴다.
// React useEffect 안에서
useEffect(() => {
const controller = new AbortController();
fetch(`/api/items/${id}`, { signal: controller.signal })
.then((res) => res.json())
.then(setItem)
.catch((err) => {
if (err.name !== 'AbortError') showError(err);
});
return () => controller.abort(); // cleanup
}, [id]);
정리
Promise는 단순한 비동기 도구가 아니라 미래의 값을 1급 객체로 다루는 모델이다. 핵심을 다시 짚으면 다음과 같다.
- Promise는 콜백 지옥과 제어 역전 문제를 해결하기 위해 등장했다.
- 상태는 pending → fulfilled/rejected로 단 한 번만 전이되며, 결과는 불변이다.
.then은 항상 새 Promise를 반환하며, 반환값(값/Promise/throw)에 따라 그 운명이 결정된다.- 정적 메서드는 동작이 다르다 —
all(모두 성공),allSettled(모두 완료),race(첫 결과),any(첫 성공). - Promise는 자동으로 풀린다(unwrapping). 단,
reject만은 인자를 그대로 reason으로 쓴다. - 이미 Promise를 반환하는 API를
new Promise로 다시 감싸지 않는다 (constructor anti-pattern). async/await는 Promise 위의 문법 설탕이다. 의존성이 없으면Promise.all로 묶어 병렬화한다.- 운영에서는 unhandled rejection 핸들러, 재시도, 타임아웃,
AbortController를 통한 취소가 기본 도구다.
Promise를 정확히 이해하면 async/await도 자연스러워지고, 실수의 유형도 줄어든다. 비동기 코드는 결국 언제·무엇이·어떤 순서로 실행되는지를 머릿속에서 그릴 수 있느냐의 문제다.
참고 자료
공식 문서 / 사양
- MDN - Promise — 메서드별 레퍼런스 (한국어)
- MDN - Promise 사용하기 — 입문자용 가이드 (한국어)
- MDN - AbortController — 취소 패턴 관련 (한국어)
- Promises/A+ 사양 — Promise 표준 사양 (영문)
- HTML 사양 - Microtask queuing — 마이크로태스크 큐 동작 명세 (영문)
심화 / 기술 블로그
- V8 - Faster async functions and promises — 엔진 레벨에서 Promise/async가 어떻게 최적화되는지 (영문)
- Jake Archibald - Tasks, microtasks, queues and schedules — 이벤트 루프와 마이크로태스크의 결정판 글 (영문)
관련 글
- JavaScript 이벤트 루프 — 마이크로태스크 큐와 Task Queue의 동작 원리