이벤트 루프 — setTimeout(0)이 Promise보다 늦게 나오는 이유

@JavaPark · 2026년 8월 24일 · 11 min read

이벤트 루프 커버 — 콜 스택과 마이크로태스크·매크로태스크 두 큐 사이를 항목이 오가는 구조
이벤트 루프 커버 — 콜 스택과 마이크로태스크·매크로태스크 두 큐 사이를 항목이 오가는 구조

안녕하세요, 자바파커입니다.

출력 순서를 맞춰 보세요.

console.log("1")
setTimeout(() => console.log("2"), 0)
Promise.resolve().then(() => console.log("3"))
console.log("4")

답은 1 4 3 2 입니다. 20ms인데 맨 뒤로 갑니다.

결론부터 말씀드리면 — 자바스크립트는 한 번에 하나만 합니다. 그리고 기다리는 줄이 하나가 아닙니다. 이 두 문장이 위 순서를 전부 설명합니다.


콜 스택 — 한 줄로만 실행된다

자바스크립트 엔진에는 실행 중인 함수를 쌓는 콜 스택이 하나 있습니다. 하나뿐입니다.

console.log("1")   ← 스택에 올라감 → 실행 → 빠짐
console.log("4")   ← 스택에 올라감 → 실행 → 빠짐

스택이 비어 있어야 다음 일을 시작할 수 있습니다. 그래서 무거운 반복문이 돌면 그동안 화면이 멈춥니다. 클릭도 안 먹습니다. 스택이 안 비니까요.


그럼 비동기는 누가 하나

setTimeout은 자바스크립트가 세지 않습니다. 브라우저(또는 Node)가 셉니다.

setTimeout(fn, 0)
  → 자바스크립트: "이거 0ms 뒤에 불러 줘" 하고 넘김
  → 브라우저: 타이머를 셈  (여긴 자바스크립트 밖입니다)
  → 다 세면: fn 을 큐에 넣음   ← 실행이 아니라 줄 세우기

여기가 오해가 시작되는 지점입니다. 타이머가 끝나도 함수가 바로 실행되지 않습니다. 줄에 설 뿐입니다.


줄이 두 개입니다

그리고 그 줄이 하나가 아닙니다.

여기 들어가는 것
마이크로태스크 큐 Promise.then · catch · finally, queueMicrotask, MutationObserver
매크로태스크 큐(태스크 큐) setTimeout, setInterval, I/O, 클릭 같은 UI 이벤트

이벤트 루프의 규칙은 이렇습니다.

1. 콜 스택이 빌 때까지 기다린다
2. 마이크로태스크 큐를 전부 비운다   ← 하나가 아니라 전부
3. 매크로태스크를 딱 하나 꺼내 실행한다
4. 다시 2번으로

2번이 3번보다 먼저이고, "전부"입니다. 그래서 Promise.thensetTimeout(0)을 항상 앞섭니다.

이제 처음 퀴즈를 다시 보시면 이렇게 흘러갑니다.

순서 무슨 일이
1 console.log("1") 실행 → 1
2 setTimeout → 브라우저에 넘김
3 Promise.then → 마이크로태스크 큐로
4 console.log("4") 실행 → 4
5 스택이 빔 → 마이크로태스크 전부 → 3
6 그제서야 매크로태스크 하나 → 2

setTimeout(0)은 0ms가 아닙니다

두 가지 이유로 그렇습니다.

하나, 줄을 서야 합니다. 앞에서 본 그대로입니다. 스택이 비고 마이크로태스크가 다 끝난 뒤에야 차례가 옵니다.

둘, 브라우저가 하한을 겁니다. setTimeout5단계 이상 중첩되면 브라우저는 최소 지연을 4ms로 올립니다. HTML 표준에 있는 동작입니다.

// 이런 재귀는 5번째부터 4ms 로 눌립니다
function tick(n) {
  if (n === 0) return
  setTimeout(() => tick(n - 1), 0)
}

"0ms니까 즉시"라는 가정으로 짠 코드는 이 지점에서 어긋납니다.


마이크로태스크는 굶길 수 있습니다

규칙 2번이 "전부 비운다" 라는 게 위험할 때가 있습니다.

function loop() {
  Promise.resolve().then(loop) // 마이크로태스크가 자기를 또 넣는다
}
loop()

마이크로태스크 큐가 영원히 안 비웁니다. 그러면 3번(매크로태스크)에 도달하지 못하고, 렌더링도 못 합니다. 화면이 완전히 얼어붙습니다.

같은 걸 setTimeout으로 하면 멈추지 않습니다. 매크로태스크는 한 번에 하나씩만 처리하므로 사이사이에 렌더 기회가 있습니다.

function loop() {
  setTimeout(loop, 0) // 이건 화면이 안 멈춥니다
}

