⏳ 05. 비동기 & Promise — 콜백 지옥에서 탈출하는 완벽한 지도

2026년 3월 8일 수정됨

📋 개요

콜백 지옥이 왜 문제인지, Promise가 어떻게 해결했는지, Promise 체이닝과 병렬 실행 패턴을 실무 중심으로 정복합니다.

🎯 이 섹션을 읽고 나면:

  • 콜백 지옥(Callback Hell)이 왜 생기고, 왜 위험한지 설명할 수 있다.
  • Promise의 3가지 상태와 체이닝 메커니즘을 이해한다.
  • Promise.all, Promise.race, Promise.allSettled를 실무에서 선택하는 기준을 안다.

📋 목차


📌 이 문서를 읽기 전에

⏱️ 예상 읽기 시간: 22분(전체) / 핵심 파트만: 14분

🗺️ 이 문서의 흐름
[콜백 지옥의 고통] → [Promise 3상태 이해] → [체이닝 패턴] → [병렬 처리 전략]

🎯 이 문서를 다 읽으면 할 수 있는 것

  • 중첩 콜백 코드를 Promise 체이닝으로 리팩토링할 수 있다.
  • API 병렬 호출 시 Promise.allPromise.allSettled 중 상황에 맞게 선택한다.
  • Promise 에러 처리를 누락 없이 구현한다.

🗺️ 이 문서의 배경 세계관: '영수네 커뮤니티'

🐣 영철: "영호 님, 게시글 상세 페이지 만들다가 코드가 너무 이상하게 생겼어요. 게시글 조회하고, 게시글 작성자 정보 조회하고, 댓글 목록 조회하는 걸 순서대로 해야 하는데... 콜백 안에 콜백이 3겹이 됐어요. 거기다 에러 처리를 각 단계마다 해야 하니까 코드가 피라미드처럼 쌓여가는데, 읽기도 힘들고 뭔가 잘못된 느낌이에요. 이게 '콜백 지옥'이라는 건가요?"

🦁 영호: "맞아. 전형적인 콜백 지옥이야. 그게 왜 문제냐면 — 에러 처리가 각 단계에 흩어져 있어서 빠뜨리기 쉽고, 실행 순서를 눈으로 추적하기 어렵고, 테스트하기 힘들어. Promise가 그 문제를 해결하기 위해 나왔어. 체이닝으로 평평하게 펼치고, 에러를 한 곳에서 처리할 수 있거든."


🤔 1. 왜 알아야 하는가? — 콜백 지옥

비동기 작업이 꼬리에 꼬리를 물고 이어질 때, 우리의 코드는 오른쪽으로 무한히 밀려나기 시작합니다. 영철이가 처음 작성한 게시글 상세 조회 로직이 어떻게 '피라미드'가 되었는지 살펴보겠습니다.

// ❌ 순진한 코드 (Naive Approach) — 콜백 지옥
// 🐣 영철의 초기 코드
 
function loadPostDetail(postId) {
  fetchPost(postId, function (err, post) {
    if (err) {
      console.error("게시글 조회 실패:", err);
      return;
    }
 
    fetchUser(post.authorId, function (err, author) {
      if (err) {
        console.error("작성자 조회 실패:", err);
        return;
      }
 
      fetchComments(postId, function (err, comments) {
        if (err) {
          console.error("댓글 조회 실패:", err);
          return;
        }
 
        // 드디어 데이터 사용 가능
        renderPostDetail({ post, author, comments });
        // 들여쓰기 4단계... 더 깊어지면?
      });
    });
  });
}

콜백 지옥의 3가지 폐해:

  1. 가독성 붕괴: 코드가 오른쪽으로 계속 밀려 피라미드 형태가 된다.
  2. 에러 처리 분산: 각 콜백마다 if (err) 처리가 중복되어 빠뜨리기 쉽다.
  3. 흐름 제어 어려움: 중간에 조건 분기, 반복, 취소 같은 로직을 넣기가 구조적으로 힘들다.

💡 2. Promise의 본질

Promise는 "미래에 완료될 작업을 나타내는 객체" 다. 커피숍에서 주문하고 받은 진동 벨 과 같다 — 지금 당장 커피를 받은 게 아니지만, 커피가 준비되면 알려줄 것이라는 약속이다.

