Promise가 setTimeout보다 먼저인 이유
Promise 가 setTimeout 보다 먼저인 데는 큐가 두 개라서요.
콘솔에 줄 네 개 찍어 보면 이상한 순서가 나와요. setTimeout(fn, 0) 보다 Promise.then 이 먼저 실행되거든요. "0ms 인데 왜 뒤로 밀리지?" 처음엔 이게 의문이었어요. 그리고 이 순서는 매번 같아요. 우연이 아니라는 뜻이죠. 답은 HTML 명세 가 정의한 두 가지 큐, task 큐와 microtask 큐의 우선순위에 있어요.
한 task 가 끝나야 다음 task 로
자바스크립트 런타임은 한 번에 하나씩 일을 처리해요. call stack 위에서 함수 호출이 차곡차곡 쌓이고, 가장 안쪽 함수가 끝나면 그 위로 빠져나오는 식이에요. 한 함수가 실행 중이면 다른 코드가 끼어들지 못해요. 이걸 run-to-completion 이라고 불러요.
"An important guarantee offered by the event loop model is that JavaScript execution is never blocking." - MDN
블로킹이 안 되는 이유는 I/O 가 비동기로 빠지기 때문이에요. 네트워크 응답이 도착하면 브라우저가 콜백을 task 큐에 던져 두고, JS 가 한 task 를 끝낸 뒤에야 큐에서 다음 task 를 꺼내요. 그래서 setTimeout 도 0ms 라고 즉시 실행이 아니라, 현재 task 가 끝난 뒤 큐에서 다시 꺼내져야 실행돼요.
여기서 task 가 뭔지 짚고 갈게요. web.dev 의 정의 로는 "브라우저가 수행하는 모든 개별 작업 단위" 인데, 렌더링, HTML/CSS 파싱, JS 실행 모두 포함돼요. 그러니까 한 task 에는 우리가 짠 JS 한 덩어리뿐 아니라 그 직후 브라우저가 해야 할 일까지 들어 있어요.
마이크로태스크라는 새치기 줄
여기까지면 setTimeout(fn, 0) 이 제일 빨리 실행돼야 해요. 다만 자바스크립트에는 task 큐 말고 한 줄이 더 있어요. microtask 큐예요.
"A microtask is a short function which is executed after the function or program which created it exits and only if the JavaScript execution stack is empty, but before returning control to the event loop." - MDN
마이크로태스크는 "자기를 만든 함수가 끝나고 + call stack 이 비고 + 이벤트 루프에 제어권을 돌려주기 직전" 에 실행돼요. 그러니까 다음 task 가 시작되기 전, 같은 turn 안에서 처리되는 셈이에요.
대표 마이크로태스크는 Promise.then, Promise.catch, Promise.finally, queueMicrotask(), MutationObserver 콜백이에요. 그래서 처음 코드의 순서가 풀려요. 동기 코드 두 줄이 먼저 찍히고, call stack 이 비는 순간 microtask 큐의 Promise.then 이 실행되고, 그 다음에야 task 큐의 setTimeout 콜백이 꺼내져요.
함정이 하나 있어요. 마이크로태스크 안에서 또 마이크로태스크를 추가하면 그게 다음 task 가 시작되기 전에 같이 처리되거든요.
"If a microtask adds more microtasks to the queue by calling queueMicrotask(), those newly-added microtasks execute before the next task is run." - MDN
then 안에서 또 then 을 부르고, 그 안에서 또 then 을 부르면, task 큐는 영원히 차례를 못 받아요. 이게 microtask 무한 루프예요. setTimeout 으로 분할한 줄 알았는데 setTimeout 은 영원히 호출되지 않는 상황이 만들어지는 거죠.
화면이 그려지는 자리
마이크로태스크 큐가 비면 그제야 다음 단계로 넘어가요. 그게 바로 렌더링 단계예요. HTML 명세는 task 하나가 끝날 때마다 microtask checkpoint 를 거치고, 그 다음에 "Update the rendering" 단계로 진입한다고 정의해요. 이 단계에서 requestAnimationFrame 콜백이 실행되고, 레이아웃과 페인트가 일어나요.
여기서 중요한 사실 하나. 렌더링은 매 task 마다 일어나지 않아요. 디스플레이 리프레시 주기에 맞춰 16.67ms 에 한 번 정도예요. 그러니까 우리가 짠 한 task 가 16ms 보다 길게 돌면 다음 프레임이 그냥 누락돼요. 사용자 입장에서는 클릭이 안 먹히고 스크롤이 끊기는 그 순간이고요.
브라우저 렌더링 파이프라인 이 어떻게 굴러가는지는 따로 정리해 둔 글이 있어요. 그 파이프라인이 돌아가는 16ms 의 빈 틈을 지키는 게 이벤트 루프의 역할이에요.
메인 스레드를 양보하는 법
긴 작업을 한 task 안에 다 욱여넣으면 다음 프레임이 밀려요. web.dev 의 long task 정의 는 메인 스레드를 50ms 이상 점유하는 JS 인데, 이게 INP 같은 핵심 지표를 직접 망가뜨려요.
해결책 흐름은 "양보" 예요. 작업을 작은 조각으로 쪼개고, 조각 사이에 이벤트 루프에 차례를 돌려주는 거죠. 가장 단순한 방법이 setTimeout(fn, 0) 인데, 이건 task 큐에 새 task 로 넣는 거라 5번 이상 중첩되면 브라우저가 최소 5ms 지연을 강제해요. 우선순위가 너무 낮은 거예요.
요즘 권장되는 건 scheduler.yield() 예요. 호출하면 현재 task 를 끊고 양보한 다음, 같은 작업의 연속(continuation) 을 다른 task 보다 우선해서 다시 실행해요. 메인 스레드가 클릭 이벤트 같은 사용자 입력에 답할 틈은 만들어 주면서, 작업 자체는 가능한 한 빨리 마치는 균형점이에요.
실행해 보면 동기 두 줄이 먼저 찍히고, microtask 두 개가 추가된 순서대로 이어지고, setTimeout 이 제일 마지막에 들어와요. 매번 같은 순서로요. queueMicrotask 와 Promise.then 이 같은 microtask 큐를 공유한다는 게 그래서 보여요.
리액트의 배치 업데이트도 같은 모델 위에 서 있어요. setState 가 여러 번 호출돼도 한 번에 모아서 처리하는 자리, 이 마이크로태스크 큐를 활용하는 패턴이에요. 가상 DOM 글 에서 이 배치가 어떻게 비용을 줄이는지 따로 정리해 두었어요.
1 편의 HTTP 응답 도 결국 이 큐를 거쳐 우리 코드까지 도착해요. 네트워크가 데이터를 가져오면 task 로 들어오고, fetch 의 then 콜백은 microtask 가 되거든요. 다음 편은 그 데이터가 도착한 다음 자바스크립트가 어떻게 표현하는지 이야기예요. 이모지 length 가 1 이 아닌 자리부터 출발해요.
참고 자료
- MDN - JavaScript Event loop
call stack, run-to-completion, JS 가 절대 블로킹되지 않는 이유
- MDN - Using microtasks in JavaScript with queueMicrotask()
마이크로태스크 정의, 우선순위, 무한 루프 함정
- WHATWG HTML Living Standard - Event loops
task 처리 후 microtask checkpoint, Update the rendering 단계 정의
- web.dev - Long tasks and the main thread
long task 50ms 기준과 INP 지표 영향
- web.dev - Optimize long tasks
task 정의, setTimeout 분할의 한계, scheduler.yield 권장