자바스크립트 고급 #4 이벤트 루프와 태스크

중급 #2 에서 Promise와 async/await의 사용법을 봤습니다. 이번 글은 그 비동기가 실제로 어떻게 동작하는지 — 이벤트 루프와 태스크 큐를 들여다봅니다.

자바스크립트는 싱글 스레드 #

먼저 가장 중요한 사실 — 자바스크립트는 한 번에 한 줄만 실행합니다. 한 함수가 동작하는 동안 다른 함수는 절대 끼어들 수 없습니다.

동기 실행
function a() {
  b();
  console.log('a 끝');
}
function b() {
  console.log('b 끝');
}

a();
// b 끝
// a 끝

이걸 가능하게 하는 자료구조가 콜 스택(call stack) 입니다. 함수가 호출되면 스택에 쌓이고, 끝나면 빠져나갑니다.

콜 스택이 비어 있어야 다음 일을 함 #

자바스크립트가 비동기를 다루는 핵심 규칙:

콜 스택이 비어 있을 때만 다음 비동기 작업이 실행된다.

순서 확인
console.log('1');
setTimeout(() => console.log('2'), 0);
console.log('3');

// 1, 3, 2 (이 순서)

setTimeout(..., 0) 인데도 2가 마지막. 이유는 — 콜 스택에 동기 코드가 모두 끝나야 타이머 콜백이 실행되기 때문입니다. 0ms 라도요.

이벤트 루프 #

이걸 관리하는 게 이벤트 루프(event loop) 입니다. 단순화하면 다음 동작을 끝없이 반복합니다.

이벤트 루프 의사코드
while (true) {
  if (콜 스택이 비어 있음) {
    if (마이크로태스크 큐에 대기 중) {
      // 큐가 빌 때까지 모두 실행
      마이크로태스크 큐의 모든 작업 실행;
    } else if (매크로태스크 큐에 대기 중) {
      매크로태스크 하나 꺼내서 실행;
    }
  }
}

핵심 포인트가 두 가지 있습니다.

  1. 콜 스택이 비어야 큐를 본다 — 동기 코드가 우선
  2. 마이크로태스크가 매크로태스크보다 우선

마이크로태스크 vs 매크로태스크 #

자바스크립트의 비동기는 두 가지 큐로 나뉩니다.

종류어디서 들어오는가
매크로태스크 (Task)setTimeout, setInterval, I/O, UI 이벤트
마이크로태스크 (Microtask)Promise.then/.catch/.finally, queueMicrotask, await 다음 부분

마이크로태스크가 매크로태스크보다 먼저 실행됩니다. 그것도 큐가 비어 모든 마이크로태스크가 실행될 때까지.

핵심 예제 #

실행 순서 비교
console.log('1');

setTimeout(() => console.log('2'), 0);

Promise.resolve().then(() => console.log('3'));

console.log('4');

// 출력 순서: 1, 4, 3, 2

설명:

  1. console.log('1') — 동기 실행
  2. setTimeout — 매크로태스크 큐에 등록
  3. Promise.resolve().then(...) — 마이크로태스크 큐에 등록
  4. console.log('4') — 동기 실행
  5. 콜 스택 비었음 → 마이크로태스크 큐 확인 → 3 출력
  6. 마이크로태스크 큐 비어있음 → 매크로태스크 큐 확인 → 2 출력

setTimeout이 0ms 인데도 Promise.then보다 늦은 이유가 이것입니다.

await 다음 부분도 마이크로태스크 #

async/await 의 동작
async function a() {
  console.log('1');
  await null;
  console.log('3');
}

console.log('start');
a();
console.log('2');

// start, 1, 2, 3

a() 안에서 await null을 만나면 — 그 시점에 함수가 잠깐 멈추고, 다음 줄(console.log('3'))이 마이크로태스크로 등록 됩니다. 컨트롤이 호출자로 돌아가서 console.log('2')가 실행되고, 그 다음 마이크로태스크가 실행됩니다.

이게 async/await의 실제 동작 원리입니다.

queueMicrotask — 직접 마이크로태스크 만들기 #

queueMicrotask
console.log('1');
queueMicrotask(() => console.log('2'));
console.log('3');

// 1, 3, 2

브라우저와 Node 모두 이 함수를 가집니다. Promise.resolve().then(...)보다 더 짧고 명확하게 마이크로태스크를 만들 수 있습니다. 라이브러리 내부 구현에서 가끔 등장합니다.

같은 큐 안에서는 FIFO #

같은 큐 안의 작업들은 들어온 순서대로 처리됩니다.

큐 안에서는 순서 유지
setTimeout(() => console.log('A'), 0);
setTimeout(() => console.log('B'), 0);
setTimeout(() => console.log('C'), 0);
// A, B, C
마이크로태스크도 동일
Promise.resolve().then(() => console.log('A'));
Promise.resolve().then(() => console.log('B'));
Promise.resolve().then(() => console.log('C'));
// A, B, C

