frontend/javascript

🟨 2-22. 이벤트 루프와 비동기 흐름의 시각적 이해 — 브라우저의 내부 작동 구조

mirabo01 2025. 11. 7. 08:55

1. 기본 구조 이해: 이벤트 루프의 세 친구

자바스크립트 런타임은 기본적으로 세 가지 핵심 요소로 돌아간다 👇

구성 요소 역할

콜 스택(Call Stack) 실행 중인 코드의 “현재 위치”
태스크 큐(Task Queue) 타이머, DOM 이벤트, fetch 콜백 등 대기 중인 비동기 작업
마이크로태스크 큐(Microtask Queue) Promise, MutationObserver 같은 “짧은 비동기” 대기 공간

그리고 이 셋을 끊임없이 돌며 조율하는 것이 바로 이벤트 루프(Event Loop) 다.

“이벤트 루프는 브라우저의 심장처럼 계속 뛰며,
콜 스택이 비면 큐에 쌓인 작업들을 순서대로 처리한다.”


2. 동기 vs 비동기 흐름 다시보기

console.log("1");
setTimeout(() => console.log("2"), 0);
console.log("3");

실행 순서:

1
3
2

✅ setTimeout()은 태스크 큐(Task Queue)에 콜백을 등록한다.
✅ 콜 스택이 모두 비워진 다음, 이벤트 루프가 큐에 있는 함수를 꺼내 실행한다.


3. 마이크로태스크(Microtask)의 등장

Promise나 async/await은 “조금 더 빠른 비동기”인 마이크로태스크 큐에 쌓인다.

console.log("시작");

setTimeout(() => console.log("setTimeout"), 0);

Promise.resolve().then(() => console.log("Promise"));

console.log("끝");

실행 순서:

시작
끝
Promise
setTimeout

✅ Promise는 Microtask Queue
✅ setTimeout은 Task Queue

Microtask Queue는 Task Queue보다 우선순위가 높다.


4. 내부 실행 순서 시각화

[1] 코드 실행 시작
[2] 콜 스택에 main() 등록
[3] console.log("시작") 실행
[4] setTimeout 등록 → Task Queue로 이동
[5] Promise.then 등록 → Microtask Queue로 이동
[6] console.log("끝") 실행
[7] 콜 스택 비워짐
[8] Microtask Queue 실행 (Promise)
[9] Task Queue 실행 (setTimeout)

즉, 이벤트 루프는 이렇게 작동한다 👇

콜 스택이 비었는가?
  ├─ 예 → Microtask Queue 비어있는가?
  │     ├─ 아니오 → Microtask 실행
  │     └─ 예 → Task Queue에서 하나 꺼내 실행
  └─ 아니오 → 대기

5. 렌더링(Rendering) 타이밍

브라우저는 “렌더링(화면 갱신)”도 이벤트 루프 안에서 수행한다.
다만 렌더링은 Microtask가 모두 끝난 뒤에 이루어진다.

[Loop Cycle]
1. Microtask Queue 실행
2. Rendering 수행
3. Task Queue 실행

그래서 이런 코드에서는 Promise가 먼저 실행되고,
렌더링이 잠시 뒤에 일어난다.

Promise.resolve().then(() => {
  document.body.style.background = "skyblue";
});

✅ Microtask Queue가 모두 비워져야 브라우저가 “화면을 다시 그림”
✅ 그래서 비동기 DOM 조작 시 약간의 지연이 생길 수 있다.


6. 마이크로태스크가 많으면?

Microtask는 Task보다 우선이기 때문에,
Promise가 너무 많으면 렌더링이 지연된다.

let count = 0;
function heavyTask() {
  Promise.resolve().then(() => {
    if (++count < 10000) heavyTask();
  });
}
heavyTask();
console.log("끝");

✅ 이 경우 브라우저가 렌더링할 시간을 얻지 못함
✅ Microtask는 렌더링보다 항상 먼저 실행되기 때문

👉 이런 문제를 방지하려면 setTimeout()으로 한 템포 늦추는 것도 방법이다.


7. async/await 내부 구조

await은 내부적으로 Promise를 사용하기 때문에
Microtask Queue에 등록된다.

async function example() {
  console.log("1");
  await Promise.resolve();
  console.log("2");
}
example();
console.log("3");

출력 순서:

