C-6: 비동기 기초와 Promise
안녕하세요, 홍순구 튜터입니다. 지난 시간 우리는 모던 JavaScript 문법과 모듈을 배웠어요. 템플릿 리터럴로 글자와 변수를 깔끔하게 섞고, ?.와 ??로 없을지 모르는 값을 안전하게 다뤘죠. 그리고 한 파일에 쌓여 있던 코드를 data.js·format.js·main.js로 나눠, 처음으로 진짜 프로젝트의 골격을 갖췄어요.
그때 우리 게시물 데이터는 data.js에 우리가 직접 적어둔 배열이었어요. feedPosts를 손으로 타이핑했죠. 그런데 진짜 인스타그램의 게시물은 우리 코드 안에 없어요. 멀리 떨어진 서버(server)에 있고, 화면을 열 때마다 받아와야 해요. 문제는 받아오는 데 시간이 걸린다는 거예요. "데이터 줘"라고 요청하면 답이 올 때까지 기다려야 하는데, 그동안 화면이 멈춰 있으면 안 되겠죠.
오늘은 이렇게 시간이 걸리는 일을 다루는 비동기(asynchronous) JavaScript의 기초를 배워요. 동기와 비동기가 뭐가 다른지부터 시작해서, 콜백이 깊어지면 생기는 문제, JavaScript가 한 번에 한 줄씩 일하면서도 멈추지 않는 비밀(이벤트 루프), 그리고 기다림을 우아하게 다루는 Promise까지 갑니다.
지난 시간 (C-5) 오늘 (C-6)
┌──────────────────────────┐ ┌──────────────────────────┐
│ data.js 에 직접 적은 배열 │ ──▶ │ "시간이 걸리는 일" 을 다룸 │
│ 결과가 그 자리에서 바로 나옴 │ │ setTimeout · 콜백 · 이벤트루프 │
│ (동기) │ │ Promise 로 기다림을 관리 │
└──────────────────────────┘ └──────────────────────────┘
오늘 코드는 화면(DOM)을 아직 건드리지 않아요. 결과는 전부 콘솔(console.log)로 확인합니다. "기다림"이라는 개념 자체에 먼저 익숙해지는 시간이에요.
💡 오늘 수업의 핵심 — "시간이 걸리는 일은 맡겨두고 다음 줄로 넘어가는 게 비동기다. 콜백이 깊어지면 Promise로 펴고, then/catch/finally로 성공·실패·마무리를 나눠 다룬다." 🎯
🎯 학습 목표
- 동기와 비동기가 무엇이고, 왜 비동기가 필요한지 이해합니다.
setTimeout으로 "시간이 걸리는 일"을 흉내 내고, 논블로킹 동작을 눈으로 확인합니다.- 콜백이 중첩되면서 생기는 콜백 지옥(callback hell) 문제를 체험합니다.
- 이벤트 루프(Call Stack · Task Queue · Microtask Queue)로 실행 순서가 정해지는 원리를 이해합니다.
new Promise로 약속을 만들고, pending · fulfilled · rejected 세 상태를 구분합니다.then·catch·finally로 성공 값·실패 이유·마무리를 나눠 처리합니다.- Promise 체이닝으로 콜백 지옥을 평평한 일렬로 펴냅니다.
Step 1: 동기 vs 비동기 — 기다림의 본질
지금까지 우리가 쓴 코드는 모두 동기(synchronous)였어요. 위에서 아래로, 한 줄이 끝나야 다음 줄이 실행됐죠. "동기"라는 말이 어렵게 들리지만, 사실 우리가 자연스럽게 떠올리는 순서 그대로예요.
// instagram-clone-frontend/js/async-demo.js
console.log("1. 사진 고르기");
console.log("2. 필터 입히기");
console.log("3. 업로드 완료");
출력은 당연히 1 → 2 → 3 순서예요. 사진을 고르고, 필터를 입히고, 업로드가 끝나죠. 각 줄이 순식간에 끝나니까 기다릴 일이 없어요.
그런데 현실에는 시간이 걸리는 일이 있어요. 서버에서 데이터를 받아오거나, 큰 사진을 업로드하거나요. 이런 일을 흉내 내려고 setTimeout을 써볼게요. setTimeout(할 일, 기다릴 시간)은 "이 일을 몇 밀리초 뒤에 해줘"라는 뜻이에요. 두 번째 인자는 밀리초 단위라 1000이 1초예요.
console.log("A. 업로드 시작");
setTimeout(() => {
console.log("C. 업로드 끝! (2초 뒤)");
}, 2000);
console.log("B. 기다리는 동안 다른 일도 해요");
여기서 재밌는 일이 벌어져요. 출력 순서가 A → B → C예요.
A. 업로드 시작
B. 기다리는 동안 다른 일도 해요
C. 업로드 끝! (2초 뒤)
C를 적은 줄이 B보다 위에 있는데, B가 먼저 나왔죠. JavaScript는 setTimeout을 만나면 "2초 뒤에 할 일"을 따로 맡겨두고, 멈추지 않고 바로 다음 줄(B)로 넘어가요. 그리고 2초가 지나면 그제야 C를 실행해요. 이렇게 기다리는 일 때문에 멈추지 않는 것을 논블로킹(non-blocking)이라고 불러요. 이게 비동기의 핵심이에요.
동기 (기다림이 막아섬) 비동기 (맡겨두고 진행)
────────────────── ──────────────────
A 시작 A 시작
⏳ 2초 멈춤 ───── 화면도 멈춤! B 먼저 (안 멈춤) ✓
C 끝 ⏳ 2초는 따로 흐름
B C 끝 (2초 후)
만약 이게 동기였다면, 2초 동안 화면 전체가 얼어붙었을 거예요. 버튼도 안 눌리고 스크롤도 안 되고요. 비동기 덕분에 "업로드 중…"을 띄워두고도 사용자는 다른 걸 계속 할 수 있어요.
Step 2: 콜백과 콜백 지옥
방금 setTimeout에 넘긴 () => { ... } 함수, 기억나죠? 지난 함수 시간에 배운 콜백(callback)이에요. 나중에 실행되라고 다른 함수에 넘기는 함수죠. setTimeout은 "시간이 다 되면 이 콜백을 불러줘"라는 약속으로 콜백을 받아요.
그런데 여기서 문제가 하나 생겨요. 비동기는 순서를 보장하지 않잖아요? 만약 "사진 업로드가 끝난 다음에 썸네일을 만들고, 그게 끝난 다음에 알림을 보내라"처럼 순서가 중요하다면 어떻게 할까요? 방법은 하나예요. 콜백 안에 또 콜백을 넣는 거죠.
// instagram-clone-frontend/js/async-demo.js
setTimeout(() => {
console.log("1단계: 사진 업로드");
setTimeout(() => {
console.log("2단계: 썸네일 생성");
setTimeout(() => {
console.log("3단계: 팔로워에게 알림");
}, 300);
}, 300);
}, 300);
출력은 우리가 원한 대로 1단계 → 2단계 → 3단계 순서예요. 순서는 지켜졌어요. 그런데 코드 모양을 보세요. 단계가 늘어날수록 오른쪽으로 계속 밀려나요.
setTimeout(() => {
setTimeout(() => {
setTimeout(() => {
setTimeout(() => { ← 단계가 늘수록
setTimeout(() => { ← 오른쪽으로 끝없이 밀림
...
이렇게 콜백이 겹겹이 쌓여 오른쪽으로 밀려나는 모습을 콜백 지옥(callback hell)이라고 불러요. 들여쓰기가 깊어져서 어디가 어디 짝인지 알아보기 힘들고, 중간에 에러 처리라도 넣으면 더 엉망이 돼요. 실무에서 5단계, 6단계로 이어지면 정말 읽기 괴로워지죠.
💡 순서를 지키려고 콜백을 중첩했더니, 코드가 오른쪽으로 무너져 내렸어요. "순서 보장"과 "읽기 좋은 코드"를 둘 다 잡을 방법이 필요해요. 그게 잠시 뒤에 배울 Promise예요.
Step 3: 이벤트 루프 — JavaScript는 왜 안 멈추나
Promise로 넘어가기 전에, 잠깐 멈춰서 원리 하나를 짚을게요. 궁금하지 않았나요? JavaScript는 한 번에 한 줄씩만 실행해요. 일꾼이 한 명뿐이라는 뜻이에요(이걸 싱글 스레드라고 불러요). 그런데 아까 봤듯이 setTimeout을 만나도 멈추지 않고 다음 줄로 넘어갔죠. 일꾼이 한 명인데 어떻게 기다리면서 동시에 다른 일도 할까요?
답은 이벤트 루프(event loop)라는 구조에 있어요. 핵심 장소가 세 군데예요.
┌──────────────────┐
│ Call Stack │ 지금 실행 중인 함수가 쌓이는 곳 (일꾼이 일하는 책상)
│ console.log() │ 한 번에 하나씩만 처리
└────────┬─────────┘
│ setTimeout(콜백, 0) 을 만나면 → 콜백을 잠깐 밖에 맡김
▼
┌──────────────────┐ 타이머 끝 ┌──────────────────────────┐
│ (타이머가 돈다) │ ────────▶ │ Task Queue — setTimeout 줄 │
└──────────────────┘ └──────────────────────────┘
Promise.then ┌──────────────────────────┐
────────▶ │ Microtask Queue — Promise 줄│ (새치기!)
└──────────────────────────┘
▲ │
└──────── 이벤트 루프 ◀───────────────┘
Call Stack 이 비면, Microtask 줄을 먼저 다 비우고 → 그다음 Task 줄에서 하나
규칙은 이래요. 책상(Call Stack)의 동기 코드를 전부 처리한 다음, 대기 줄에서 다음 일을 가져와요. 그런데 대기 줄이 두 개예요. setTimeout 콜백이 서는 Task Queue와, Promise 콜백이 서는 Microtask Queue죠. 그리고 Microtask 줄이 항상 먼저예요. Promise는 새치기를 할 수 있는 셈이에요.
말로만 들으면 어려우니 직접 출력 순서로 확인해요. (Promise는 바로 다음 Step에서 자세히 배워요. 여기선 "더 빠른 줄에 선다"는 것만 봐주세요.)
// instagram-clone-frontend/js/async-demo.js
console.log("첫 번째 — 지금 바로");
setTimeout(() => console.log("네 번째 — setTimeout(Task Queue)"), 0);
Promise.resolve().then(() => console.log("세 번째 — Promise(Microtask Queue)"));
console.log("두 번째 — 이것도 지금 바로");
출력은 이래요.
첫 번째 — 지금 바로
두 번째 — 이것도 지금 바로
세 번째 — Promise(Microtask Queue)
네 번째 — setTimeout(Task Queue)
코드 순서대로면 setTimeout이 Promise보다 위에 있으니 먼저 나와야 할 것 같죠? 그런데 결과는 정반대예요. 먼저 동기 코드(첫 번째·두 번째)가 다 나오고, 그다음 Microtask 줄의 Promise(세 번째), 마지막에 Task 줄의 setTimeout(네 번째)이 나와요.
🙋 학생 질문 — "튜터님, setTimeout에 0초를 줬는데 왜 마지막이에요?"
좋은 질문이에요. setTimeout(콜백, 0)은 "0초 뒤에"가 아니라 "지금 당장 할 일을 다 끝낸 뒤, 가장 빠르게"라는 뜻에 가까워요. 0을 줘도 콜백은 일단 Task Queue라는 대기 줄에 가서 서야 해요.
그런데 이벤트 루프 규칙상, 책상에 있는 동기 코드를 먼저 다 처리하고, 그다음 Microtask 줄(Promise)을 비우고, 마지막에야 Task 줄(setTimeout)을 봐요. 그래서 0초여도 동기 코드와 Promise보다 뒤로 밀리는 거예요. "0초 = 즉시"가 아니라 "0초 = 차례가 오면 바로"라고 기억하면 돼요.
이 순서 규칙을 알아두면, 나중에 코드가 예상과 다른 순서로 도는 걸 보고 당황하지 않아요. "아, Promise는 Microtask라 먼저 가는구나" 하고 바로 이해되죠.
Step 4: Promise 만들기 — 세 상태
이제 오늘의 주인공 Promise예요. 단어 그대로 "약속"이라는 뜻이에요. "지금은 결과가 없지만, 나중에 꼭 줄게"라고 약속하는 상자라고 생각하면 돼요. 음식을 주문하고 받는 진동벨을 떠올려보세요. 주문하면 벨을 받죠(약속). 음식이 나오면 벨이 울리고(성공), 재료가 떨어지면 직원이 알려줘요(실패).
Promise는 new Promise로 만들어요. 안에 함수를 하나 넘기는데, 그 함수는 resolve와 reject 두 개를 받아요. 일이 성공하면 resolve(값)을 부르고, 실패하면 reject(이유)를 불러요.
// instagram-clone-frontend/js/async-demo.js
const uploadPromise = new Promise((resolve, reject) => {
const ok = true; // 업로드가 성공했다고 가정
setTimeout(() => {
if (ok) {
resolve("업로드 완료!"); // 성공 → fulfilled(이행) 상태로
} else {
reject("업로드 실패..."); // 실패 → rejected(거부) 상태로
}
}, 400);
});
console.log(uploadPromise);
만든 직후 바로 찍어보면 이렇게 나와요.
Promise { <pending> }
pending은 "대기 중"이라는 뜻이에요. 아직 0.4초가 안 지나서 결과가 안 나온 상태죠. Promise는 딱 세 가지 상태만 가져요.
new Promise(...)
│
▼
┌───────────┐
│ pending │ 대기 — 아직 결과 없음
└─────┬─────┘
resolve│reject
┌────┴─────┐
▼ ▼
┌───────────┐ ┌───────────┐
│ fulfilled │ │ rejected │
│ (이행) │ │ (거부) │
└───────────┘ └───────────┘
성공한 값 보관 실패한 이유 보관
처음엔 무조건 pending이에요. 그러다 resolve가 불리면 fulfilled(이행), reject가 불리면 rejected(거부)로 딱 한 번 바뀌어요. 한번 결과가 정해지면 다시는 안 바뀌어요. 진동벨이 한번 울리면 끝인 것처럼요.
그런데 이렇게 만든 Promise의 결과를 어떻게 꺼내 쓸까요? console.log로 찍으면 pending만 보이잖아요. 결과를 받는 방법이 다음 Step의 then이에요.
Step 5: 결과 다루기 — then / catch / finally
Promise의 결과는 then·catch·finally 세 가지로 받아요. 역할이 깔끔하게 나뉘어요. 성공하면 then, 실패하면 catch, 성공이든 실패든 마지막엔 finally예요.
// instagram-clone-frontend/js/async-demo.js
const likePromise = new Promise((resolve, reject) => {
setTimeout(() => resolve(42), 200);
});
likePromise
.then((count) => console.log(`좋아요 ${count}개를 받았어요`))
.catch((error) => console.log(`문제 발생: ${error}`))
.finally(() => console.log("성공이든 실패든, 로딩 표시는 끝"));
resolve(42)가 불렸으니 성공이에요. then이 그 값 42를 받아서 출력해요.
좋아요 42개를 받았어요
성공이든 실패든, 로딩 표시는 끝
then이 성공 값을 받고, finally가 마지막에 불렸죠. catch는 실패가 없었으니 건너뛰었어요. then의 count로 들어온 게 바로 resolve에 넘긴 값이에요. 진동벨이 울려서 음식을 받은 거죠.
이번엔 실패하는 경우를 볼게요. resolve 대신 reject를 부르면요.
const likePromise = new Promise((resolve, reject) => {
setTimeout(() => reject("네트워크 끊김"), 200);
});
likePromise
.then((count) => console.log(`좋아요 ${count}개를 받았어요`))
.catch((error) => console.log(`문제 발생: ${error}`))
.finally(() => console.log("성공이든 실패든, 로딩 표시는 끝"));
이번엔 reject가 불렸으니 then은 건너뛰고 catch가 실패 이유를 받아요.
문제 발생: 네트워크 끊김
성공이든 실패든, 로딩 표시는 끝
성공이든 실패든 finally는 항상 실행돼요. 그래서 "로딩 중…" 표시를 끄는 것처럼, 결과와 상관없이 꼭 해야 할 마무리에 잘 어울려요.
💡 진동벨 비유로 정리해요.
then은 벨이 울려 음식을 받는 것,catch는 재료가 떨어졌다는 사과를 받는 것,finally는 어느 쪽이든 식사를 마치고 정리하는 것이에요.
Step 6: Promise 체이닝 — 콜백 지옥 탈출
이제 Step 2의 콜백 지옥을 Promise로 풀어볼 차례예요. Promise의 진짜 힘은 체이닝(chaining), 즉 then을 줄줄이 잇는 데 있어요. 비결은 하나예요. then이 돌려준 값이 다음 then의 입력으로 넘어가요.
먼저 각 단계를 Promise를 돌려주는 함수로 만들어요.
// instagram-clone-frontend/js/async-demo.js
function uploadPhoto() {
return new Promise((resolve) => setTimeout(() => resolve("photo.jpg"), 200));
}
function makeThumbnail(file) {
return new Promise((resolve) => setTimeout(() => resolve(`thumb-${file}`), 200));
}
function notifyFollowers(thumb) {
return new Promise((resolve) => setTimeout(() => resolve(`알림 전송 완료: ${thumb}`), 200));
}
이제 세 단계를 then으로 이어요.
uploadPhoto()
.then((file) => makeThumbnail(file)) // 1단계 결과 → 2단계 입력
.then((thumb) => notifyFollowers(thumb)) // 2단계 결과 → 3단계 입력
.then((result) => console.log(result)) // 최종 결과
.catch((error) => console.log(`중간에 실패: ${error}`)); // 어디서 터져도 여기로
출력은 이래요.
알림 전송 완료: thumb-photo.jpg
uploadPhoto가 돌려준 "photo.jpg"가 첫 then의 file로, 거기서 만든 "thumb-photo.jpg"가 다음 then의 thumb로 흘러가요. 값이 한 단계씩 손에서 손으로 넘어가는 거예요. Step 2의 중첩과 비교하면 차이가 한눈에 보여요.
콜백 지옥 (오른쪽으로 밀림) Promise 체이닝 (아래로 곧게)
────────────────────── ────────────────────────
do1(() => { do1()
do2(() => { .then(do2)
do3(() => { .then(do3)
done(); .then(done)
}); .catch(onError)
});
});
오른쪽으로 무너지던 코드가 아래로 곧게 흐르는 일렬이 됐어요. 게다가 에러 처리도 깔끔해요. 콜백 지옥에선 단계마다 에러를 따로 챙겨야 했는데, 체이닝에선 맨 끝에 catch 하나만 두면 어느 단계에서 터지든 거기로 모여요. 1단계에서 실패하든 3단계에서 실패하든, catch 한 곳에서 다 받아내죠.
💡 콜백 지옥의 두 가지 고통이 한 번에 풀렸어요. 깊은 들여쓰기는 평평한 일렬로, 단계마다 흩어지던 에러 처리는 끝의
catch하나로 모였어요.
마무리
오늘 우리는 "시간이 걸리는 일"을 다루는 비동기의 기초와, 그걸 우아하게 관리하는 Promise를 배웠어요. 되짚어볼게요.
- 동기 vs 비동기 — 동기는 한 줄이 끝나야 다음 줄. 비동기는 시간이 걸리는 일을 맡겨두고 다음 줄로 넘어가요(논블로킹).
setTimeout으로 그 동작을 눈으로 확인했죠. - 콜백 지옥 — 순서를 지키려고 콜백을 중첩하면 코드가 오른쪽으로 무너져요. 읽기도 힘들고 에러 처리도 흩어져요.
- 이벤트 루프 — JavaScript는 일꾼이 한 명(싱글 스레드)이지만, Call Stack을 비운 뒤 Microtask 줄(Promise)을 먼저, Task 줄(setTimeout)을 나중에 처리해서 멈추지 않아요.
- Promise 세 상태 —
pending에서 시작해resolve면fulfilled,reject면rejected로 딱 한 번 바뀌어요. - then / catch / finally — 성공은
then, 실패는catch, 마무리는 항상finally. - Promise 체이닝 —
then을 이으면 값이 다음 단계로 흐르고, 끝의catch하나가 모든 에러를 받아요. 콜백 지옥이 평평하게 펴졌어요.
비동기는 처음엔 "왜 순서가 바뀌지?" 하고 헷갈릴 수 있어요. 괜찮아요. "시간이 걸리는 일은 맡겨두고 먼저 진행한다"는 한 가지만 붙들면, 나머지는 코드를 돌려보며 자연스럽게 익숙해져요. 오늘 출력 순서가 코드 순서와 다르게 나오는 걸 직접 봤으니, 비동기가 무슨 뜻인지 몸으로 느꼈을 거예요.
다음 시간 예고
오늘 Promise 하나를 다루는 법을 배웠어요. 그런데 실무에선 이런 상황이 자주 와요. "사진 세 장을 동시에 업로드하고, 셋 다 끝나면 다음으로 넘어가고 싶다." Promise를 하나씩 then으로 잇는 걸로는 부족하죠. 다음 시간엔 여러 Promise를 한꺼번에 다루는 Promise.all·Promise.race 같은 도구를 배워요.
그리고 then을 줄줄이 잇는 체이닝도, 사실 더 깔끔하게 쓰는 방법이 있어요. 마치 동기 코드처럼 위에서 아래로 읽히는 async/await예요. 오늘 배운 Promise가 그 바탕이에요. 비동기 코드를 사람이 읽기 가장 편한 모습으로 진화시키는 그 마지막 단계를, 다음 시간에 만나요. 기대하세요!
과제
오늘 배운 setTimeout, 콜백, Promise, then/catch/finally를 직접 익혀볼 차례예요. 기초 → 응용 → 탐구 순서로 풀어보세요. 모든 과제는 콘솔(console.log)에서 확인하고, 오늘 배운 내용만으로 충분히 풀 수 있어요. (아직 async/await는 쓰지 않아요. 다음 시간에 배워요.)
[구현] setTimeout으로 비동기 순서 직접 확인하기 (기초)
setTimeout이 어떻게 순서를 바꾸는지 직접 찍어보세요.
console.log로"주문 받음"을 먼저 찍으세요.setTimeout으로 1.5초 뒤에"커피 나왔습니다"를 찍게 하세요.- 그 아래에
console.log로"다음 손님 받기"를 찍으세요. - 실행했을 때 출력 순서가 어떻게 되는지 예상하고, 실제로 맞는지 확인하세요.
"커피 나왔습니다"가 왜 마지막에 나오는지 한 문장으로 설명해보세요.
[구현] Promise 만들고 then/catch로 결과 받기 (응용)
성공할 수도, 실패할 수도 있는 Promise를 만들어보세요.
new Promise로checkLogin이라는 Promise를 만드세요. 안에서 변수success를 두고,setTimeout1초 뒤에success가true면resolve("로그인 성공"), 아니면reject("비밀번호 틀림")을 부르게 하세요..then으로 성공 메시지를,.catch로 실패 메시지를,.finally로"로그인 시도 끝"을 찍으세요.success를true로 한 번,false로 한 번 바꿔 실행해보세요.then과catch중 어느 쪽이 불리는지,finally는 두 경우 모두 불리는지 확인하세요.
[탐구] 이벤트 루프 출력 순서 예측하기 (탐구)
아래 네 줄이 어떤 순서로 출력될지, 먼저 종이에 예상을 적고 실행해 맞춰보세요.
console.log("①")setTimeout(() => console.log("②"), 0)Promise.resolve().then(() => console.log("③"))console.log("④")- 정답 순서를 적고, 왜
②(setTimeout)가③(Promise)보다 뒤에 나오는지 이벤트 루프의 두 대기 줄(Task Queue·Microtask Queue)로 설명해보세요.
생각해볼 주제
정답을 적는 문제가 아니에요. 오늘 배운 것의 "왜"를 곱씹어보는 질문들이에요. 스스로 답을 만들어본 뒤, 예시답안과 비교해보세요.
1. 비동기가 없다면, 화면은 어떻게 될까?
오늘 setTimeout으로 "2초 걸리는 일"을 맡겨두고 다음 줄로 넘어가는 걸 봤어요. 만약 JavaScript에 비동기가 없어서, 시간이 걸리는 일을 만나면 무조건 끝날 때까지 멈춰야 한다고 상상해보세요. 서버에서 게시물을 받아오는 데 3초가 걸린다면, 그 3초 동안 화면은 어떤 상태일까요? 사용자는 스크롤이나 버튼 클릭을 할 수 있을까요? 비동기가 "느린 일을 빠르게" 만드는 게 아니라 "느린 일이 다른 걸 막지 않게" 만드는 기술이라는 점을, 멈춘 화면을 떠올리며 생각해보세요.
2. 콜백 지옥은 단지 "보기 싫은" 문제일까?
Step 2에서 콜백을 중첩했더니 코드가 오른쪽으로 밀려났고, Step 6에서 Promise 체이닝으로 그걸 평평하게 폈어요. 그런데 콜백 지옥의 진짜 문제가 들여쓰기가 깊어 "보기 싫은" 것뿐일까요? 5단계 중첩 콜백에서 3단계가 실패하면 어디서 에러를 잡아야 할지 떠올려보세요. 반대로 Promise 체이닝에선 catch 하나로 끝의 어디서 터지든 받아냈죠. "읽기 좋다"는 것이 단순히 미관이 아니라, 에러를 빠뜨리지 않고 안전하게 다루는 것과 어떻게 연결되는지 곱씹어보세요.
3. Promise는 왜 "약속"이라는 이름일까?
Promise를 만들면 결과가 바로 나오지 않고 pending 상태부터 시작했어요. 그리고 한번 fulfilled나 rejected로 정해지면 다시 안 바뀌었죠. 이게 현실의 "약속"과 어떻게 닮았는지 생각해보세요. 친구가 "이따 연락할게"라고 약속하면, 그 순간엔 연락이 온 것도 안 온 것도 아닌 상태(pending)예요. 나중에 연락이 오거나(fulfilled) 못 오게 되거나(rejected) 둘 중 하나로 정해지죠. 만약 Promise의 상태가 정해진 뒤에도 계속 바뀔 수 있다면, 우리 코드는 어떤 혼란을 겪을까요? "한번 정해지면 안 바뀐다"는 규칙이 왜 약속을 믿을 수 있게 만드는지 생각해보세요.
✅ 예시 답안정답 보기
🎯 [과제 1 예시답안] setTimeout으로 비동기 순서 직접 확인하기
수업에서 setTimeout이 코드 순서를 바꾸는 걸 봤죠. 이 과제는 그 동작을 카페 주문 상황으로 직접 찍어보는 거예요.
핵심 접근
console.log 두 개 사이에 setTimeout을 끼우면 돼요. 위에서 아래로 적었어도, setTimeout 안의 일은 시간이 지난 뒤에야 실행된다는 걸 눈으로 확인하는 게 목표예요.
예시 구현
console.log("주문 받음");
setTimeout(() => {
console.log("커피 나왔습니다");
}, 1500);
console.log("다음 손님 받기");
출력 순서는 이래요.
주문 받음
다음 손님 받기
커피 나왔습니다
"커피 나왔습니다"를 적은 줄이 "다음 손님 받기"보다 위에 있는데도 마지막에 나왔어요. JavaScript는 setTimeout을 만나면 "1.5초 뒤에 할 일"을 따로 맡겨두고, 멈추지 않고 바로 다음 줄("다음 손님 받기")로 넘어가거든요. 커피를 기다리는 동안 다음 손님을 받는 것과 똑같아요.
채점 포인트
console.log("주문 받음")을 맨 먼저 적었는가setTimeout의 두 번째 인자를1500(1.5초)으로 주었는가"다음 손님 받기"를setTimeout아래에 적었는가- 출력 순서가
주문 받음 → 다음 손님 받기 → 커피 나왔습니다로 나오는 걸 확인했는가 "커피 나왔습니다"가 마지막인 이유를 "시간이 걸리는 일은 맡겨두고 다음 줄로 넘어가서"로 설명했는가
흔한 실수
setTimeout(console.log("커피 나왔습니다"), 1500)처럼 함수 호출을 바로 넣음 → 이러면console.log가 즉시 실행돼서 순서가 안 바뀌어요.() => { ... }로 감싸 "나중에 실행할 함수"로 넘겨야 해요.- 두 번째 인자를
1.5로 적음 → 밀리초 단위라1.5는 0.0015초예요. 1.5초는1500이에요.
🎯 [과제 2 예시답안] Promise 만들고 then/catch로 결과 받기
성공할 수도, 실패할 수도 있는 Promise를 직접 만들고, then·catch·finally로 결과를 받는 과제예요. 오늘 배운 Promise의 흐름을 손으로 짜보는 거죠.
핵심 접근
new Promise 안에서 setTimeout으로 시간을 흘려보낸 뒤, success 값에 따라 resolve 또는 reject를 불러요. 그리고 .then·.catch·.finally를 줄줄이 이어 각 경우를 처리해요.
예시 구현
const success = true; // false 로 바꿔서도 실행해보세요
const checkLogin = new Promise((resolve, reject) => {
setTimeout(() => {
if (success) {
resolve("로그인 성공");
} else {
reject("비밀번호 틀림");
}
}, 1000);
});
checkLogin
.then((message) => console.log(message))
.catch((error) => console.log(error))
.finally(() => console.log("로그인 시도 끝"));
success가 true일 때 출력은 이래요.
로그인 성공
로그인 시도 끝
success를 false로 바꾸면 reject가 불려서 then은 건너뛰고 catch가 받아요.
비밀번호 틀림
로그인 시도 끝
성공이면 then, 실패면 catch가 불리는데, finally는 두 경우 모두 마지막에 실행돼요. 그래서 "로그인 시도 끝"은 항상 나와요.
채점 포인트
new Promise((resolve, reject) => ...)형태로 만들었는가setTimeout안에서success에 따라resolve/reject를 갈라 불렀는가.then으로 성공 메시지,.catch로 실패 메시지를 받았는가.finally로"로그인 시도 끝"을 찍었는가success를true/false로 바꿔,then과catch가 갈리고finally는 둘 다 불리는 걸 확인했는가
흔한 실수
resolve만 부르고reject를 안 씀 → 실패 상황을 만들 수 없어catch가 영영 안 불려요. 두 경우를 모두 만들어야 차이를 봐요.then·catch를 각각 다른 곳에 떼어 씀 → 한 Promise 뒤에 점(.)으로 줄줄이 이어야 해요.checkLogin.then(...).catch(...).finally(...)형태로요.
🎯 [과제 3 예시답안] 이벤트 루프 출력 순서 예측하기
코드 순서와 실행 순서가 왜 다른지, 네 줄로 직접 맞춰보는 탐구예요. 이벤트 루프의 두 대기 줄을 머릿속에 그릴 수 있으면 성공이에요.
핵심 접근
동기 코드가 먼저, 그다음 Microtask(Promise), 마지막에 Task(setTimeout). 이 규칙대로 네 줄의 순서를 따져요.
예시 구현
console.log("①");
setTimeout(() => console.log("②"), 0);
Promise.resolve().then(() => console.log("③"));
console.log("④");
정답 순서는 이래요.
①
④
③
②
순서를 따라가 볼게요.
① console.log → 동기 코드, 지금 바로 실행
② setTimeout → Task Queue 줄에 가서 대기
③ Promise.then → Microtask Queue 줄에 가서 대기
④ console.log → 동기 코드, 지금 바로 실행
실행: 동기(① ④) 먼저 → Microtask(③) → Task(②)
①과 ④는 동기 코드라 적힌 순서대로 먼저 나와요. 그다음 대기 줄을 보는데, Microtask 줄(③ Promise)이 Task 줄(② setTimeout)보다 먼저예요. 그래서 ③ 다음에 ②가 나와요.
채점 포인트
- 동기 코드
①·④가 가장 먼저 나온다고 적었는가 ③(Promise)이②(setTimeout)보다 먼저라고 맞혔는가- 최종 순서를
① → ④ → ③ → ②로 적었는가 ②가 뒤인 이유를 "setTimeout은 Task Queue, Promise는 먼저인 Microtask Queue"로 설명했는가
흔한 실수
setTimeout(…, 0)이라②가 바로 나온다고 예상함 → 0초여도 Task Queue에 줄을 서고, 동기 코드와 Microtask보다 뒤예요. "0초 = 차례가 오면 바로"예요.③과②의 순서를 바꿔 적음 → Microtask(Promise)가 Task(setTimeout)보다 항상 먼저라는 게 핵심이에요.
💭 [생각해볼 주제 예시답안]
1. 비동기가 없다면, 화면은 어떻게 될까?
문제 상황 요약
setTimeout으로 "시간이 걸리는 일"을 맡겨두고 다음 줄로 넘어가는 걸 봤어요. 만약 JavaScript에 비동기가 없어서, 느린 일을 만나면 끝날 때까지 무조건 멈춰야 한다면 화면은 어떻게 될까요?
튜터의 가이드 및 해설
핵심은 JavaScript의 일꾼이 한 명뿐이라는 데 있어요. 화면을 그리고, 클릭에 반응하고, 스크롤을 처리하는 게 전부 같은 일꾼의 몫이에요. 만약 서버에서 게시물을 받는 3초 동안 이 일꾼이 거기에 묶여 있으면, 그동안 다른 일은 아무것도 못 해요.
결과는 "멈춘 화면"이에요. 버튼을 눌러도 반응이 없고, 스크롤도 안 되고, 입력창에 글자도 안 쳐져요. 사용자 눈엔 앱이 멈춘 것처럼 보이죠. 이걸 "블로킹(blocking)"이라고 불러요. 한 가지 느린 일이 나머지 전부를 막아버리는 거예요.
여기서 흔한 오해를 짚어야 해요. 비동기는 느린 일을 빠르게 만드는 게 아니에요. 서버 응답이 3초 걸리는 건 비동기를 써도 똑같이 3초예요. 비동기가 하는 일은 "그 3초가 다른 걸 막지 않게" 하는 거예요. 받아오는 동안 "로딩 중…"을 띄우고, 사용자는 스크롤이나 다른 버튼을 계속 쓸 수 있죠. 같은 3초여도 화면이 살아 있느냐 멈춰 있느냐가 갈려요.
🎯 면접관을 홀리는 핵심 멘트
"비동기는 느린 일을 빠르게 만드는 게 아니라, 느린 일이 다른 걸 막지 않게 만드는 기술이에요. JavaScript는 일꾼이 한 명이라, 서버 응답을 동기로 기다리면 그동안 화면 전체가 얼어붙어요. 비동기 덕분에 같은 3초여도 사용자는 멈춤 없이 앱을 계속 쓸 수 있습니다."
2. 콜백 지옥은 단지 "보기 싫은" 문제일까?
문제 상황 요약
콜백을 중첩했더니 코드가 오른쪽으로 밀려났고, Promise 체이닝으로 평평하게 폈어요. 그런데 콜백 지옥의 진짜 문제가 들여쓰기가 깊어 "보기 싫은" 것뿐일까요?
튜터의 가이드 및 해설
들여쓰기가 깊어 읽기 힘든 건 겉으로 드러난 증상이에요. 더 깊은 문제는 에러 처리에 있어요. 5단계 중첩 콜백을 떠올려보세요. 3단계에서 일이 실패하면, 그 에러를 어디서 잡아야 할까요? 콜백마다 성공과 실패를 따로 챙겨야 하니, 단계마다 에러 처리 코드가 흩어져요. 한 군데라도 빠뜨리면, 실패가 조용히 묻혀서 나중에 엉뚱한 곳에서 터지죠.
Promise 체이닝은 이 문제를 구조로 풀어요. then을 줄줄이 잇고 맨 끝에 catch 하나만 두면, 어느 단계에서 터지든 거기로 모여요. 1단계에서 실패하든 4단계에서 실패하든, 에러는 체인을 타고 내려와 끝의 catch가 받아내죠. 에러를 빠뜨릴 구멍이 사라지는 거예요.
그래서 "읽기 좋다"는 건 단순한 미관이 아니에요. 코드가 평평하고 에러가 한곳에 모이면, 사람이 실수할 여지가 줄어요. 깊은 중첩에선 짝이 안 맞는 괄호 하나, 빠뜨린 에러 처리 하나가 버그가 되는데, 평평한 체인에선 그런 함정이 없죠. 읽기 좋은 구조가 곧 안전한 구조예요.
🎯 면접관을 홀리는 핵심 멘트
"콜백 지옥의 진짜 문제는 미관이 아니라 에러 처리가 흩어지는 거예요. 단계마다 실패를 따로 챙기다 한 군데만 빠뜨려도 버그가 조용히 묻히죠. Promise 체이닝은 끝의
catch하나로 어느 단계의 에러든 모아 받아서, '읽기 좋은 구조'가 곧 '에러를 빠뜨리지 않는 구조'가 됩니다."
3. Promise는 왜 "약속"이라는 이름일까?
문제 상황 요약
Promise를 만들면 결과가 바로 안 나오고 pending부터 시작했어요. 그리고 한번 fulfilled나 rejected로 정해지면 다시 안 바뀌었죠. 이게 현실의 "약속"과 어떻게 닮았을까요?
튜터의 가이드 및 해설
친구가 "이따 연락할게"라고 약속하는 순간을 떠올려보세요. 그 순간엔 연락이 온 것도, 안 온 것도 아니에요. 결과가 아직 안 정해진 상태죠. 이게 pending이에요. 그러다 나중에 연락이 오거나(fulfilled), 사정이 생겨 못 오게 되거나(rejected) 둘 중 하나로 정해져요. Promise의 세 상태가 약속의 흐름과 똑 닮았어요.
더 중요한 건 "한번 정해지면 안 바뀐다" 는 규칙이에요. 친구가 연락을 했으면 그건 일어난 일이에요. 나중에 "사실 연락 안 한 걸로 하자"라고 뒤집을 수 없죠. Promise도 마찬가지예요. resolve나 reject가 한 번 불리면, 그 상태로 굳어요. 두 번째로 resolve를 불러도 무시돼요.
만약 이 규칙이 없어서 상태가 정해진 뒤에도 계속 바뀔 수 있다면 어떨까요? 우리 코드는 믿을 게 없어져요. then에서 "성공"을 받아 처리했는데, 잠시 뒤 그 Promise가 "실패"로 바뀐다면? 이미 한 일을 되돌려야 하고, 결과가 언제 또 바뀔지 몰라 불안하죠. "한번 정해지면 끝"이라는 규칙이 있어야, 우리는 then이 준 값을 믿고 다음 일을 진행할 수 있어요. 약속이 약속인 이유는, 한번 지키면(또는 깨지면) 그걸로 확정되기 때문이에요.
🎯 면접관을 홀리는 핵심 멘트
"Promise가 '약속'인 이유는 한번 정해지면 안 바뀌기 때문이에요.
pending에서 시작해fulfilled나rejected로 딱 한 번 굳고, 그 뒤엔 절대 안 뒤집혀요. 이 불변성 덕분에then이 준 결과를 믿고 다음 일을 진행할 수 있죠. 상태가 계속 바뀐다면 어떤 결과도 신뢰할 수 없을 거예요."