JavaScript-이벤트 루프

HyeonE·2026년 3월 12일

JS

목록 보기
18/18
post-thumbnail

싱글 스레드인 JavaScript가 어떻게 비동기를 처리할까?

JavaScript는 싱글 스레드다. 콜 스택도 하나다. 그런데 setTimeout은 돌아가고, fetch는 기다리지 않는다. 도대체 누가 이걸 가능하게 하는 걸까?


출발점: JavaScript는 싱글 스레드다

JavaScript 엔진(V8 등)은 콜 스택(Call Stack)이 하나다. 한 번에 하나의 작업만 처리할 수 있다는 뜻이다. 함수가 호출되면 스택에 쌓이고, 실행이 끝나면 빠진다. 위에서부터 하나씩, 순서대로.

function first() { console.log("1"); }
function second() { console.log("2"); }
function third() { console.log("3"); }

first();
second();
third();

// 출력: 1, 2, 3 (무조건 이 순서)

단순하고 예측 가능하다. 하지만 이 구조에는 치명적인 문제가 있다. 하나가 오래 걸리면 전부 멈춘다.

// 만약 이 함수가 5초 걸린다면?
const data = fetchSync("/api/heavy-data"); // ← 5초 동안 화면 멈춤
console.log(data);                          // ← 5초 후에야 실행
button.addEventListener("click", handler);  // ← 그동안 클릭도 안 됨

네트워크 요청이 끝날 때까지 화면이 얼어붙는다. 버튼도 안 눌리고, 스크롤도 안 된다. 이래서는 웹 앱을 만들 수 없다.


비동기는 JavaScript가 아닌 '브라우저'가 처리한다

여기서 핵심 반전이 하나 있다.

setTimeout, fetch, addEventListener — 이런 비동기 함수들은 JavaScript 엔진이 처리하는 게 아니다. 브라우저가 제공하는 Web API가 처리한다.

Web API는 각각 별도의 스레드에서 동작한다. setTimeout을 호출하면 Timer API 스레드에서 시간을 세고, fetch를 호출하면 Ajax API 스레드에서 네트워크 통신을 한다. 싱글 스레드인 건 JavaScript 엔진뿐이지, 브라우저 자체는 멀티 스레드다.


이벤트 루프 — "작업을 옮기는 택배 기사"

이벤트 루프는 직접 작업을 실행하지 않는다. 하는 일은 딱 하나다.

콜 스택이 비어 있으면, 태스크 큐에서 작업을 꺼내 콜 스택에 올려준다.

택배 기사가 물건을 만들지도, 사용하지도 않는 것처럼 — 이벤트 루프도 작업을 만들거나 실행하지 않고, 옮기기만 한다. 실제 작업의 주체는 Web API(비동기 처리)와 JS 엔진(콜백 실행)이다.

setTimeout의 여정을 따라가보자

console.log("시작");

setTimeout(() => {
  console.log("타이머 콜백");
}, 1000);

console.log("끝");

setTimeout(cb, 0)이라고 해도 결과는 같다. 0ms 후에 태스크 큐에 들어가지만, 콜 스택이 비어야 실행되기 때문이다.

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

// 출력: 1 → 3 → 2 (절대 1 → 2 → 3이 아님!)

마이크로태스크 vs 매크로태스크 — 큐에도 우선순위가 있다

태스크 큐는 사실 하나가 아니다. 두 종류의 큐가 있고, 실행 우선순위가 다르다.

구분마이크로태스크 큐 (Microtask)매크로태스크 큐 (Macrotask)
우선순위높음 (먼저 실행)낮음 (나중 실행)
대표 APIPromise.then/catch/finallysetTimeout
queueMicrotask()setInterval
MutationObserverDOM 이벤트 핸들러
async/await (의 후속 처리)requestAnimationFrame*
실행 방식큐가 완전히 빌 때까지 전부 실행한 번에 하나만 실행

*requestAnimationFrame은 엄밀히 매크로태스크는 아니고 렌더링 단계에서 실행되지만, 매크로태스크 이후에 처리된다는 점에서 여기에 분류했다.

코드로 확인해보자

console.log("1: 동기");

setTimeout(() => {
  console.log("2: 매크로태스크 (setTimeout)");
}, 0);

Promise.resolve().then(() => {
  console.log("3: 마이크로태스크 (Promise)");
});

console.log("4: 동기");

