포스트

JavaScript Event Loop 완벽 가이드 - 비동기 처리의 핵심 메커니즘

JavaScript Event Loop의 동작 원리부터 Microtask와 Macrotask의 차이, 실전 활용까지 비동기 처리의 핵심을 완벽하게 이해합니다. Call Stack, Web API, Task Queue의 상호작용을 시각화로 배우고 Promise와 setTimeout의 실행 순서를 예측하는 방법을 익힙니다. 싱글 스레드 환경에서의 비동기 처리 메커니즘을 마스터합니다.

JavaScript Event Loop 완벽 가이드 - 비동기 처리의 핵심 메커니즘

들어가며

Event Loop는 JavaScript의 비동기 처리를 가능하게 하는 핵심 메커니즘입니다.

JavaScript는 싱글 스레드 언어이지만, Event Loop 덕분에 비동기 작업을 효율적으로 처리할 수 있습니다.

Event Loop를 이해하면 setTimeout, Promise, async/await 같은 비동기 코드의 동작 순서를 정확하게 예측할 수 있습니다.

Event Loop란?

Event Loop는 Call Stack과 Message Queue의 상태를 지속적으로 체크하여, Call Stack이 비어있을 때 Message Queue의 첫 번째 콜백을 Call Stack으로 이동시키는 메커니즘입니다.

Event Loop의 핵심 역할

역할설명
모니터링Call Stack과 Queue 상태 지속 감시
작업 스케줄링적절한 타이밍에 콜백 실행
동시성 제어싱글 스레드에서 비동기 처리 가능하게 함
1
2
3
4
5
6
7
8
9
10
11
12
console.log('Start');

setTimeout(() => {
  console.log('Timeout');
}, 0);

console.log('End');

// 출력:
// Start
// End
// Timeout (setTimeout이 0초여도 동기 코드 이후에 실행)

Event Loop의 이러한 반복적인 행동을 Tick이라고 부릅니다.

Event Loop 동작 원리

JavaScript 런타임 구성 요소

JavaScript Runtime
Call Stack
Heap
Web APIs
setTimeout
DOM Events
fetch
Microtask Queue
Promise
Macrotask Queue
setTimeout
⚡ Event Loop ⚡
↑ 지속적으로 모니터링 및 작업 스케줄링 ↑

Event Loop 처리 순서

  1. 동기 코드 실행 (Call Stack)
  2. Microtask Queue 확인 및 실행 (비어있을 때까지 모두 실행)
  3. 렌더링 수행 (필요한 경우)
  4. Macrotask Queue에서 하나의 작업 실행
  5. 1번으로 돌아가서 반복 (Tick)

Tick이란?

Tick은 Event Loop가 한 사이클을 완료하는 것을 의미합니다.

Tick의 과정

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 첫 번째 Tick
console.log('1');  // 동기 코드 실행

setTimeout(() => {
  console.log('2'); // 다음 Tick에서 실행
}, 0);

Promise.resolve().then(() => {
  console.log('3'); // 현재 Tick의 Microtask에서 실행
});

console.log('4');  // 동기 코드 실행

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

실행 순서 분석:

Tick단계실행 내용출력
1동기 코드console.log('1')'1'
1동기 코드setTimeout 등록-
1동기 코드Promise.resolve().then() 등록-
1동기 코드console.log('4')'4'
1MicrotaskPromise 콜백 실행'3'
2MacrotasksetTimeout 콜백 실행'2'

하나의 Tick 안에서 모든 Microtask가 실행되지만, Macrotask는 하나씩만 실행됩니다.

Microtask vs Macrotask

JavaScript의 비동기 작업은 두 가지 Queue로 분류됩니다.

Microtask Queue

Microtask는 현재 실행 중인 스크립트 직후에 실행되는 작업입니다.

Microtask 생성 API설명
Promise.then()Promise 체이닝
Promise.catch()Promise 에러 처리
Promise.finally()Promise 완료 처리
queueMicrotask()명시적 Microtask 등록
MutationObserverDOM 변화 감지

Macrotask Queue

Macrotask는 다음 Event Loop Tick에서 실행되는 작업입니다.

Macrotask 생성 API설명
setTimeout()지연 실행
setInterval()반복 실행
setImmediate()즉시 실행 (Node.js)
requestAnimationFrame()애니메이션 프레임
I/O 작업파일 읽기/쓰기

