⏳ 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겹이 됐어요. 거기다 에러 처리를 각 단계마다 해야 하니까 코드가 피라미드처럼 쌓여가는데, 읽기도 힘들고 뭔가 잘못된 느낌이에요. 이게 '콜백 지옥'이라는 건가요?"
🦁 영호: "맞아. 전형적인 콜백 지옥이야. 그게 왜 문제냐면 — 에러 처리가 각 단계에 흩어져 있어서 빠뜨리기 쉽고, 실행 순서를 눈으로 추적하기 어렵고, 테스트하기 힘들어. 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가 나왔을 때 얼마나 혁신적이었을까. 다음엔 async/await을 배운다는데, 더 편해진다고 한다. 기대된다.
💡 오늘의 교훈: "비동기 작업은 정말 순서가 꼭 필요한 게 아니라면, 기다리지 말고 동시에 진행하는 것이 기본입니다.
Promise.all로 성능의 날개를 달아주세요."
오늘은 영호 님이 코드리뷰에서 Promise.allSettled 예시 보여줬을 때 "오, 이게 있었어?" 했는데, 나중에 활용해볼 수 있을 것 같다. 집 가서 오늘 배운 거 정리 좀 해야겠다. 기분 좋은 하루였다.