dev.syw

매크로/마이크로태스크 큐, 렌더링 타이밍, 동시성 제어, 백프레셔까지 비동기 실행 순서를 정밀하게 다룬다.

이벤트 루프와 동시성 심화

입문편 7강에서 Promise와 async/await의 사용법을 익혔다면, 이번 강에서는 한 단계 더 내려가 런타임이 비동기 코드를 실제로 어떻게 스케줄링하는지를 살펴봅니다. "왜 이 코드가 이 순서로 실행되는가?"라는 질문에 답할 수 있어야 복잡한 비동기 버그를 정확히 진단하고, 성능에 민감한 애니메이션이나 대용량 네트워크 요청을 올바르게 설계할 수 있습니다.

자바스크립트는 단일 스레드 언어이지만 이벤트 루프(event loop) 덕분에 논블로킹(non-blocking) 동작이 가능합니다. 이 메커니즘의 세부 동작을 모르면 미묘한 순서 버그, 렌더링 지연, 메모리 누수가 발생했을 때 원인을 찾기 어렵습니다.

학습 목표

  • 매크로태스크 큐마이크로태스크 큐의 차이를 설명하고 실행 순서를 정밀하게 예측할 수 있다.
  • Promise, queueMicrotask, setTimeout, requestAnimationFrame이 어느 큐에 들어가는지 이해하고, 브라우저 렌더링 파이프라인과의 관계를 파악한다.
  • 동시성 제어 패턴(병렬 한도, 큐잉, 디바운스/스로틀)의 내부 동작을 직접 구현할 수 있다.
  • AbortController와 타임아웃을 활용해 취소 가능한 비동기 흐름을 설계한다.
  • Node.js 이벤트 루프 페이즈(timers → poll → check)와 브라우저 이벤트 루프의 차이를 구분한다.

이벤트 루프 구조: 콜 스택과 두 가지 큐

자바스크립트 런타임의 핵심은 세 가지 구성 요소로 이루어집니다.

  1. 콜 스택(Call Stack) — 현재 실행 중인 함수의 프레임이 쌓이는 LIFO 구조.
  2. 매크로태스크 큐(Task Queue)setTimeout, setInterval, I/O 콜백, UI 이벤트 핸들러 등이 대기하는 큐.
  3. 마이크로태스크 큐(Microtask Queue)Promise.then/catch/finally, queueMicrotask, MutationObserver 콜백이 대기하는 큐.

이벤트 루프의 한 회전(tick)은 다음 순서로 진행됩니다.

[콜 스택 비워짐]
  → 마이크로태스크 큐가 빌 때까지 모두 처리
  → (브라우저) 렌더링 기회 발생 (requestAnimationFrame → style/layout/paint)
  → 매크로태스크 큐에서 항목 하나 꺼내 실행
  → 다시 콜 스택 비워짐 → 반복

⚠️ 주의 마이크로태스크는 "콜 스택이 비워질 때마다" 큐가 완전히 소진될 때까지 실행됩니다. 마이크로태스크 안에서 마이크로태스크를 재귀적으로 계속 추가하면 렌더링이 차단되는 무한 루프와 같은 상황이 발생합니다.

아래 코드는 실행 순서를 예측하는 연습에 자주 등장하는 고전적 예제입니다.

console.log('1: 동기');                           // ① 콜 스택

setTimeout(() => console.log('2: setTimeout'), 0); // ⑤ 매크로태스크 큐

Promise.resolve()
  .then(() => console.log('3: Promise.then'))       // ③ 마이크로태스크 큐
  .then(() => console.log('4: Promise.then 체인')); // ④ 마이크로태스크 큐 (③ 실행 후 추가)

queueMicrotask(() => console.log('5: queueMicrotask')); // Promise.then(③)보다 나중에 등록되므로 ③ 다음에 실행

console.log('6: 동기');                           // ② 콜 스택

// 출력 순서: 1 → 6 → 3 → 5 → 4 → 2
JavaScript

queueMicrotaskPromise.resolve().then()과 동일한 우선순위를 가지며 콜백 등록 순서에 따라 처리됩니다. setTimeout(fn, 0)은 매크로태스크이므로 모든 마이크로태스크가 소진된 후에야 실행됩니다.

마이크로태스크 폭풍(Microtask Storm) 방지

