⏳ 05. 비동기 & Promise — 콜백 중첩을 정리하는 완벽한 지도
📋 개요
깊은 콜백 중첩이 왜 문제인지, Promise가 어떻게 해결했는지, Promise 체이닝과 병렬 실행 패턴을 실무 중심으로 정복합니다.
🎯 이 섹션을 읽고 나면:
- 깊은 콜백 중첩(Callback Hell)이 왜 생기고, 왜 위험한지 설명할 수 있다.
- Promise의 3가지 상태와 체이닝 메커니즘을 이해한다.
Promise.all,Promise.race,Promise.allSettled를 실무에서 선택하는 기준을 안다.
📋 목차
- 📌 이 문서를 읽기 전에
- 🤔 1. 왜 알아야 하는가? — 콜백 중첩
- 💡 2. Promise의 본질
- 🔗 3. Promise 체이닝
- ⚡ 4. Promise 병렬 실행 패턴
- 📝 마무리 퀴즈
- 🐣 영철이의 퇴근 일기
- 🔗 더 알아보기
📌 이 문서를 읽기 전에
⏱️ 예상 읽기 시간: 22분(전체) / 핵심 파트만: 14분
🗺️ 이 문서의 흐름
[콜백 중첩의 문제] → [Promise 3상태 이해] → [체이닝 패턴] → [병렬 처리 전략]
🎯 이 문서를 다 읽으면 할 수 있는 것
- 중첩 콜백 코드를 Promise 체이닝으로 리팩토링할 수 있다.
- API 병렬 호출 시
Promise.all과Promise.allSettled중 상황에 맞게 선택한다. - Promise 에러 처리를 누락 없이 구현한다.
🗺️ 이 문서의 배경 세계관: '영수네 커뮤니티'
🐣 영철: "영호 님, 게시글 상세 페이지 만들다가 코드가 너무 이상하게 생겼어요. 게시글 조회하고, 게시글 작성자 정보 조회하고, 댓글 목록 조회하는 걸 순서대로 해야 하는데... 콜백 안에 콜백이 3겹이 됐어요. 거기다 에러 처리를 각 단계마다 해야 하니까 코드가 피라미드처럼 쌓여가는데, 읽기도 힘들고 뭔가 잘못된 느낌이에요. 이게 흔히 말하는 Callback Hell인가요?"
🦁 영호: "맞아. 전형적인 깊은 콜백 중첩이야. 그게 왜 문제냐면 — 에러 처리가 각 단계에 흩어져 있어서 빠뜨리기 쉽고, 실행 순서를 눈으로 추적하기 어렵고, 테스트하기 힘들어. 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가지 폐해:
- 가독성 붕괴: 코드가 오른쪽으로 계속 밀려 피라미드 형태가 된다.
- 에러 처리 분산: 각 콜백마다
if (err)처리가 중복되어 빠뜨리기 쉽다. - 흐름 제어 어려움: 중간에 조건 분기, 반복, 취소 같은 로직을 넣기가 구조적으로 힘들다.
💡 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.all과 Promise.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가 해결한 건 단순한 들여쓰기 문제가 아니라, 성공과 실패를 하나의 흐름으로 연결하고 조합할 수 있게 만든 점이었다.
💡 오늘의 교훈: "비동기 작업은 정말 순서가 꼭 필요한 게 아니라면, 기다리지 말고 동시에 진행하는 것이 기본입니다.
Promise.all로 성능의 날개를 달아주세요."
내일은 대시보드 초기 로딩 코드를 보면서 "서로 의존하는 요청"과 "동시에 시작해도 되는 요청"을 나눠보려고 한다. 실패해도 나머지 결과를 보여줘야 하는 영역은 Promise.allSettled 후보로 따로 표시해둬야겠다.