setTimeout(fn, 0) vs Promise: 누가 먼저 실행될까? (자바스크립트 실행 순서 분석)

setTimeoutPromisejavascript

자바스크립트 비동기 처리의 핵심: 이벤트 루프와 우선순위 완벽 정리

프론트엔드 개발을 하다 보면 비동기 코드가 예상과 다르게 동작해 당황하는 경우가 종종 발생합니다. 특히 setTimeoutPromise가 섞여 있을 때 실행 순서를 정확히 예측하는 것은 자바스크립트 엔진의 동작 원리를 이해하는 데 매우 중요한 척도가 됩니다.

아래 코드의 출력 순서를 먼저 예측해 볼까요?

1 2 3 4 5 6 7 8 9 10 11 console.log('Start'); // 1 setTimeout(() => { console.log('Timeout'); // ? }, 0); Promise.resolve().then(() => { console.log('Promise'); // ? }); console.log('End'); // 4

직관적으로는 setTimeout의 지연 시간이 0이므로 먼저 실행될 것 같지만, 실제로는 Promise가 먼저 실행됩니다. 이는 자바스크립트 엔진이 두 가지 작업을 처리하는 **'우선순위(Priority)'**가 다르기 때문입니다.

이번 글에서는 자바스크립트 비동기 처리의 심장부인 **이벤트 루프(Event Loop)**와 두 가지 태스크 큐의 동작 메커니즘을 심층 분석해 봅니다.


1. 자바스크립트 런타임의 구조

자바스크립트는 기본적으로 한 번에 하나의 작업만 수행할 수 있는 싱글 스레드(Single Thread) 언어입니다. 하지만 웹 브라우저에서는 네트워크 요청, 타이머, 사용자 이벤트 등을 동시에 처리하는 것처럼 보입니다.

이것이 가능한 이유는 자바스크립트 엔진(V8 등)이 브라우저의 Web API와 협력하며 비동기 작업을 처리하기 때문입니다.

  1. Call Stack (호출 스택): 자바스크립트 코드가 실행되는 곳입니다. (LIFO 구조)
  2. Web APIs: setTimeout, fetch, DOM 이벤트 등 브라우저가 제공하는 비동기 기능을 담당합니다.
  3. Callback Queue (태스크 큐): 비동기 작업이 완료된 후 실행을 기다리는 대기열입니다.
  4. Event Loop: 호출 스택이 비어있는지 지속적으로 확인하고, 큐에 있는 작업을 스택으로 옮기는 역할을 합니다.

2. 두 개의 대기열: Microtask vs Macrotask

이벤트 루프를 제대로 이해하기 위해서는 '큐(Queue)'가 하나가 아니라는 점을 알아야 합니다. 자바스크립트는 처리해야 할 작업의 종류에 따라 서로 다른 우선순위를 가진 두 개의 큐를 운용합니다.

1) 마이크로태스크 큐 (Microtask Queue)

  • 우선순위: 높음 (Highest Priority)
  • 대상: Promise.then, catch, finally, queueMicrotask, MutationObserver
  • 동작 방식: 호출 스택이 비는 즉시 실행됩니다. 중요한 점은 큐에 쌓인 모든 작업이 처리될 때까지 이벤트 루프는 다음 단계로 넘어가지 않습니다.

2) 매크로태스크 큐 (Macrotask Queue)

  • 우선순위: 낮음 (Normal Priority)
  • 대상: setTimeout, setInterval, setImmediate, I/O 작업, UI 렌더링
  • 동작 방식: 마이크로태스크 큐가 완전히 비워져야만 실행됩니다. 한 번에 하나의 작업만 실행하고, 다시 마이크로태스크 큐를 확인합니다.

3. 실행 순서 상세 분석

처음 예시로 돌아가서, 코드가 실행되는 과정을 단계별로 추적해 보겠습니다.

1 2 3 4 5 6 7 console.log('Start'); setTimeout(() => { console.log('Timeout'); }, 0); Promise.resolve().then(() => { console.log('Promise'); }); console.log('End');
  1. 동기 코드 실행: console.log('Start')가 호출 스택에 들어가고 즉시 실행됩니다. (출력: Start)
  2. 매크로태스크 등록: setTimeout을 만납니다. 타이머 처리는 Web API로 넘겨지고, 0ms 후 콜백 함수는 매크로태스크 큐에 들어갑니다.
  3. 마이크로태스크 등록: Promise를 만납니다. .then() 내부의 콜백 함수는 마이크로태스크 큐에 들어갑니다.
  4. 동기 코드 종료: console.log('End')가 실행됩니다. (출력: End)
  5. 이벤트 루프의 판단: 호출 스택이 비었습니다. 우선순위가 높은 마이크로태스크 큐를 먼저 확인합니다.
  6. 마이크로태스크 실행: Promise 콜백이 실행됩니다. (출력: Promise)
  7. 매크로태스크 실행: 마이크로태스크 큐가 비었으므로, 매크로태스크 큐setTimeout 콜백을 실행합니다. (출력: Timeout)

최종 결과: StartEndPromiseTimeout


4. 성능 관점에서의 주의점: 기아 상태 (Starvation)

마이크로태스크는 "큐가 빌 때까지 계속 실행된다"는 특성이 있습니다. 이는 개발자가 의도치 않게 브라우저의 렌더링을 차단할 수 있음을 의미합니다.

만약 마이크로태스크 내부에서 또 다른 마이크로태스크를 반복적으로 생성한다면 어떻게 될까요?

1 2 3 4 function heavyLoop() { Promise.resolve().then(heavyLoop); // 재귀적으로 마이크로태스크 추가 } heavyLoop();

이 경우 이벤트 루프는 마이크로태스크 큐를 처리하느라 매크로태스크(클릭 이벤트 처리, 화면 렌더링 등)로 넘어가지 못합니다. 결과적으로 브라우저 화면이 멈추는 프리징(Freezing) 현상이 발생합니다.

따라서 무거운 작업을 비동기로 처리할 때는 우선순위를 고려하여 setTimeout이나 scheduler.postTask 등을 적절히 혼합해 사용하는 전략이 필요합니다.

Event-Loop-in-JavaScript.jpg


5. 핵심 요약

자바스크립트의 비동기 처리 로직을 설계할 때 기억해야 할 핵심 원칙은 다음과 같습니다.

  1. 우선순위의 차이: 마이크로태스크(Promise)는 항상 매크로태스크(setTimeout)보다 먼저 실행됩니다.
  2. 연속 실행: 마이크로태스크는 큐가 빌 때까지 연속으로 실행되지만, 매크로태스크는 한 번에 하나씩 실행되고 다시 우선순위 검사를 수행합니다.
  3. 렌더링과의 관계: 과도한 마이크로태스크 작업은 UI 렌더링을 차단할 수 있으므로 주의가 필요합니다.

References

  • MDN Web Docs: The Event Loop
  • JavaScript.info: Microtasks and Macrotasks