Ray Book
비동기 JavaScript

Promise

콜백의 한계를 해결하기 위해 등장한 Promise, 상태 전환, 체이닝, 에러 전파, 병렬 실행을 시각화합니다

javascriptpromiseasyncerror-handling

콜백에서 Promise로

이전 글에서 콜백의 네 가지 문제를 살펴봤습니다.

  1. 에러 처리의 반복
  2. 제어 역전
  3. 흐름 추적의 어려움
  4. 병렬 처리의 복잡성

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의 상태는 한 번만 전환됩니다.

resolve()reject() / throwpendingfulfilledsettledrejectedsettledPromise가 생성되면 pending 상태입니다. 아직 결과가 결정되지 않았습니다.
코드
1const p = new Promise((resolve, reject) => {
2 setTimeout(() => resolve("done"), 1000);
3});
4 
5p.then(value => console.log(value));
Promise 체인
pPending
Promise가 생성되면 즉시 executor 함수가 실행됩니다. resolve나 reject가 호출되기 전까지 상태는 pending입니다.

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는 같은 .thenonFulfilled에서 발생한 에러를 잡지 못합니다.

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() 하나로 충분합니다.

코드
1fetch("/user/1")
2 .then(r => r.json())
3 .then(u => fetch("/orders/" + u.id))
4 .then(r => r.json())
5 .then(orders => console.log(orders))
6 .catch(err => console.error(err));
Promise 체인
fetch()Pending
fetch()가 네트워크 요청을 시작하고 Promise를 반환합니다. 응답이 올 때까지 pending 상태입니다.

체이닝의 규칙

.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) 체크를 할 필요가 없습니다.

코드
1Promise.resolve(1)
2 .then(v => {
3 throw new Error("실패!");
4 })
5 .then(v => console.log("건너뜀"))
6 .catch(e => console.error(e.message))
7 .then(() => console.log("복구됨"));
Promise 체인
Promise.resolveFulfilled
1
Promise.resolve(1)은 즉시 fulfilled 상태인 Promise를 만듭니다.

에러 전파의 규칙을 정리하면:

  1. .then() 콜백에서 throw하거나, rejected Promise를 반환하면 → 다음 Promise가 rejected
  2. rejected 상태는 .catch()를 만날 때까지 .then()을 건너뛰며 전파
  3. .catch()가 에러를 처리하면 → 반환된 Promise는 fulfilled (체인 복구)
  4. .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 위에서 어떻게 동기 코드처럼 비동기를 작성할 수 있게 하는지 살펴보겠습니다.