JavaScript 이벤트 루프 - 비동기의 비밀을 파헤치다

이런 코드를 본 적 있으신가요?

console.log('1');

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

console.log('3');

“당연히 1, 2, 3 순서로 출력되겠지?”라고 생각하셨다면, 결과를 보고 놀라실 겁니다. 실제로는 1, 3, 2 순서로 출력됩니다. setTimeout의 대기 시간을 0으로 설정했는데도 말이죠!

저도 처음 JavaScript를 배울 때 이 결과를 보고 “뭐지? 버그인가?”라고 생각했습니다. 하지만 이것은 버그가 아니라 JavaScript의 이벤트 루프(Event Loop)가 작동하는 방식 때문입니다.

이벤트 루프는 JavaScript가 싱글 스레드임에도 불구하고 비동기 작업을 처리할 수 있게 해주는 핵심 메커니즘입니다. 이 문서에서는 이벤트 루프가 무엇인지, 어떻게 작동하는지, 그리고 실제 개발에서 왜 이것을 이해해야 하는지 처음부터 끝까지 자세히 설명하겠습니다.

목차

왜 이벤트 루프를 이해해야 할까요?

1. 비동기 코드의 실행 순서를 예측할 수 있습니다

// ❌ 예상: A → B → C
// ✅ 실제: A → C → B
console.log('A');

fetch('/api/data').then(() => {
  console.log('B');
});

console.log('C');

이벤트 루프를 이해하지 못하면, 이런 코드의 실행 순서를 예측할 수 없습니다. 특히 복잡한 비동기 로직에서는 예상치 못한 버그가 발생할 수 있습니다.

2. 성능 문제를 진단하고 해결할 수 있습니다

// ❌ 메인 스레드를 블로킹하는 코드
function heavyCalculation() {
  let result = 0;
  for (let i = 0; i < 10000000000; i++) {
    result += i;
  }
  return result;
}

// UI가 멈춥니다!
const result = heavyCalculation();

이벤트 루프를 이해하면, 왜 이런 코드가 UI를 멈추게 하는지 알 수 있고, 어떻게 해결해야 하는지도 알 수 있습니다.

3. Promise, async/await의 동작 원리를 이해할 수 있습니다

async function test() {
  console.log('1');

  await Promise.resolve();
  console.log('2');

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

  await Promise.resolve();
  console.log('4');
}

test();
console.log('5');

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

이벤트 루프를 이해하면, 이 코드가 왜 이런 순서로 실행되는지 완벽하게 설명할 수 있습니다.

먼저, JavaScript는 싱글 스레드입니다

JavaScript는 싱글 스레드(Single Thread) 언어입니다. 이것이 무슨 의미일까요?

멀티 스레드 (예: Java)
┌─────────┐  ┌─────────┐  ┌─────────┐
│ Thread1 │  │ Thread2 │  │ Thread3 │
│  작업A  │  │  작업B  │  │  작업C  │
└─────────┘  └─────────┘  └─────────┘
   동시에 실행 가능!

싱글 스레드 (JavaScript)
┌─────────┐
│  Thread │
│  작업A  │ → 작업B → 작업C
└─────────┘
   한 번에 하나만 실행!

싱글 스레드는 한 번에 하나의 작업만 처리할 수 있습니다. 마치 혼자서 여러 손님을 응대하는 레스토랑 직원처럼요.

그렇다면 의문이 생깁니다.

“그럼 JavaScript는 어떻게 여러 작업을 동시에 처리하는 것처럼 보이지?”

바로 이벤트 루프 덕분입니다!

이벤트 루프란 무엇인가?

이벤트 루프는 JavaScript 런타임이 비동기 작업을 처리하는 메커니즘입니다.

레스토랑 비유로 설명해볼게요:

🏪 레스토랑 (JavaScript 엔진)
┌────────────────────────────────────┐
│  👨‍🍳 주방장 (Call Stack)            │
│  "한 번에 하나의 요리만 만듭니다"   │
│                                    │
│  📋 주문 목록 (Task Queue)          │
│  "완료된 주문들이 대기합니다"       │
│                                    │
│  👔 매니저 (Event Loop)              │
│  "주방장이 한가하면 다음 주문을     │
│   전달합니다"                       │
└────────────────────────────────────┘