Promise 3가지 상태

Pending (대기)
    │
    ├── 성공(resolve) → Fulfilled (이행) ── .then() 실행
    │
    └── 실패(reject)  → Rejected (거부)  ── .catch() 실행

한번 Fulfilled 또는 Rejected가 된 Promise는 변경되지 않는다 (불변).

Promise 생성과 소비

약속을 만드는 쪽(공급자)과 그 약속을 믿고 기다리는 쪽(소비자)의 코드는 다음과 같은 형태를 띱니다.

// Promise 생성 (내부 로직 담당자)
function fetchPost(postId) {
  return new Promise((resolve, reject) => {
    // 비동기 작업 시작
    setTimeout(() => {
      if (postId > 0) {
        resolve({ id: postId, title: "클로저 완전정복", authorId: 42 });
        // resolve 호출 → Fulfilled 상태, .then()으로 전달
      } else {
        reject(new Error("유효하지 않은 postId"));
        // reject 호출 → Rejected 상태, .catch()로 전달
      }
    }, 1000);
  });
}
 
// Promise 소비 (결과 사용)
fetchPost(1)
  .then((post) => {
    console.log("게시글:", post.title); // "게시글: 클로저 완전정복"
    return post;
  })
  .catch((err) => {
    console.error("에러:", err.message);
  });

🔗 3. Promise 체이닝

.then 체이닝

.then()이 새로운 Promise를 반환한다는 점을 이용하면, 깊게 중첩되었던 콜백들을 한 줄로 길게 늘어뜨릴 수 있습니다. 영호 리드 님이 영철이의 '지옥' 코드를 어떻게 구출했는지 확인해 보세요.

// ✅ 우아한 코드 (Pro Approach) — Promise 체이닝
// 🦁 영호의 리팩토링
 
function loadPostDetail(postId) {
  return fetchPost(postId)
    .then((post) => {
      // post를 받고, author 조회 → Promise 반환
      return fetchUser(post.authorId).then((author) => ({ post, author }));
    })
    .then(({ post, author }) => {
      // author까지 받고, 댓글 조회
      return fetchComments(postId).then((comments) => ({
        post,
        author,
        comments,
      }));
    })
    .then(({ post, author, comments }) => {
      // 모든 데이터 준비 완료
      renderPostDetail({ post, author, comments });
    })
    .catch((err) => {
      // 어느 단계에서 에러가 나도 여기 하나에서 처리
      console.error("게시글 상세 로드 실패:", err.message);
      showErrorUI();
    });
}

핵심 규칙: .then() 콜백에서 값을 반환하면 그 값으로 resolved된 Promise가 반환된다. Promise를 반환하면 그 Promise가 완료될 때까지 기다린다.

에러 처리 — .catch와 .finally

약속이 깨졌을 때(Reject)를 대비하는 안전장치입니다. 어느 단계에서 문제가 생기든, 한 곳에서 깔끔하게 수습할 수 있습니다.

fetchPost(1)
  .then((post) => {
    throw new Error("의도적 에러"); // .then 안에서 throw해도 .catch로 이동
    return post;
  })
  .then((post) => {
    // 앞에서 에러가 났으므로 이 .then은 건너뜀
    console.log("이건 실행 안 됨");
  })
  .catch((err) => {
    // 체인의 어느 지점에서 에러가 나도 여기서 처리
    console.error("에러 캐치:", err.message);
    // catch에서 값을 반환하면 에러 복구 — 이후 .then이 정상 실행됨
    return { title: "기본 게시글" };
  })
  .then((post) => {
    // catch가 에러를 복구했으므로 여기 실행됨
    console.log("복구된 게시글:", post.title);
  })
  .finally(() => {
    // 성공/실패 무관하게 항상 실행 (로딩 스피너 숨기기 등)
    hideLoadingSpinner();
  });

⚡ 4. Promise 병렬 실행 패턴

순차 실행이 아닌 동시에 여러 비동기 작업 을 실행해야 할 때 사용한다.

Promise.all — 전부 성공해야 진행

