비동기 입문 — setTimeout·콜백·이벤트 루프
JS 는 한 줄씩 도는데 어떻게 동시에 보일까. 비동기의 첫 인상.
JS 는 단일 스레드입니다. 한 번에 한 줄씩만 실행. 그런데 어떻게 setTimeout, fetch, 클릭 이벤트가 동시에 도는 것처럼 보일까요? 답은 이벤트 루프. 15편은 비동기의 첫 인상 — 동기 vs 비동기, setTimeout, 콜백 패턴과 그 한계까지.
동기 vs 비동기 — 한 줄 차이
// 동기 — 한 줄이 끝나야 다음 줄
console.log("1");
heavyTask(); // 5초 걸림 — 그동안 모든 게 멈춤
console.log("2");
// 비동기 — 시작만 하고 다음 줄로
console.log("1");
setTimeout(() => console.log("2"), 5000); // 5초 뒤 큐에 들어감
console.log("3");
// 출력: 1, 3, 2
핵심. 비동기 함수는 "시작 신호만 주고 돌아옴". 결과는 나중에 콜백·Promise 로 전달. JS 는 그동안 다른 일을 함.
이벤트 루프 — JS 의 심장
JS 엔진의 모습을 단순화하면:
- 콜 스택 — 지금 실행 중인 함수들 쌓이는 곳
- Web API / Node API — setTimeout·fetch·DOM 이벤트를 처리하는 외부 영역 (브라우저·Node 가 제공)
- 태스크 큐 — 끝난 비동기 결과 (콜백) 들이 대기
- 이벤트 루프 — 콜 스택이 비면 큐의 첫 콜백을 스택에 올림
console.log("a");
setTimeout(() => console.log("b"), 0);
// ^ 0ms 뒤에 큐로 — 하지만 동기 코드 다 끝나야 실행
Promise.resolve().then(() => console.log("c"));
// ^ 마이크로태스크 큐 (우선순위 더 높음)
console.log("d");
// 출력: a, d, c, b
마이크로태스크 vs 매크로태스크. Promise 콜백·queueMicrotask 는 마이크로 — 현재 동기 코드 끝나자마자, 다음 매크로 전에 모두 실행. setTimeout·setInterval·이벤트는 매크로. 그래서 위 출력 순서가 c → b.
setTimeout · setInterval — 가장 기본 비동기
// 1초 뒤 한 번
const id = setTimeout(() => {
console.log("실행");
}, 1000);
// 취소
clearTimeout(id);
// 0.5초마다 반복
const tid = setInterval(() => {
console.log("tick");
}, 500);
clearInterval(tid);
// 0ms 도 가능 — "다음 매크로태스크로 미루기"
setTimeout(() => {
// 동기 코드 다 끝난 뒤
}, 0);
setTimeout 의 정확도 한계. "정확히 1000ms" 가 아니라 "최소 1000ms 이후 큐에서 차례가 되면". 콜 스택이 바쁘면 더 늦어집니다. 정확한 타이밍·애니메이션은 requestAnimationFrame.
콜백 패턴 — 옛 방식
// 파일 읽기 (Node 의 fs)
fs.readFile("config.json", "utf8", (err, data) => {
if (err) {
console.error(err);
return;
}
console.log(data);
});
// 사용자 입력 받기 (브라우저)
btn.addEventListener("click", (event) => {
console.log("클릭!");
});
// 자체 비동기 함수 만들기
function loadUser(id, callback) {
setTimeout(() => {
callback(null, { id, name: "준성" });
}, 100);
}
loadUser(1, (err, user) => {
console.log(user);
});
콜백 헬 — 왜 Promise 가 나왔나
// "다단계 비동기" 가 콜백으로는 들여쓰기 지옥
loadUser(1, (err, user) => {
if (err) return console.error(err);
loadOrders(user.id, (err, orders) => {
if (err) return console.error(err);
loadItems(orders[0].id, (err, items) => {
if (err) return console.error(err);
loadDetail(items[0].id, (err, detail) => {
if (err) return console.error(err);
// 진짜 코드는 여기 ↓ 5단 들여쓰기
console.log(detail);
});
});
});
});
"콜백 헬" 또는 "피라미드 오브 둠". 에러 처리도 매번 같은 패턴 반복 — 이걸 해결하려고 ES2015 가 Promise 를 표준화했습니다 (16편).
콜백 vs Promise — 같은 작업 비교
// 콜백
fs.readFile("a.txt", "utf8", (err, a) => {
if (err) return cb(err);
fs.readFile("b.txt", "utf8", (err, b) => {
if (err) return cb(err);
cb(null, a + b);
});
});
// Promise (Node 의 fs.promises)
const a = await fs.readFile("a.txt", "utf8");
const b = await fs.readFile("b.txt", "utf8");
return a + b;
이벤트 큐 우선순위 — 한 표
| 종류 | 예시 | 우선순위 |
|---|---|---|
| 동기 코드 | 일반 statements | 즉시 |
| 마이크로태스크 | Promise.then, queueMicrotask, await 뒤 | 동기 직후 / 매크로 전 |
| RAF | requestAnimationFrame | 다음 화면 그리기 전 |
| 매크로태스크 | setTimeout, setInterval, IO, 이벤트 | 한 번에 하나 |
긴 동기 작업 — 메인 스레드 막지 않기
// ❌ 1억 번 반복 — 그동안 UI 동결
for (let i = 0; i < 100000000; i++) { ... }
// ✅ 청크로 잘라 비동기로
function chunkedTask(items, chunkSize = 1000) {
let i = 0;
function next() {
const end = Math.min(i + chunkSize, items.length);
for (; i < end; i++) process(items[i]);
if (i < items.length) {
setTimeout(next, 0); // 매크로로 미루기 → UI 그릴 틈
}
}
next();
}
// 더 좋은 옵션: Web Worker — 별도 스레드로
한 줄 원칙. "한 동기 블록은 16ms 안에 끝나야 부드러운 60fps". 더 오래 걸리는 작업은 잘게 자르거나 Web Worker 로.
16편 — Promise 와 async/await
콜백 헬 탈출. Promise.then·async·await·Promise.all 까지.
이전: 14편 DOM · 현재: 15편 (중급) · 다음 → 16편 Promise · 진행: 15/26