이벤트 루프의 역할:

  1. Call Stack이 비어있는지 확인
  2. 비어있다면 Task Queue에서 대기 중인 작업 가져오기
  3. Call Stack에 작업 추가
  4. 1번부터 반복

이벤트 루프의 핵심 구성 요소

1. Call Stack (호출 스택)

현재 실행 중인 함수를 추적하는 LIFO(Last In, First Out) 구조입니다.

function first() {
  console.log('첫 번째');
  second();
  console.log('다시 첫 번째');
}

function second() {
  console.log('두 번째');
  third();
}

function third() {
  console.log('세 번째');
}

first();

Call Stack 동작:

1. first() 호출
   ┌─────────┐
   │ first() │
   └─────────┘

2. console.log('첫 번째') 실행
   ┌──────────────────┐
   │ console.log(...) │
   │ first()          │
   └──────────────────┘

3. second() 호출
   ┌──────────┐
   │ second() │
   │ first()  │
   └──────────┘

4. third() 호출
   ┌─────────┐
   │ third() │
   │ second()│
   │ first() │
   └─────────┘

5. third() 완료 → second() 완료 → first() 완료
   ┌─────────┐
   │  (비움)  │
   └─────────┘

2. Web APIs (브라우저 제공)

브라우저가 제공하는 비동기 API들입니다.

  • setTimeout / setInterval
  • DOM 이벤트
  • fetch (네트워크 요청)
  • requestAnimationFrame

이들은 Call Stack 밖에서 작동합니다!

3. Task Queue (Callback Queue / Macrotask Queue)

비동기 작업이 완료되면 콜백이 대기하는 FIFO(First In, First Out) 큐입니다.

setTimeout(() => {
  console.log('1초 후');
}, 1000);

// 1초 후, 콜백이 Task Queue에 추가됨

4. Microtask Queue

Promise의 .then(), async/await, queueMicrotask() 등이 대기하는 큐입니다.

중요: Microtask Queue는 Task Queue보다 우선순위가 높습니다!

이벤트 루프는 어떻게 작동할까요?

이벤트 루프의 실행 순서:

1. Call Stack의 모든 동기 코드 실행
   ↓
2. Call Stack이 비어있는가?
   예 → 3번으로
   아니오 → 1번으로
   ↓
3. Microtask Queue에 작업이 있는가?
   예 → 모든 Microtask 실행
   아니오 → 4번으로
   ↓
4. Task Queue에서 하나의 작업 가져와서 실행
   ↓
5. 2번으로 돌아가기

시각적으로 이해하기

console.log('1');

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

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

console.log('4');

단계별 실행:

[Call Stack]        [Microtask Queue]    [Task Queue]
┌─────────┐         ┌─────────┐          ┌─────────┐
│ 시작     │         │         │          │         │
└─────────┘         └─────────┘          └─────────┘

1단계: console.log('1') 실행
┌──────────────┐
│console.log(1)│
└──────────────┘
출력: 1

2단계: setTimeout 등록
┌───────────────┐                        ┌─────────┐
│setTimeout(...) │                       │ cb→'2'  │
└───────────────┘                        └─────────┘
(Web API로 전달, 0ms 후 Task Queue에 추가)

3단계: Promise.then 등록
┌──────────────┐    ┌─────────┐
│Promise.then()│    │ cb→'3'  │
└──────────────┘    └─────────┘
(Microtask Queue에 추가)

4단계: console.log('4') 실행
┌──────────────┐
│console.log(4)│
└──────────────┘
출력: 4

5단계: Call Stack 비어있음 → Microtask Queue 확인
                    ┌─────────┐
                    │ cb→'3'  │
                    └─────────┘
실행 → 출력: 3

6단계: Microtask Queue 비어있음 → Task Queue 확인
                                        ┌─────────┐
                                        │ cb→'2'  │
                                        └─────────┘
실행 → 출력: 2

최종 출력: 1, 4, 3, 2

Macrotask vs Microtask

이벤트 루프에서 가장 중요한 개념입니다!

Macrotask (Task)

  • setTimeout
  • setInterval
  • setImmediate (Node.js)
  • I/O 작업
  • UI 렌더링

Microtask

  • Promise.then / catch / finally
  • async/await
  • queueMicrotask()
  • MutationObserver

실행 우선순위

// 📋 우선순위 테스트
console.log('1. 동기 코드');

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

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