// ❌ 마이크로태스크를 무한히 추가하면 렌더링 차단
function infiniteMicro() {
  queueMicrotask(infiniteMicro);
}

// ✅ 큰 작업은 매크로태스크(setTimeout)나 scheduler.yield()로 분산
async function chunkedWork(items) {
  for (let i = 0; i < items.length; i++) {
    process(items[i]);
    if (i % 100 === 0) {
      // 100개마다 렌더링 기회를 양보
      await new Promise(resolve => setTimeout(resolve, 0));
    }
  }
}
JavaScript

Promise·queueMicrotask·setTimeout·requestAnimationFrame 실행 순서 정밀 추적

네 가지 API가 함께 등장하는 시나리오를 단계별로 분석합니다.

function runAll() {
  // A: 동기
  console.log('A');

  // B: 매크로태스크
  setTimeout(() => console.log('B: setTimeout'), 0);

  // C: rAF — 다음 페인트 직전 (매크로태스크와 마이크로태스크 사이)
  requestAnimationFrame(() => console.log('C: rAF'));

  // D: 마이크로태스크
  Promise.resolve().then(() => {
    console.log('D: Promise.then');
    // E: D 안에서 추가된 마이크로태스크
    queueMicrotask(() => console.log('E: nested queueMicrotask'));
  });

  // F: 동기
  console.log('F');
}

runAll();
// 브라우저 출력: A → F → D → E → C → B
// (렌더링이 필요한 경우 C가 B 이전에 오고, 렌더링 불필요 시 순서 달라질 수 있음)
JavaScript
API큐 종류타이밍
Promise.then마이크로태스크현재 태스크 종료 즉시
queueMicrotask마이크로태스크현재 태스크 종료 즉시
requestAnimationFrame렌더링 전 콜백다음 프레임 직전
setTimeout(fn, 0)매크로태스크다음 이벤트 루프 틱
setImmediate (Node.js)check 페이즈poll 페이즈 종료 후
process.nextTick (Node.js)네이티브 마이크로태스크 큐마이크로태스크보다 우선

💡 TIP requestAnimationFrame은 "매크로태스크"로 분류되기도 하지만, 브라우저 스펙에서는 렌더링 단계 직전에 별도로 처리됩니다. 실제 측정 결과 마이크로태스크 소진 후, setTimeout(fn, 0) 이전에 실행됩니다.

브라우저 렌더링 파이프라인과 reflow/paint 타이밍

브라우저는 JS 실행과 렌더링을 같은 스레드에서 처리합니다. 이벤트 루프에서 매크로태스크 하나가 끝나면 브라우저는 렌더링이 필요한지 판단하고, 필요하다면 다음 단계를 진행합니다.

requestAnimationFrame 콜백 실행
  → Style recalculation (스타일 재계산)
  → Layout / Reflow (레이아웃 계산)
  → Paint (픽셀 페인팅)
  → Composite (레이어 합성)

강제 동기 레이아웃(Forced Synchronous Layout) 은 대표적인 성능 함정입니다.

// ❌ 읽기(offsetWidth)와 쓰기(style 변경)가 교차 반복 → 프레임마다 강제 reflow
function badAnimation(elements) {
  elements.forEach(el => {
    const width = el.offsetWidth;        // 읽기: 레이아웃 강제 실행
    el.style.width = width + 10 + 'px'; // 쓰기
  });
}

// ✅ 읽기를 먼저 일괄 처리 후 쓰기를 일괄 처리 (FastDOM 패턴)
function goodAnimation(elements) {
  const widths = elements.map(el => el.offsetWidth); // 읽기 일괄
  elements.forEach((el, i) => {
    el.style.width = widths[i] + 10 + 'px';         // 쓰기 일괄
  });
}
JavaScript

애니메이션 로직은 반드시 requestAnimationFrame 내에서 처리해야 프레임 타이밍과 동기화됩니다.

function animate(timestamp) {
  // timestamp는 DOMHighResTimeStamp — 페인트 직전의 정확한 시각
  const progress = (timestamp % 2000) / 2000; // 2초 주기
  element.style.transform = `translateX(${progress * 300}px)`;
  requestAnimationFrame(animate);
}
requestAnimationFrame(animate);
JavaScript

동시성 제어 패턴: 병렬 한도·큐잉·디바운스·스로틀

병렬 한도(Concurrency Limit)

