자바스크립트 교재 · 15편 / 26편

비동기 입문 — setTimeout·콜백·이벤트 루프

JS 는 한 줄씩 도는데 어떻게 동시에 보일까. 비동기의 첫 인상.

중급읽는 시간 7분2026-05-17
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 뒤동기 직후 / 매크로 전
RAFrequestAnimationFrame다음 화면 그리기 전
매크로태스크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

© 2026 주나이테크(주) @JUNAITECH