여러 작업이 서로를 기다릴 필요가 없을 때, 한꺼번에 출발시키는 방법입니다. 영수네 커뮤니티에서 게시글의 모든 구성 요소를 동시에 불러오는 장면을 상상해 보세요.

// 영수네 커뮤니티 — 게시글 상세 + 작성자 + 댓글을 동시에 조회
 
async function loadPostDetail(postId) {
  const [post, comments, relatedPosts] = await Promise.all([
    fetchPost(postId),           // 병렬 시작
    fetchComments(postId),       // 병렬 시작
    fetchRelatedPosts(postId),   // 병렬 시작
  ]);
  // 세 요청이 모두 완료된 후에 실행
  return { post, comments, relatedPosts };
}
 
// 하나라도 reject되면 전체가 reject! (단점)
// 게시글 조회 성공, 댓글 조회 실패 → loadPostDetail 전체 실패

사용 기준: 모든 요청이 반드시 성공 해야 다음 단계를 진행할 수 있을 때.

Promise.allSettled — 결과 무관하게 모두 수집

// 대시보드 — 일부 위젯이 실패해도 나머지는 보여줘야 할 때
 
const results = await Promise.allSettled([
  fetchPostCount(),     // 성공
  fetchUserCount(),     // 서버 오류로 실패
  fetchCommentCount(),  // 성공
]);
 
results.forEach((result) => {
  if (result.status === "fulfilled") {
    console.log("성공:", result.value);
  } else {
    console.error("실패:", result.reason);
    // 실패한 것만 기본값으로 대체
  }
});
// 게시글 수와 댓글 수는 정상 표시, 유저 수만 "--"로 표시

사용 기준: 일부 실패해도 성공한 결과만이라도 활용 해야 할 때.

Promise.race — 가장 빠른 것만

// 타임아웃 구현 — 3초 안에 응답 없으면 에러 처리
 
function withTimeout(promise, ms) {
  const timeout = new Promise((_, reject) =>
    setTimeout(() => reject(new Error(`${ms}ms 타임아웃`)), ms)
  );
  return Promise.race([promise, timeout]);
}
 
try {
  const post = await withTimeout(fetchPost(1), 3000);
  renderPost(post);
} catch (err) {
  if (err.message.includes("타임아웃")) {
    showTimeoutMessage();
  }
}

사용 기준: 가장 빠른 응답 만 필요할 때, 또는 타임아웃 구현.

Promise.any — 가장 먼저 성공한 것

// 여러 CDN 중 가장 빠른 곳에서 리소스 로드
 
const image = await Promise.any([
  loadFrom("cdn1.youngsu.com/logo.png"),
  loadFrom("cdn2.youngsu.com/logo.png"),
  loadFrom("cdn3.youngsu.com/logo.png"),
]);
// 첫 번째로 성공한 CDN의 이미지 사용
// 전부 실패하면 AggregateError 발생

사용 기준: 여러 소스 중 하나만 성공 하면 되는 폴백 전략.

메서드성공 기준실패 기준반환값
Promise.all전부 성공하나라도 실패결과 배열
Promise.allSettled없음 (항상 fulfilled)-상태+값 배열
Promise.race가장 먼저 완료가장 먼저 실패단일 결과
Promise.any가장 먼저 성공전부 실패단일 결과

📝 마무리 퀴즈

Q1. 아래 코드의 출력 순서를 예측하라.

console.log("1");
 
Promise.resolve("2").then((val) => {
  console.log(val);
});
 
console.log("3");

정답: 1, 3, 2

💡 상세 해설:

  • console.log("1"): 동기 코드, 즉시 실행.
  • Promise.resolve("2").then(...): Promise가 이미 fulfilled 상태여도, .then 콜백은 마이크로태스크 큐 에 등록된다. 현재 동기 코드가 모두 끝난 후에 실행된다.
  • console.log("3"): 동기 코드, 즉시 실행.
  • 현재 콜 스택이 비워진 후 마이크로태스크 큐에서 .then 콜백 실행 → 2 출력.
  • 📌 핵심 기억법: "Promise의 .then지금 당장이 아니라, 현재 코드가 다 끝난 후에 실행된다."

Q2. Promise.allPromise.allSettled를 언제 각각 써야 하는가?