1
3
2

✅ await 뒤의 코드는 다음 루프 사이클의 Microtask에서 실행됨


8. 예제: 모든 큐의 흐름 한눈에 보기

console.log("1");

setTimeout(() => console.log("2 (Task Queue)"), 0);

Promise.resolve()
  .then(() => console.log("3 (Microtask Queue 1)"))
  .then(() => console.log("4 (Microtask Queue 2)"));

console.log("5");

결과:

1
5
3 (Microtask Queue 1)
4 (Microtask Queue 2)
2 (Task Queue)

실행 순서 흐름 👇

main() 시작
 → 콜 스택에 main()
   ├ console.log(1)
   ├ setTimeout 등록 → Task Queue
   ├ Promise.then 등록 → Microtask Queue
   ├ console.log(5)
 → main() 종료
 → Microtask Queue 실행
 → Task Queue 실행

9. 렌더링과 비동기의 협력

자바스크립트는 싱글 스레드지만,
렌더링 엔진(화면 갱신)은 이벤트 루프와 협력하여 병렬처럼 느껴지게 만든다.

즉, JS가 비동기로 작업을 큐에 넘겨놓고,
브라우저는 그 사이에 렌더링 프레임(16ms 주기) 을 수행한다.

이 구조 덕분에
사용자는 “버벅이지 않는 화면”을 볼 수 있게 된다.


10. 실무 예제 — 애니메이션 루프

function loop() {
  console.log("프레임 실행");
  requestAnimationFrame(loop);
}
requestAnimationFrame(loop);

✅ requestAnimationFrame()은 Task Queue가 아닌 “렌더링 단계”에 실행된다.
✅ 이 덕분에 애니메이션이 부드럽고 GPU와 동기화된다.


11. 이벤트 루프와 Node.js의 차이

Node.js에서도 이벤트 루프가 존재하지만,
브라우저보다 단계가 세분화되어 있다.

Node 이벤트 루프 단계 👇

  1. timers (setTimeout, setInterval)
  2. pending callbacks
  3. idle, prepare
  4. poll (I/O 이벤트 처리)
  5. check (setImmediate)
  6. close callbacks

하지만 기본 원리(스택, 큐, 루프)는 동일하다.


12. 디버깅 팁 — 순서 예측하기

이 코드를 실행하기 전에
결과를 스스로 예측해보자 👇

console.log("start");

setTimeout(() => console.log("timeout"), 0);

Promise.resolve()
  .then(() => console.log("promise 1"))
  .then(() => console.log("promise 2"));

console.log("end");

정답:

start
end
promise 1
promise 2
timeout

✅ “Microtask → Rendering → Task” 순서만 기억하면
이벤트 루프의 대부분 코드는 예측 가능하다.


13. 이벤트 루프 전체 흐름 요약

1. 콜 스택이 비어 있는지 확인
2. Microtask Queue 실행 (Promise, async/await 등)
3. 렌더링 업데이트 수행
4. Task Queue 실행 (setTimeout, I/O 등)
5. 다음 루프로 이동

💡 이벤트 루프는 “시간 단위의 순환”이 아니라,
스택의 상태를 감시하는 반복 시스템이다.


14. 시각적 구조 (개념도)

 ┌─────────────┐
 │  Call Stack │  ← 실행 중 코드
 └──────┬──────┘
        │
        ▼
 ┌─────────────┐
 │ Microtasks  │  ← Promise.then(), await
 └──────┬──────┘
        │
        ▼
 ┌─────────────┐
 │ Rendering   │  ← DOM 업데이트, paint
 └──────┬──────┘
        │
        ▼
 ┌─────────────┐
 │ Task Queue  │  ← setTimeout, fetch, events
 └─────────────┘

이 모든 단계를 이벤트 루프가 계속 돌며 감시한다.


15. 마무리

이제 자바스크립트 코드가
시간의 흐름 속에서 어떻게 실행되는지 완전히 이해했을 거야.

콜 스택, 마이크로태스크, 태스크 큐, 렌더링, 그리고 이벤트 루프는
언뜻 분리된 개념 같지만, 실제로는 브라우저라는 하나의 생태계 안에서
정교하게 협력하고 있다.

“비동기를 이해한다는 건,
자바스크립트의 시간 개념을 이해하는 것이다.”