queueMicrotask(() => {
  console.log('4. queueMicrotask (Microtask)');
});

console.log('5. 동기 코드');

// 출력 순서:
// 1. 동기 코드
// 5. 동기 코드
// 3. Promise (Microtask)
// 4. queueMicrotask (Microtask)
// 2. setTimeout (Macrotask)

핵심 규칙:

동기 코드 > Microtask > Macrotask

Microtask가 Macrotask를 블로킹하는 예

// ❌ 위험한 패턴: 무한 Microtask
function recursiveMicrotask() {
  return Promise.resolve().then(() => {
    console.log('Microtask');
    recursiveMicrotask(); // 다시 Microtask 추가
  });
}

recursiveMicrotask();

setTimeout(() => {
  console.log('이것은 절대 실행되지 않습니다!');
}, 0);

// Microtask가 계속 실행되어 Macrotask가 실행될 기회가 없습니다!

실전 예제로 배우는 이벤트 루프

예제 1: 복잡한 실행 순서

async function complex() {
  console.log('1');

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

  await Promise.resolve();
  console.log('3');

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

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

  console.log('6');
}

complex();
console.log('7');

// 출력 순서: 1, 7, 3, 6, 5, 2, 4

단계별 분석:

1. complex() 호출 → '1' 출력 (동기)
2. setTimeout(...'2'...) → Task Queue에 추가
3. await Promise.resolve() → Microtask Queue에 추가
4. console.log('7') 실행 → '7' 출력 (동기)

Call Stack 비어있음 → Microtask Queue 실행
5. await 이후 재개 → '3' 출력
6. setTimeout(...'4'...) → Task Queue에 추가
7. Promise.then(...'5'...) → Microtask Queue에 추가
8. '6' 출력 (동기)

Microtask Queue 실행
9. '5' 출력

Task Queue 실행
10. '2' 출력
11. '4' 출력

예제 2: fetch와 함께 사용하기

console.log('시작');

fetch('/api/user')
  .then(response => response.json())
  .then(data => {
    console.log('사용자 데이터:', data);
  });

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

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

console.log('');

// 출력 순서:
// 시작
// 끝
// Promise 완료
// 사용자 데이터: {...}
// Timeout 완료

예제 3: UI 업데이트 최적화

// ❌ 나쁜 예: 동기적으로 대량의 DOM 업데이트
function badUpdate(items) {
  items.forEach(item => {
    const div = document.createElement('div');
    div.textContent = item;
    document.body.appendChild(div);
    // 매번 리플로우 발생!
  });
}

// ✅ 좋은 예: requestAnimationFrame 활용
function goodUpdate(items) {
  const fragment = document.createDocumentFragment();

  items.forEach(item => {
    const div = document.createElement('div');
    div.textContent = item;
    fragment.appendChild(div);
  });

  requestAnimationFrame(() => {
    document.body.appendChild(fragment);
    // 한 번만 리플로우 발생!
  });
}

함정과 주의사항

1. setTimeout(fn, 0)은 즉시 실행되지 않습니다

// ❌ 오해: 0ms니까 즉시 실행된다
setTimeout(() => {
  console.log('즉시?');
}, 0);

// ✅ 실제: 모든 동기 코드와 Microtask 이후에 실행
console.log('먼저 실행됨');

이유:

  • setTimeout(fn, 0)도 Macrotask이므로 우선순위가 낮습니다
  • 브라우저는 최소 4ms 지연을 적용합니다 (중첩된 경우)

2. Long Task는 이벤트 루프를 블로킹합니다

// ❌ UI가 멈춥니다
function longTask() {
  const start = Date.now();
  while (Date.now() - start < 3000) {
    // 3초 동안 아무것도 할 수 없습니다!
  }
}

button.addEventListener('click', longTask);

해결책: Task를 쪼개기

// ✅ UI를 블로킹하지 않습니다
function* taskGenerator() {
  for (let i = 0; i < 1000000; i++) {
    yield i;
  }
}

async function processInChunks() {
  const gen = taskGenerator();

  function processChunk() {
    const start = Date.now();
    let result = gen.next();

    while (!result.done && Date.now() - start < 50) {
      // 50ms 단위로 작업 수행
      result = gen.next();
    }

    if (!result.done) {
      // 다음 이벤트 루프 사이클에 계속
      setTimeout(processChunk, 0);
    }
  }

  processChunk();
}

