자바스크립트 교재 · 16편 / 26편

Promise 와 async/await

콜백 헬 탈출. then/catch 와 async/await, 그리고 Promise.all 패턴.

중급읽는 시간 7분2026-05-17
Promise 가 pending → fulfilled/rejected 로 전이되는 상태 다이어그램

15편의 콜백 헬을 푸는 표준 도구가 Promise(ES2015) 와 그 문법적 설탕 async/await(ES2017). 16편은 두 도구의 핵심 사용법, 동시 실행 패턴(Promise.all/race/allSettled), 그리고 자주 만나는 함정까지.

Promise — 3가지 상태

// Promise 는 "미래의 값" 을 담은 객체
const p = new Promise((resolve, reject) => {
  setTimeout(() => {
    if (Math.random() > 0.5) resolve("성공!");
    else reject(new Error("실패"));
  }, 1000);
});

// 상태
// pending → fulfilled (resolve 호출)
// pending → rejected (reject 호출)
// 한 번 settle 되면 다시 안 바뀜

then · catch · finally

p
  .then(value => {
    console.log("성공:", value);
    return value.toUpperCase();   // 다음 then 에 전달
  })
  .then(upper => console.log(upper))
  .catch(err => console.error(err))    // 어디서 reject 됐든 여기로
  .finally(() => console.log("끝"));   // 성공/실패 무관

async/await — 같은 일을 보기 좋게

// Promise 체이닝
function loadUser(id) {
  return fetch(`/api/users/${id}`)
    .then(res => res.json())
    .then(user => {
      return fetch(`/api/orders?userId=${user.id}`);
    })
    .then(res => res.json())
    .then(orders => ({ user, orders }))   // ← user 가 안 보임 (스코프)
    .catch(err => console.error(err));
}

// async/await — 동기처럼 읽힘
async function loadUser(id) {
  try {
    const res1 = await fetch(`/api/users/${id}`);
    const user = await res1.json();
    const res2 = await fetch(`/api/orders?userId=${user.id}`);
    const orders = await res2.json();
    return { user, orders };
  } catch (err) {
    console.error(err);
  }
}

한 줄. async 함수는 항상 Promise 를 반환합니다. 본문에서 return 한 값이 자동으로 wrap. throw 한 에러는 reject 가 됨. await 는 Promise 의 값을 꺼냄.

Promise.all — 동시 실행

// ❌ 순차 — 두 fetch 가 차례로 (느림)
const u = await fetch("/users").then(r => r.json());
const p = await fetch("/posts").then(r => r.json());
// 총 시간: 두 응답 시간의 합

// ✅ 병렬 — Promise.all
const [u, p] = await Promise.all([
  fetch("/users").then(r => r.json()),
  fetch("/posts").then(r => r.json()),
]);
// 총 시간: 두 응답 중 느린 쪽

Promise.all 의 함정. 하나라도 reject 되면 즉시 전체가 reject — 나머지 결과를 받을 수 없음. 일부 실패해도 나머지를 보고 싶으면 Promise.allSettled 사용.

Promise.allSettled · race · any

// allSettled — 모두 끝날 때까지
const results = await Promise.allSettled([p1, p2, p3]);
for (const r of results) {
  if (r.status === "fulfilled") console.log(r.value);
  else console.error(r.reason);
}

// race — 가장 먼저 settle (성공/실패 무관)
const winner = await Promise.race([
  fetch("/data"),
  new Promise((_, reject) => setTimeout(() => reject(new Error("Timeout")), 5000)),
]);

// any (ES2021) — 가장 먼저 성공 (실패는 무시)
const fastest = await Promise.any([
  fetch("https://mirror1.example.com"),
  fetch("https://mirror2.example.com"),
  fetch("https://mirror3.example.com"),
]);
// 모두 실패하면 AggregateError

에러 처리 — try/catch + .catch

// async 안에서는 try/catch
async function load() {
  try {
    const data = await fetchData();
    return data;
  } catch (err) {
    console.error("로드 실패:", err);
    return null;
  }
}

// Promise.then 체인이면 .catch
fetchData()
  .then(data => processData(data))
  .catch(err => console.error(err));

// 둘 다 사용 (await 결과를 then 처럼)
const data = await fetchData().catch(err => null);   // 실패 시 null

마이크로태스크 우선순위 (15편 다시)

console.log("1");

setTimeout(() => console.log("2"), 0);          // 매크로

Promise.resolve().then(() => console.log("3"));   // 마이크로

console.log("4");

// 출력: 1, 4, 3, 2  ← 동기 다 끝나고, 마이크로(3) → 매크로(2)

async + map 의 함정

// ❌ async + map — 결과는 Promise 배열
const results = users.map(async (u) => {
  return await loadProfile(u.id);
});
console.log(results);   // [Promise, Promise, ...]  ← !!

// ✅ Promise.all 로 풀기
const profiles = await Promise.all(users.map(u => loadProfile(u.id)));
//                                          ^ Promise 배열을 한 번에 await

// 순서 보장하면서 순차로
const profiles = [];
for (const u of users) {
  profiles.push(await loadProfile(u.id));
}

매핑 패턴 한 표. ① 병렬 OK · 결과 배열 → Promise.all(arr.map(async ...)). ② 순차 (앞 결과로 다음 결정) → for of + await. ③ N 개 동시 제한 (rate limiting) → p-limit 같은 라이브러리.

top-level await (ES2022, ESM 한정)

// 모듈 최상위에서 직접 await — ESM 에서만
// app.mjs
const config = await loadConfig();
const db = await connect(config.dbUrl);

export { db };

npm 라이브러리는 가급적 사용 자제 — top-level await 가 모듈 로딩 시간에 영향을 줍니다. 앱 진입점·CLI 에서는 OK.

실전 패턴 — 재시도 + 타임아웃

async function withRetry<T>(fn, max = 3, delay = 1000) {
  for (let i = 0; i < max; i++) {
    try {
      return await fn();
    } catch (err) {
      if (i === max - 1) throw err;
      await new Promise(r => setTimeout(r, delay * 2 ** i));   // 지수 백오프
    }
  }
}

async function withTimeout(promise, ms) {
  const t = new Promise((_, reject) =>
    setTimeout(() => reject(new Error("Timeout")), ms)
  );
  return Promise.race([promise, t]);
}

// 사용
const data = await withTimeout(
  withRetry(() => fetch("/api").then(r => r.json())),
  10000
);

안티패턴 5가지

1. async 함수에 await 안 씀. "왜 async 인지" 모름. 동기 함수로 충분하면 그렇게.

2. await 결과를 then 으로. 둘 섞으면 가독성 폭락. 한 함수 안에서는 하나만.

3. forEach + async. forEach 는 Promise 를 무시. for...of + await 또는 Promise.all + map.

4. catch 빠뜨림. 처리 안 된 reject 는 콘솔 경고만 — 사용자엔 무음.

5. fetch 결과 .ok 검사 안 함. fetch 는 4xx/5xx 응답에 reject 안 함. if (!res.ok) throw ... 직접.

17편 — fetch API · API 호출

fetch 의 모든 모양, headers, POST/JSON, AbortController, CORS 한 줄.

📚 쉽게 배우는 자바스크립트 교재
이전: 15편 비동기 입문 · 현재: 16편 (중급) · 다음 → 17편 fetch · 진행: 16/26

© 2026 주나이테크(주) @JUNAITECH