우선순위 비교

1
2
3
4
5
6
7
8
9
10
setTimeout(() => console.log('setTimeout'), 0);
Promise.resolve().then(() => console.log('Promise'));
queueMicrotask(() => console.log('queueMicrotask'));
console.log('Sync');

// 출력:
// Sync
// Promise
// queueMicrotask
// setTimeout

우선순위:

1
동기 코드 > Microtask Queue > Macrotask Queue

Microtask는 현재 Tick에서 실행되고, Macrotask는 다음 Tick에서 실행됩니다.

실전 예제

예제 1: 복잡한 비동기 순서

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
console.log('Script start');

setTimeout(() => {
  console.log('setTimeout 1');
}, 0);

Promise.resolve()
  .then(() => {
    console.log('Promise 1');
  })
  .then(() => {
    console.log('Promise 2');
  });

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

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

console.log('Script end');

출력 순서:

1
2
3
4
5
6
7
Script start
Script end
Promise 1
Promise 3
Promise 2
setTimeout 1
setTimeout 2

단계별 분석:

Tick단계실행 내용
1동기Script start
1동기setTimeout 1 등록 → Macrotask Queue
1동기Promise 1, 2 체인 등록 → Microtask Queue
1동기setTimeout 2 등록 → Macrotask Queue
1동기Promise 3 등록 → Microtask Queue
1동기Script end
1MicrotaskPromise 1 → 다시 Promise 2 Microtask 등록
1MicrotaskPromise 3
1MicrotaskPromise 2
2MacrotasksetTimeout 1
3MacrotasksetTimeout 2

예제 2: Microtask의 연쇄 실행

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
console.log('Start');

setTimeout(() => {
  console.log('Timeout');
}, 0);

Promise.resolve()
  .then(() => {
    console.log('Promise 1');
    return Promise.resolve();
  })
  .then(() => {
    console.log('Promise 2');
  });

console.log('End');

출력:

1
2
3
4
5
Start
End
Promise 1
Promise 2
Timeout

Promise 체이닝은 모두 Microtask Queue에서 처리되므로, setTimeout보다 먼저 실행됩니다.

예제 3: 무한 Microtask 주의

1
2
3
4
5
6
7
8
9
// ⚠️ 주의: 이 코드는 브라우저를 멈추게 합니다!
function recursiveMicrotask() {
  queueMicrotask(() => {
    console.log('Microtask');
    recursiveMicrotask();  // 무한 재귀
  });
}

// recursiveMicrotask(); // 실행하지 마세요!

문제점:

  • Microtask는 현재 Tick에서 Queue가 빌 때까지 계속 실행됨
  • 무한히 Microtask를 생성하면 Event Loop가 다음 Tick으로 넘어가지 못함
  • 렌더링이 차단되어 브라우저가 응답하지 않음

해결책: Macrotask(setTimeout)를 사용하면 렌더링 기회를 제공합니다.

1
2
3
4
5
6
7
// ✅ 개선된 버전
function safeRecursion() {
  setTimeout(() => {
    console.log('Macrotask');
    safeRecursion();
  }, 0);
}

Event Loop와 렌더링

렌더링 타이밍

브라우저는 Macrotask 실행 후, 다음 Macrotask 실행 전에 렌더링을 수행합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const button = document.querySelector('button');
const display = document.querySelector('#display');

button.addEventListener('click', () => {
  display.textContent = 'Loading...';

  // ❌ 동기 작업: 렌더링이 차단됨
  for (let i = 0; i < 1000000000; i++) {
    // 무거운 작업
  }

  display.textContent = 'Done!';
  // 사용자는 'Loading...'을 볼 수 없음!
});

개선 방법:

1
2
3
4
5
6
7
8
9
10
11
12
button.addEventListener('click', () => {
  display.textContent = 'Loading...';

  // ✅ setTimeout으로 렌더링 기회 제공
  setTimeout(() => {
    for (let i = 0; i < 1000000000; i++) {
      // 무거운 작업
    }
    display.textContent = 'Done!';
  }, 0);
  // 사용자는 'Loading...'을 볼 수 있음!
});

requestAnimationFrame의 활용

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function animateProgress() {
  let progress = 0;

  function updateProgress() {
    progress += 1;
    progressBar.style.width = progress + '%';

    if (progress < 100) {
      requestAnimationFrame(updateProgress);  // 다음 렌더링 직전에 실행
    }
  }

  requestAnimationFrame(updateProgress);
}