3. Microtask 과다 생성 주의

// ❌ Microtask가 계속 생성되어 Macrotask가 실행 안 됨
function processQueue(queue) {
  return Promise.resolve().then(() => {
    const item = queue.shift();
    if (item) {
      process(item);
      return processQueue(queue); // 재귀적으로 Microtask 생성
    }
  });
}

// ✅ 적절히 쪼개기
function processQueue(queue) {
  const chunk = queue.splice(0, 100); // 100개씩 처리

  chunk.forEach(process);

  if (queue.length > 0) {
    setTimeout(() => processQueue(queue), 0); // Macrotask로 양보
  }
}

4. async/await의 실행 순서

async function test() {
  console.log('1');

  const result = await Promise.resolve('2');
  console.log(result);

  console.log('3');
}

test();
console.log('4');

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

이유:

  • await 키워드는 나머지 함수를 Microtask로 만듭니다
  • await 이후의 코드는 .then() 안에 있는 것과 같습니다
// 위 코드는 이것과 동일합니다.
function test() {
  console.log('1');

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

실전에서 활용하기

1. 디바운싱/쓰로틀링 구현

// 디바운스: 마지막 호출 후 일정 시간 대기
function debounce(fn, delay) {
  let timeoutId;

  return function(...args) {
    clearTimeout(timeoutId);

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

// 사용 예
const debouncedSearch = debounce((query) => {
  console.log('검색:', query);
}, 300);

input.addEventListener('input', (e) => {
  debouncedSearch(e.target.value);
});

2. requestIdleCallback으로 우선순위 낮은 작업 처리

// 브라우저가 한가할 때만 실행
function doLowPriorityWork() {
  if ('requestIdleCallback' in window) {
    requestIdleCallback((deadline) => {
      while (deadline.timeRemaining() > 0 && tasks.length > 0) {
        const task = tasks.shift();
        processTask(task);
      }

      if (tasks.length > 0) {
        doLowPriorityWork(); // 남은 작업 계속
      }
    });
  } else {
    // 폴백: setTimeout 사용
    setTimeout(doLowPriorityWork, 0);
  }
}

3. Promise 체이닝 최적화

// ❌ 불필요한 Promise 체이닝
async function bad() {
  const a = await fetchA();
  const b = await fetchB();
  const c = await fetchC();
  // 순차 실행: 느림!
}

// ✅ 병렬 실행
async function good() {
  const [a, b, c] = await Promise.all([
    fetchA(),
    fetchB(),
    fetchC()
  ]);
  // 동시 실행: 빠름!
}

4. 애니메이션 최적화

// ❌ setTimeout 사용
let position = 0;
function animate() {
  position += 1;
  element.style.left = position + 'px';

  if (position < 100) {
    setTimeout(animate, 16); // ~60fps 목표
  }
}

// ✅ requestAnimationFrame 사용
let position = 0;
function animate() {
  position += 1;
  element.style.left = position + 'px';

  if (position < 100) {
    requestAnimationFrame(animate); // 브라우저 최적화!
  }
}

결론: 이벤트 루프를 언제 어떻게 활용할까?

이벤트 루프를 이해하면

  1. 비동기 코드의 실행 순서를 정확히 예측할 수 있습니다
  2. 성능 문제를 진단하고 해결할 수 있습니다
  3. Promise, async/await을 올바르게 사용할 수 있습니다
  4. UI를 블로킹하지 않는 코드를 작성할 수 있습니다

핵심 원칙

동기 코드 실행
  ↓
Microtask Queue 비우기 (Promise.then, async/await)
  ↓
Macrotask 하나 실행 (setTimeout, setInterval)
  ↓
필요시 렌더링
  ↓
반복

기억해야 할 것

  1. JavaScript는 싱글 스레드입니다
  2. 이벤트 루프가 비동기를 가능하게 합니다
  3. Microtask > Macrotask 우선순위
  4. Long Task는 이벤트 루프를 블로킹합니다
  5. 작업을 쪼개서 처리하세요

이벤트 루프는 JavaScript의 심장입니다. 이것을 이해하면 더 나은 코드를 작성할 수 있습니다!

참고 자료

공식 문서

심화 학습

추가 참고

댓글