Promise.all은 모든 작업을 즉시 시작합니다. 수백 개의 HTTP 요청을 동시에 날리면 서버나 브라우저 연결 한도를 초과할 수 있습니다. 최대 병렬 실행 수를 제한하는 함수를 구현해 봅니다.

/**
 * tasks: (() => Promise<T>)[] — 프로미스를 반환하는 함수 배열
 * limit: 동시에 실행할 최대 개수
 */
async function pLimit(tasks, limit) {
  const results = [];
  const executing = new Set();

  for (const task of tasks) {
    const p = Promise.resolve().then(() => task()); // 즉시 시작

    results.push(p);
    executing.add(p);

    // 완료 시 Set에서 제거
    p.finally(() => executing.delete(p));

    // 실행 중인 작업이 한도에 도달하면 하나가 끝날 때까지 대기
    // 한도 제어용 await는 거부에 영향받지 않도록 .catch()로 무시한다.
    // (실제 거부는 마지막 Promise.all(results)에서 처리)
    if (executing.size >= limit) {
      await Promise.race(executing).catch(() => {});
    }
  }

  return Promise.all(results);
}

// 사용 예
const urls = Array.from({ length: 20 }, (_, i) => `https://api.example.com/item/${i}`);
const fetchTask = url => () => fetch(url).then(r => r.json());

const data = await pLimit(urls.map(fetchTask), 3); // 동시에 최대 3개만 실행
JavaScript

주의: 위 구현에서 어느 한 task가 reject되면, 한도 제어용 Promise.race(executing)에서 거부가 전파되어 나머지 task 등록이 중단되거나 미처리 거부(unhandled rejection)가 발생할 수 있습니다. 이를 막기 위해 한도 제어 await에는 .catch(() => {})를 붙여 거부의 영향을 받지 않게 했고, 실제 거부는 마지막 Promise.all(results)에서 처리됩니다. 만약 일부 실패를 허용하고 싶다면 task 실행 결과를 { status, value | reason } 같은 결과 객체로 감싸거나 Promise.allSettled를 사용하는 방식을 권장합니다.

디바운스(Debounce) 내부 구현

디바운스는 마지막 호출로부터 일정 시간이 지난 후 함수를 한 번만 실행합니다. 검색창 자동완성, 리사이즈 이벤트 처리에 적합합니다.

function debounce(fn, delay) {
  let timerId = null;

  return function (...args) {
    // 이전 타이머 취소 (매크로태스크 취소)
    clearTimeout(timerId);

    timerId = setTimeout(() => {
      fn.apply(this, args);
      timerId = null;
    }, delay);
  };
}

const handleSearch = debounce(async (query) => {
  const result = await searchAPI(query);
  renderResults(result);
}, 300);

input.addEventListener('input', e => handleSearch(e.target.value));
JavaScript

스로틀(Throttle) 내부 구현

스로틀은 일정 시간 동안 최대 한 번만 실행합니다. 스크롤, 마우스 이동 이벤트에 적합합니다.

function throttle(fn, interval) {
  let lastTime = 0;
  let timerId = null;

  return function (...args) {
    const now = Date.now();
    const remaining = interval - (now - lastTime);

    if (remaining <= 0) {
      // 인터벌이 지났으면 즉시 실행
      clearTimeout(timerId);
      timerId = null;
      lastTime = now;
      fn.apply(this, args);
    } else if (!timerId) {
      // 인터벌이 남았으면 trailing 호출 예약
      timerId = setTimeout(() => {
        lastTime = Date.now();
        timerId = null;
        fn.apply(this, args);
      }, remaining);
    }
  };
}

const handleScroll = throttle(() => updateScrollPosition(), 100);
window.addEventListener('scroll', handleScroll);
JavaScript

💡 TIP 디바운스와 스로틀 모두 내부적으로 setTimeout(매크로태스크)을 사용합니다. 따라서 타이머 콜백은 렌더링 이후에 실행될 수 있으며, 정밀한 프레임 동기화가 필요할 때는 requestAnimationFrame을 결합하는 것이 더 적합합니다.

취소(AbortController)와 타임아웃, 백프레셔

AbortController를 활용한 취소 가능한 비동기 흐름

AbortController는 Web API로, 시그널(signal)을 통해 비동기 작업에 취소 요청을 전달합니다.