한 매크로태스크 vs 모든 마이크로태스크 #

다시 한 번 강조 — 매크로태스크 하나 처리할 때마다 마이크로태스크 큐가 비어질 때까지 모든 마이크로태스크를 끝내고 다음 매크로태스크로 갑니다.

섞어 보기
setTimeout(() => {
  console.log('macro 1');
  Promise.resolve().then(() => console.log('micro 1'));
}, 0);

setTimeout(() => console.log('macro 2'), 0);

// macro 1, micro 1, macro 2

macro 1이 실행되며 micro 1이 마이크로태스크 큐에 들어갑니다. macro 1이 끝나고 콜 스택이 비면 — 매크로태스크 큐로 가기 전에 마이크로태스크 큐를 먼저 비워요. 그래서 micro 1macro 2보다 먼저 출력됩니다.

실전에서 의식해야 할 부분 #

1) 무한 마이크로태스크 → 매크로태스크 굶기 #

마이크로태스크 안에서 또 마이크로태스크를 끝없이 등록하면, 매크로태스크와 UI 업데이트가 차단됩니다.

이러지 마세요
function loop() {
  Promise.resolve().then(loop);
}
loop();
// 브라우저 멈춤 — 다른 이벤트, 렌더링 모두 못 함

setTimeout으로 같은 짓을 하면 매크로태스크라 UI가 갱신될 짬이 있습니다. 마이크로태스크는 더 우선이라 더 위험합니다.

2) DOM 업데이트와 마이크로태스크 #

Promise.resolve().then(...) 안의 DOM 변경은 렌더링되기 전에 적용됩니다. 같은 프레임 안에서 DOM을 여러 번 바꿔도 한 번만 그려져요.

DOM과 마이크로태스크
button.addEventListener('click', () => {
  el.textContent = '1';
  Promise.resolve().then(() => {
    el.textContent = '2';   // 사용자는 '2'만 봄
  });
});

이게 React 같은 라이브러리가 마이크로태스크를 활용해 batch 업데이트를 할 수 있는 이유 중 하나입니다.

3) await 사이의 동기 코드 #

await로 잠시 멈춤
async function process() {
  console.log('1');
  await fetch('/api');
  console.log('2');   // fetch 가 끝난 후에야 실행
}

process();
console.log('3');     // process 가 await 에서 멈춘 동안 실행

process()await를 만나면 잠시 멈춥니다. 그동안 호출자(console.log('3'))가 진행할 기회를 가져요. fetch가 끝나면 process의 다음 줄이 마이크로태스크로 등록됩니다.

requestAnimationFramerequestIdleCallback #

브라우저 환경에는 매크로태스크 외에 렌더링과 동기화 된 콜백이 두 가지 있습니다.

requestAnimationFrame — 다음 프레임에 실행 #

rAF
requestAnimationFrame(() => {
  // 다음 프레임 그려지기 직전 실행 (보통 60fps = 16.7ms 간격)
  el.style.transform = `translateX(${x}px)`;
});

애니메이션, DOM 변경의 그룹화에 적합합니다. setTimeout보다 더 정확하게 프레임에 맞춰 실행됩니다.

requestIdleCallback — 한가할 때 실행 #

rIC
requestIdleCallback(() => {
  // 메인 스레드가 한가할 때 실행 — 우선순위 낮은 작업
  saveAnalytics();
});

급하지 않은 작업(분석 데이터 저장, 백그라운드 미리계산)에 적합합니다. 사용자 인터랙션을 막지 않게.

Node의 이벤트 루프 — 살짝 다름 #

Node도 이벤트 루프가 있지만, 단계가 더 세분화됐습니다.

단계처리
timerssetTimeout / setInterval 콜백
I/O callbacks일부 시스템 콜백
poll새 I/O 이벤트 받기
checksetImmediate 콜백
closesocket.on('close', ...)

setImmediateprocess.nextTick 같은 Node 전용 도구도 있는데 — process.nextTick은 마이크로태스크보다도 먼저 실행됩니다. 일반 코드에서 자주 쓸 일은 없습니다.

마무리 #

이번 글에서 정리한 내용:

  • 자바스크립트는 싱글 스레드, 콜 스택이 비어야 비동기 처리
  • 이벤트 루프 — 콜 스택 빌 때까지 동기, 그 다음 마이크로 → 매크로
  • 마이크로태스크 — Promise.then, queueMicrotask, await 다음
  • 매크로태스크 — setTimeout, I/O, UI 이벤트
  • 한 매크로 처리 후 마이크로 큐가 빌 때까지 비움
  • 무한 마이크로태스크는 UI 차단
  • requestAnimationFrame / requestIdleCallback은 렌더링과 동기화
  • Node의 이벤트 루프는 단계 더 세분화

다음 글(#5 메모리 모델과 GC)에서는 자바스크립트가 메모리를 어떻게 관리하는지 — 가비지 컬렉터의 동작과 누수가 일어나는 패턴들을 다룹니다.

X