Promise가 setTimeout보다 항상 먼저 실행된다. 둘 다 0ms 후에 큐에 들어가더라도, 마이크로태스크 큐가 먼저 비워지기 때문이다.

더 복잡한 예제 — 마이크로태스크가 마이크로태스크를 낳을 때

console.log("1");

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

Promise.resolve()
  .then(() => {
    console.log("3");
    // 마이크로태스크 안에서 또 마이크로태스크를 추가!
    Promise.resolve().then(() => console.log("4"));
  })
  .then(() => console.log("5"));

console.log("6");
실행 흐름:

[동기] "1" 출력, setTimeout 등록, Promise 체인 등록, "6" 출력

[마이크로태스크 큐 비우기]
  "3" 출력 → 내부에서 새 Promise.then("4") 등록
  "5" 출력 (체이닝된 .then)
  "4" 출력 (중간에 추가된 마이크로태스크도 전부 처리!)

[마이크로태스크 큐 완전히 비었음 → 매크로태스크]
  "2" 출력

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

setTimeout("2")이 가장 마지막이다. 마이크로태스크 안에서 추가된 마이크로태스크("4")조차 매크로태스크보다 먼저 실행된다.


이벤트 루프 한 사이클

이벤트 루프는 아래 4단계를 무한 반복한다.

핵심은 2단계다. 마이크로태스크 큐는 한 번 시작하면 큐가 완전히 빌 때까지 전부 실행한다. 중간에 새로운 마이크로태스크가 추가되더라도 전부 처리한 후에야 다음 단계로 넘어간다.


렌더링은 언제 일어나는가?

이벤트 루프에서 빠지기 쉬운 부분이 렌더링이다. 브라우저는 보통 60fps(약 16.6ms마다 한 번)를 목표로 화면을 그리는데, 렌더링은 마이크로태스크가 모두 끝난 후에 일어난다.


마이크로태스크가 너무 많으면 렌더링이 밀린다.

// ⚠️ 위험한 코드: 마이크로태스크 무한 루프
function danger() {
  Promise.resolve().then(() => {
    danger(); // 큐가 영원히 비지 않음 → 렌더링 불가 → 화면 멈춤!
  });
}
danger(); // 브라우저 프리징 🥶
// ✅ 안전한 대안: 매크로태스크로 끊어주기
function safe() {
  setTimeout(() => {
    safe(); // 매크로태스크 사이에 렌더링이 일어날 수 있음
  }, 0);
}
safe(); // 화면 정상 동작

requestAnimationFrame은 렌더링 직전에 실행되므로, DOM 조작이나 애니메이션 관련 작업은 여기에 넣는 게 가장 적절하다.

// ✅ 부드러운 애니메이션을 위한 패턴
function animate() {
  element.style.transform = `translateX(${position}px)`;
  position += 2;

  if (position < 300) {
    requestAnimationFrame(animate); // 다음 렌더링 직전에 다시 호출
  }
}
requestAnimationFrame(animate);

종합 퀴즈 — 실행 순서 맞춰보기

배운 걸 종합해서, 아래 코드의 출력 순서를 예측해보자.

console.log("A");

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

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

requestAnimationFrame(() => console.log("E"));

queueMicrotask(() => console.log("F"));

console.log("G");
정답 보기
A → G → C → F → D → E → B

[동기]       A, G
[마이크로]   C → F → D  (Promise.then과 queueMicrotask 모두 마이크로태스크)
[렌더링]     E           (requestAnimationFrame은 렌더링 단계)
[매크로]     B           (setTimeout)

※ 실행 환경에 따라 E와 B의 순서는 달라질 수 있다. 핵심은 동기 → 마이크로 → 매크로 순서가 보장된다는 점이다.


마치며

정리하면 이렇다.

JavaScript는 싱글 스레드가 맞다. 하지만 브라우저는 아니다. Web API라는 별도의 스레드 풀이 비동기 작업을 대신 처리해주고, 이벤트 루프가 그 결과물을 다시 JavaScript 엔진으로 옮겨주는 구조 덕분에 — 싱글 스레드임에도 비동기 처리가 가능한 것이다.

이벤트 루프 자체는 작업을 처리하지 않는다. "콜 스택 비었니? 큐에 뭐 있니?" 이 두 가지를 끊임없이 확인하며 작업을 옮겨주는 것이 전부다. 소박하지만, 이 단순한 루프 위에 웹의 모든 비동기가 돌아가고 있다.


References

profile
기억보다 기록을

0개의 댓글