async function fetchWithTimeout(url, timeoutMs = 5000) {
  const controller = new AbortController();
  const { signal } = controller;

  // 타임아웃 후 자동 취소
  const timeoutId = setTimeout(() => {
    controller.abort(new DOMException('Request timed out', 'TimeoutError'));
  }, timeoutMs);

  try {
    const response = await fetch(url, { signal });
    clearTimeout(timeoutId); // 성공 시 타이머 정리
    return await response.json();
  } catch (err) {
    if (err.name === 'AbortError') {
      console.warn('요청이 취소되었습니다:', err.message);
      return null;
    }
    throw err; // 다른 에러는 재throw
  }
}
JavaScript

⚠️ 주의 fetch의 경우 AbortErrorDOMException으로 던져집니다. err.name === 'AbortError'로 확인하는 것이 가장 안전합니다.

여러 요청을 하나의 컨트롤러로 묶어 일괄 취소하는 패턴도 자주 사용됩니다.

class CancellableRequest {
  #controller = new AbortController();

  async get(url) {
    return fetch(url, { signal: this.#controller.signal });
  }

  cancelAll() {
    this.#controller.abort();
    // 새 컨트롤러로 교체해 이후 요청은 계속 사용 가능하게
    this.#controller = new AbortController();
  }
}

const req = new CancellableRequest();
req.get('/api/data1');
req.get('/api/data2');
req.cancelAll(); // 두 요청 모두 취소
JavaScript

백프레셔(Backpressure) 처리

백프레셔는 생산자(producer)가 소비자(consumer)보다 빠르게 데이터를 생성할 때 발생하는 압력입니다. 이를 제어하지 않으면 메모리가 고갈될 수 있습니다.

// 큐 크기를 제한하는 간단한 비동기 채널 구현
class BoundedQueue {
  #queue = [];
  #maxSize;
  #waiters = []; // 공간이 생길 때까지 대기 중인 resolve 함수들

  constructor(maxSize = 10) {
    this.#maxSize = maxSize;
  }

  // 생산자: 큐가 가득 차면 공간이 생길 때까지 대기
  async push(item) {
    while (this.#queue.length >= this.#maxSize) {
      await new Promise(resolve => this.#waiters.push(resolve));
    }
    this.#queue.push(item);
  }

  // 소비자: 큐에서 항목을 꺼냄
  pop() {
    const item = this.#queue.shift();
    if (this.#waiters.length > 0) {
      const resolve = this.#waiters.shift();
      resolve(); // 대기 중인 생산자 깨우기
    }
    return item;
  }

  get size() { return this.#queue.length; }
}

// 사용 예: 빠른 생산자와 느린 소비자
const channel = new BoundedQueue(5);

// 생산자
async function producer() {
  for (let i = 0; i < 20; i++) {
    await channel.push(i); // 큐가 가득 차면 자동으로 대기
    console.log(`생산: ${i}, 큐 크기: ${channel.size}`);
  }
}

// 소비자
async function consumer() {
  for (let i = 0; i < 20; i++) {
    await new Promise(resolve => setTimeout(resolve, 100)); // 느린 처리
    const item = channel.pop();
    console.log(`소비: ${item}`);
  }
}

producer();
consumer();
JavaScript

Node.js 이벤트 루프 페이즈와 브라우저 차이

Node.js의 이벤트 루프는 libuv 라이브러리 위에서 동작하며, 브라우저와 달리 명시적인 페이즈(phase) 로 구분됩니다.

Node.js 이벤트 루프 페이즈 순서:

1. timers        — setTimeout, setInterval 콜백 실행
2. pending       — 이전 루프에서 지연된 I/O 콜백
3. idle/prepare  — 내부 전용
4. poll          — 새 I/O 이벤트 대기 및 처리 (가장 많은 시간 소비)
5. check         — setImmediate 콜백 실행
6. close         — 소켓 close 등 종료 콜백

각 페이즈 전환 시 process.nextTick 큐와 Promise 마이크로태스크 큐가 소진됩니다.

// Node.js에서 실행 순서 비교
setImmediate(() => console.log('A: setImmediate'));   // check 페이즈
setTimeout(() => console.log('B: setTimeout'), 0);    // timers 페이즈

Promise.resolve().then(() => console.log('C: Promise')); // 마이크로태스크
process.nextTick(() => console.log('D: nextTick'));       // nextTick 큐

console.log('E: 동기');

// 출력: E → D → C → B → A (B와 A의 순서는 환경에 따라 바뀔 수 있음)
// (B와 A의 순서는 타이머 정밀도에 따라 다름, 단 D와 C는 항상 먼저)
JavaScript

⚠️ 주의 process.nextTick은 Node.js 전용이며, 마이크로태스크 큐보다도 먼저 처리됩니다. 재귀적으로 사용하면 I/O 이벤트가 영구 차단되는 starvation이 발생할 수 있습니다.

브라우저 vs Node.js 주요 차이점

항목브라우저Node.js
렌더링 단계있음 (rAF → paint)없음
requestAnimationFrame지원미지원
setImmediate미지원지원 (check 페이즈)
process.nextTick미지원지원 (최고 우선순위 마이크로태스크)
이벤트 루프 구조W3C HTML 스펙 기준libuv 기반 6단계 페이즈
마이크로태스크 처리 시점태스크 사이마다페이즈 전환마다 + nextTick

요약

  • 마이크로태스크 큐(Promise.then, queueMicrotask)는 현재 콜 스택이 비워질 때마다 큐 전체가 소진될 때까지 실행되며, 매크로태스크(setTimeout, setInterval)보다 항상 먼저 처리된다.
  • 브라우저는 마이크로태스크 소진 후 렌더링 기회를 갖고, requestAnimationFrame 콜백은 paint 직전에 실행된다. 강제 동기 레이아웃을 피하려면 읽기/쓰기를 분리해야 한다.
  • 병렬 한도는 Promise.race를 활용한 실행 중 Set으로 구현하고, 디바운스/스로틀은 setTimeout의 취소·재등록 메커니즘을 이용한다.
  • AbortControllerfetch 등 Web API 요청을 취소하고 타임아웃을 구현할 수 있으며, 에러는 err.name === 'AbortError'로 구분한다.
  • 백프레셔는 생산자-소비자 패턴에서 큐 크기를 제한하고, 큐가 가득 찼을 때 생산자를 await으로 일시 정지시키는 방식으로 처리한다.
  • Node.js는 6단계 페이즈로 이루어진 libuv 기반 이벤트 루프를 사용하며, process.nextTick은 마이크로태스크보다도 높은 우선순위를 가진다.

연습문제

  1. 아래 코드의 출력 순서를 예측하고, 각 로그가 어떤 큐(콜 스택/마이크로태스크/매크로태스크)에서 실행되는지 설명하세요.
async function alpha() {
  console.log('a1');
  await Promise.resolve();
  console.log('a2');
}

async function beta() {
  console.log('b1');
  await new Promise(resolve => setTimeout(resolve, 0));
  console.log('b2');
}

console.log('start');
alpha();
beta();
console.log('end');
JavaScript

힌트 await Promise.resolve()는 마이크로태스크를, await new Promise(resolve => setTimeout(resolve, 0))는 마이크로태스크 + 매크로태스크 조합을 사용합니다.

  1. 최대 동시 요청 수를 2개로 제한하여 5개의 URL에서 데이터를 순서대로 가져오는 함수를 구현하세요. 각 요청 함수는 delay(ms) 헬퍼로 시뮬레이션합니다.

힌트 이 강에서 구현한 pLimit 함수를 활용하거나, 직접 executing Set을 관리하는 방식으로 구현하세요.

  1. 사용자가 입력 필드에 타이핑을 멈춘 후 500ms 뒤에 API를 호출하되, 이전 호출이 아직 진행 중이라면 자동으로 취소하는 searchWithCancel 함수를 작성하세요.

힌트 AbortController와 디바운스 패턴을 결합합니다. 이전 컨트롤러를 취소하고 새 컨트롤러를 만드는 과정에 주목하세요.

  1. Node.js 환경에서 process.nextTick, Promise.then, setImmediate, setTimeout(fn, 0) 네 가지 콜백이 각각 어느 시점에 실행되는지 실제로 확인하는 테스트 코드를 작성하고 예상 출력을 설명하세요.

힌트 모든 스케줄링 API를 동기 코드 블록 안에서 동시에 등록한 후, 출력 순서를 관찰하세요.

💡 연습문제 풀이

불러오는 중…

함께 보면 좋은 자료

댓글 0

JavaScript 심화” 강좌에 대한 댓글입니다.

댓글을 작성하려면 로그인이 필요합니다.