requestAnimationFrame은 렌더링 직전에 실행되어 부드러운 애니메이션을 보장합니다.

성능 최적화

1. 무거운 작업 분할

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// ❌ 나쁜 예: UI 차단
function processLargeArray(array) {
  array.forEach(item => {
    heavyProcessing(item);
  });
}

// ✅ 좋은 예: 작업 분할
async function processLargeArrayAsync(array, chunkSize = 100) {
  for (let i = 0; i < array.length; i += chunkSize) {
    const chunk = array.slice(i, i + chunkSize);

    chunk.forEach(item => {
      heavyProcessing(item);
    });

    // 다음 청크 처리 전에 다른 작업 기회 제공
    await new Promise(resolve => setTimeout(resolve, 0));
  }
}

2. Debouncing과 Throttling

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// Debounce: 마지막 호출 후 일정 시간 대기
function debounce(func, delay) {
  let timeoutId;
  return function(...args) {
    clearTimeout(timeoutId);
    timeoutId = setTimeout(() => func.apply(this, args), delay);
  };
}

// Throttle: 일정 시간 간격으로 실행
function throttle(func, delay) {
  let lastCall = 0;
  return function(...args) {
    const now = Date.now();
    if (now - lastCall >= delay) {
      lastCall = now;
      func.apply(this, args);
    }
  };
}

// 사용 예
input.addEventListener('input', debounce(handleInput, 300));
window.addEventListener('scroll', throttle(handleScroll, 100));

3. 우선순위 관리

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 높은 우선순위: Microtask 사용
function highPriorityTask() {
  queueMicrotask(() => {
    console.log('High priority');
  });
}

// 낮은 우선순위: setTimeout 사용
function lowPriorityTask() {
  setTimeout(() => {
    console.log('Low priority');
  }, 0);
}

// 즉각적인 피드백이 필요한 경우
button.addEventListener('click', () => {
  highPriorityTask();  // 사용자 피드백
  lowPriorityTask();   // 분석 데이터 전송
});

디버깅 팁

Chrome DevTools 활용

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// Performance 탭에서 Event Loop 모니터링
console.time('Task');
setTimeout(() => {
  console.timeEnd('Task');
}, 0);

// Long Task 감지
if ('PerformanceObserver' in window) {
  const observer = new PerformanceObserver(list => {
    for (const entry of list.getEntries()) {
      console.warn('Long task detected:', entry.duration);
    }
  });

  observer.observe({ entryTypes: ['longtask'] });
}

실행 순서 시각화

1
2
3
4
5
6
7
8
9
10
11
function logWithTiming(message) {
  const timestamp = performance.now().toFixed(2);
  console.log(`[${timestamp}ms] ${message}`);
}

logWithTiming('Start');

setTimeout(() => logWithTiming('setTimeout'), 0);
Promise.resolve().then(() => logWithTiming('Promise'));

logWithTiming('End');

핵심 정리

Event Loop 처리 순서

1
2
3
4
5
6
7
8
9
1. Call Stack 실행 (동기 코드)
   ↓
2. Microtask Queue 비우기 (모든 Microtask 실행)
   ↓
3. 렌더링 (필요한 경우)
   ↓
4. Macrotask Queue에서 하나 실행
   ↓
5. 1번으로 돌아가기 (Tick 반복)

Microtask vs Macrotask 비교

구분MicrotaskMacrotask
실행 타이밍현재 Tick다음 Tick
처리 방식Queue가 빌 때까지 모두 실행Tick당 하나씩 실행
우선순위높음낮음
렌더링렌더링 전에 실행렌더링 후에 실행
예시Promise, queueMicrotasksetTimeout, setInterval

Best Practices

  1. 무거운 작업은 분할하여 UI 차단 방지
  2. 우선순위에 맞는 Queue 선택 (중요한 작업은 Microtask)
  3. 무한 Microtask 생성 주의 (렌더링 차단 위험)
  4. Debouncing/Throttling 활용으로 과도한 실행 방지
  5. Performance API로 성능 모니터링

Event Loop를 이해하면 비동기 코드의 실행 순서를 예측하고, 성능 문제를 해결할 수 있습니다.

참고 자료

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.