긴 작업을 쪼갤 때 Promise가 아니라 setTimeout을 쓰는 이유가 이것입니다.


렌더링은 어디에 끼나

브라우저는 매크로태스크 하나를 처리하고 마이크로태스크를 비운 뒤, 필요하면 화면을 그립니다. 보통 초당 60번 정도입니다.

그래서 애니메이션을 setTimeout으로 돌리면 렌더 시점과 어긋나 끊깁니다. requestAnimationFrame렌더 직전에 불리도록 예약되므로 이런 어긋남이 없습니다.

하려는 일 쓸 것
화면 갱신·애니메이션 requestAnimationFrame
긴 작업 쪼개기 setTimeout(fn, 0)
DOM 변경 직후 정리 마이크로태스크(queueMicrotask)

Node.js는 조금 다릅니다

원리는 같은데 단계가 더 잘게 나뉩니다. libuv가 여러 페이즈를 돌고, 각 페이즈 사이마다 마이크로태스크를 비웁니다.

timers → pending → poll → check(setImmediate) → close

그리고 Node에는 마이크로태스크보다 더 먼저 처리되는 줄이 하나 더 있습니다.

setTimeout(() => {
  process.nextTick(() => console.log("A")) // 이게
  Promise.resolve().then(() => console.log("B")) // 이것보다 먼저
}, 0)
// A B

그런데 이걸 파일 최상위에 그냥 두면 결과가 갈립니다.

위치 출력
콜백 안 (어느 방식이든) A B
CommonJS 파일 최상위 (.cjs) A B
ESM 파일 최상위 (.mjs) B A

ESM 모듈은 평가 자체가 이미 프로미스 잡 안에서 일어납니다. 그래서 모듈 본문이 끝나도 곧바로 nextTick 체크포인트로 가지 않고, 대기 중이던 마이크로태스크가 먼저 흘러갑니다. Node 24에서 확인한 결과입니다.

nextTick이 먼저라는 규칙 자체는 맞습니다. 다만 "어느 체크포인트에서 보느냐" 가 붙습니다. 최상위에서 실험하면 규칙을 잘못 배웁니다 — 콜백 안에서 테스트하세요.

process.nextTick을 재귀로 돌리면 이벤트 루프가 아예 진행되지 않습니다. 브라우저의 마이크로태스크 굶주림보다 더 강하게 막힙니다.


자주 묻는 질문 (FAQ)

자바스크립트가 싱글 스레드인데 어떻게 동시에 여러 일을 하나요?

자바스크립트가 하는 게 아닙니다. 타이머·네트워크·파일 읽기는 브라우저나 Node가 자기 스레드에서 처리하고, 끝나면 콜백을 큐에 넣어 줍니다. 자바스크립트는 큐에서 하나씩 꺼내 실행할 뿐이라 여전히 한 번에 하나입니다.

async/await은 어느 큐에 들어가나요?

마이크로태스크입니다. await 뒤의 코드는 then에 넘긴 것과 같게 동작합니다. 그래서 await 다음 줄은 setTimeout(0)보다 항상 먼저 실행됩니다.

그럼 무거운 계산은 어떻게 하나요?

쪼개서 setTimeout으로 넘기거나, Web Worker로 아예 다른 스레드에 보냅니다. Worker는 별도 스레드라 메인 스택을 막지 않습니다. 다만 DOM에는 접근할 수 없습니다.

브라우저마다 순서가 다를 수 있나요?

표준으로 정해진 부분은 같습니다. 마이크로태스크가 매크로태스크보다 먼저인 것은 어디서나 동일합니다. 다만 타이머의 실제 지연, 여러 타이머 사이의 순서, 렌더 시점 같은 것은 구현과 상황에 따라 달라질 수 있으니 순서에 의존하는 코드는 쓰지 마세요.


정리하며

두 문장이면 됩니다. 한 번에 하나만 하고, 줄이 두 개다.

  • 콜 스택은 하나입니다. 비어야 다음이 시작됩니다
  • 타이머는 자바스크립트가 세지 않습니다. 다 세면 실행이 아니라 줄 세우기입니다
  • 마이크로태스크가 항상 먼저이고, 전부 비웁니다. 그래서 PromisesetTimeout(0)을 이깁니다
  • 마이크로태스크로 자기를 계속 넣으면 렌더까지 멈춥니다. 쪼갤 땐 setTimeout
  • Node는 페이즈가 더 잘고, process.nextTick이 마이크로태스크보다도 먼저입니다

처음 퀴즈, 맞히셨나요? 1 4 3 2 중에 어디서 갈리셨는지 궁금합니다.

참고 자료

@JavaPark
AI 시대의 개발자 도구, 실전 경험을 공유합니다