정답:

  • Promise.all: 모든 결과가 다 성공해야만 의미가 있을 때 (예: 결제 처리 전 재고 확인 + 유저 잔액 확인)
  • Promise.allSettled: 일부 실패해도 성공한 결과를 활용 해야 할 때 (예: 대시보드 여러 위젯 로드)

💡 상세 해설:

  • Promise.all빠르고 단호하다: 하나가 실패하면 즉시 전체 reject. 결제 같이 "전부 확인되지 않으면 진행 불가"인 경우 적합.
  • Promise.allSettled관대하고 완전하다: 모든 Promise가 완료될 때까지 기다리고, 성공/실패 결과를 전부 배열로 준다. 부분 성공으로 UI를 구성해야 할 때 적합.
  • 📌 핵심 기억법: "all = 전부 아니면 포기 / allSettled = 어떻든 다 모아와"

Q3. 영철이의 테스트 타임 — 긴급 디버깅

영수 PM님이 걱정스러운 표정으로 찾아왔습니다. "영철 님, 게시글 리스트에서 스크롤을 빨리 하면 데이터가 꼬이는 것 같아요. 이전 요청이 끝나기도 전에 새 데이터가 뒤섞여서 들어오는데, 이거 해결 가능할까요?"

// 🐣 영철이의 코드 — 이전 요청이 완료되기 전에 새 요청이 날아가는 문제
window.addEventListener("scroll", async () => {
  const posts = await fetchMorePosts(currentPage++);
  appendPosts(posts);
});

이 코드의 문제는 무엇이고, 어떻게 고쳐야 하는가?

정답: 이전 요청이 완료되기 전에 새 요청이 중복 발생하고 응답 순서가 뒤섞이는 Race Condition 문제. 플래그 변수 또는 AbortController로 중복 요청을 방지해야 한다.

💡 상세 해설:

// ✅ 리팩토링 — 로딩 중 중복 요청 방지
let isLoading = false;
 
window.addEventListener("scroll", async () => {
  if (isLoading) return; // 이미 로딩 중이면 무시
 
  isLoading = true;
  try {
    const posts = await fetchMorePosts(currentPage++);
    appendPosts(posts);
  } finally {
    isLoading = false; // 성공/실패 무관하게 플래그 해제
  }
});
 
// ✅ 더 고급: AbortController로 이전 요청 취소
let controller = null;
 
window.addEventListener("scroll", async () => {
  if (controller) controller.abort(); // 이전 요청 취소
  controller = new AbortController();
 
  try {
    const posts = await fetchMorePosts(currentPage++, controller.signal);
    appendPosts(posts);
  } catch (err) {
    if (err.name === "AbortError") return; // 취소는 에러 아님
    console.error(err);
  }
});
  • 📌 핵심 기억법: "비동기 이벤트 핸들러는 중복 호출 방지가 기본 이다. 플래그 또는 AbortController를 항상 챙겨라."

🐣 영철이의 퇴근 일기

점심 먹고 와서 졸린 걸 억지로 버텼는데, Promise 병렬 실행 패턴 배우면서 갑자기 눈이 번쩍 뜨였다. Promise.all, Promise.allSettled... 이것들을 내가 그동안 순차로 await만 해왔는데, 병렬로 돌리면 성능이 훨씬 좋아진다는 걸 직접 코드로 보니까 충격이었다.

사실 콜백 지옥 코드를 보면서 "이게 뭐야..." 싶었는데, 그 당시 개발자들은 이게 최선이었겠지. Promise가 나왔을 때 얼마나 혁신적이었을까. 다음엔 async/await을 배운다는데, 더 편해진다고 한다. 기대된다.

💡 오늘의 교훈: "비동기 작업은 정말 순서가 꼭 필요한 게 아니라면, 기다리지 말고 동시에 진행하는 것이 기본입니다. Promise.all로 성능의 날개를 달아주세요."

오늘은 영호 님이 코드리뷰에서 Promise.allSettled 예시 보여줬을 때 "오, 이게 있었어?" 했는데, 나중에 활용해볼 수 있을 것 같다. 집 가서 오늘 배운 거 정리 좀 해야겠다. 기분 좋은 하루였다.


🔗 더 알아보기