javascript

콜백 지옥에서 async/await까지: JavaScript Promise 깊이 이해하기

JavaScript Promise가 등장한 배경부터 상태 모델, then 체이닝, 정적 메서드, async/await와의 관계, 실무 패턴(재시도/타임아웃/취소)까지 한 번에 정리합니다.

javascript promise async-await microtask asynchronous abort-controller

들어가며

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) 이다. 들여쓰기가 깊어지는 것은 표면적인 문제고, 진짜 문제는 두 가지다.

  1. 에러 처리가 흩어진다. 모든 콜백마다 if (err) 분기를 반복해야 한다. 한 번이라도 빼먹으면 에러가 조용히 사라진다.
  2. 제어 역전(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);

핵심은 두 가지다.

Promise의 3가지 상태

Promise는 항상 다음 3가지 상태 중 하나에 있다.

상태의미전이
pending아직 결과가 결정되지 않음초기 상태
fulfilled작업이 성공적으로 완료됨pendingfulfilled
rejected작업이 실패함pendingrejected

fulfilledrejected를 합쳐 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

요약하면 이렇다.

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(값), throwPromise.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 함수 내부의 throwawait된 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를 정확히 이해하면 async/await도 자연스러워지고, 실수의 유형도 줄어든다. 비동기 코드는 결국 언제·무엇이·어떤 순서로 실행되는지를 머릿속에서 그릴 수 있느냐의 문제다.

참고 자료

공식 문서 / 사양

심화 / 기술 블로그

관련 글