setTimeout보다 Promise가 먼저 실행되는 이유
이벤트 루프, 태스크 큐, 마이크로태스크 큐를 직접 만져보면서 정리했어요. 결국 프로미스를 이해하러 가는 글이에요.
목차
백엔드 스터디에서 프론트엔드 쪽도 한 번씩 파보기로 해서, 제가 자바스크립트 비동기를 맡았어요. 처음엔 이벤트 루프 따로, 프로미스 따로 정리하려고 했는데 하다 보니 이게 전부 하나의 이야기더라고요. 싱글스레드인 자바스크립트가 어떻게 안 멈추고 여러 일을 처리하는 척하는지 — 그 답이 이벤트 루프고, 그 위에 태스크 큐와 마이크로태스크 큐가 있고, 프로미스는 그 큐를 쓰는 문법이에요.
글 중간중간에 시뮬레이터를 넣어놨어요. 코드를 한 스텝씩 실행하면서 콜스택과 큐가 어떻게 움직이는지 직접 눌러보면서 따라오면 돼요.
퀴즈부터 풀고 시작할게요
일단 얼마나 헷갈리는 주제인지부터 확인하고 가죠. 아래 코드, 출력 순서가 어떻게 될까요?
console.log("A");setTimeout(() => {console.log("B");}, 0);Promise.resolve().then(() => {console.log("C");});console.log("D");
맞혔다면 이 글은 복습이 될 거고, 틀렸다면 잘 오신 거예요. 저도 처음엔 틀렸어요.
제출하면 시뮬레이터가 열리는데, 패널이 다섯 개나 돼서 지금은 이해 안 되는 게 정상이에요. 글을 다 읽고 나면 저 화면이 그냥 읽혀요. 그게 이 글의 목표예요.
자바스크립트는 한 번에 하나만 해요
자바스크립트는 싱글스레드 언어예요. 스레드가 하나라는 건, 코드를 실행하는 작업대가 하나뿐이라는 뜻이에요. 그 작업대가 콜스택이에요. 함수를 호출하면 스택에 쌓이고, 반환하면 빠져요. 스택 맨 위에 있는 함수가 지금 실행 중인 코드고, 그게 끝나기 전에는 다른 어떤 코드도 끼어들 수 없어요.
말로 하면 심심하니까 직접 보죠. 동기 함수 두 개가 호출되고 반환되는 과정이에요.
동기 함수 호출과 콜스택
function greet(name) {return "안녕, " + name;}function main() {const message = greet("지니");console.log(message);}main();console.log("끝");
비어 있어요
아직 출력이 없어요
아직 아무것도 실행하기 전이에요. 콜스택이 비어 있어요.
여기까지는 특별할 게 없어요. 위에서 아래로, 한 번에 하나씩. 이게 규칙의 전부예요.
기다리는 동안엔 누가 일할까요
그런데 이상하죠. 작업대가 하나뿐이면 setTimeout으로 타이머를 걸었을 때 자바스크립트는 그 시간 동안 멈춰 있어야 할 것 같은데, 안 멈춰요. 다음 줄을 바로 실행해요.
비밀은 타이머를 자바스크립트가 직접 돌리지 않는다는 거예요. setTimeout을 호출하면 콜백을 브라우저(또는 Node 런타임)에 맡기고 바로 반환해요. 시간을 재는 건 런타임이 자기 일꾼으로 알아서 해요. 브라우저에서는 이런 것들을 Web API라고 불러요. 타이머, 네트워크 요청, 파일 읽기 — 오래 걸리는 일은 전부 이쪽으로 넘어가요.
setTimeout과 Web API
console.log("첫 번째");setTimeout(() => {console.log("타이머 콜백");}, 0);console.log("두 번째");
비어 있어요
비어 있어요
아직 출력이 없어요
실행 전이에요.
setTimeout이 스택에 잠깐 올라왔다가 바로 빠지는 게 보이죠. 기다림은 바깥에서 일어나요.
끝난 일은 태스크 큐에 줄을 서요
타이머가 끝났다고 콜백이 스택에 바로 뛰어들 수는 없어요. 스택에서는 지금 다른 코드가 실행 중일 수 있으니까요. 그래서 끝난 일은 태스크 큐라는 대기줄에 서요.
그리고 여기서 드디어 이벤트 루프가 등장해요. 하는 일은 허무할 정도로 단순해요. "콜스택이 비었나? 비었으면 큐 맨 앞의 작업을 스택에 올린다." 이걸 무한히 반복하는 게 이벤트 루프예요.
태스크 큐와 이벤트 루프
setTimeout(() => {console.log("콜백 A");}, 0);setTimeout(() => {console.log("콜백 B");}, 0);console.log("동기 코드 끝");
비어 있어요
비어 있어요
비어 있어요
아직 출력이 없어요
실행 전이에요.
타이머 두 개가 거의 동시에 끝나도 콜백은 큐에 선 순서대로, 한 번에 하나씩 처리돼요.
새치기 전용 줄, 마이크로태스크 큐
여기까지면 깔끔했을 텐데, 큐가 하나 더 있어요. 마이크로태스크 큐예요. Promise.then의 콜백과 queueMicrotask로 등록한 작업이 여기로 들어가요.
왜 줄이 두 개나 필요할까요? 프로미스 콜백은 "방금 끝난 일의 뒷처리"인 경우가 많아서, 렌더링이나 다른 태스크에 밀리지 않고 최대한 빨리 이어지는 게 좋거든요. 그래서 이벤트 루프는 태스크를 하나 처리할 때마다 마이크로태스크 큐를 먼저, 그것도 다 비울 때까지 처리해요. 태스크 큐 입장에서는 새치기 전용 줄이 따로 있는 셈이에요.
마이크로태스크는 다 비울 때까지 우선
setTimeout(() => {console.log("태스크");}, 0);Promise.resolve().then(() => {console.log("마이크로 1");}).then(() => {console.log("마이크로 2");});console.log("동기");
비어 있어요
비어 있어요
비어 있어요
비어 있어요
아직 출력이 없어요
실행 전이에요.
0ms짜리 setTimeout이 제일 늦게 실행되는 거, 이제 이유가 보이죠. 지연이 짧아서가 아니라 서 있는 줄이 달라서예요.
프로미스와 await로 다시 보기
이제 재료가 다 모였어요. 프로미스를 이 그림 위에 올려보죠.
.then(콜백)은 "프로미스가 이행되면 이 콜백을 마이크로태스크 큐에 넣어줘"라는 예약이에요. 그게 전부예요. 프로미스가 특별한 실행 엔진을 갖고 있는 게 아니라, 지금까지 본 큐 시스템을 그대로 쓰는 거예요.
그럼 await는요? await는 then의 문법 설탕인데, 동작이 꽤 재밌어요. async 함수가 await를 만나면 함수가 거기서 반으로 잘려요. 앞부분은 그 자리에서 끝나고(스택에서 내려가요), 나머지 반쪽은 "이어서 할 일"이 되어 프로미스가 이행될 때 마이크로태스크 큐로 들어가요.
await는 함수를 반으로 갈라요
async function order() {console.log("주문 접수");await Promise.resolve();console.log("주문 처리");}order();console.log("다음 손님");
비어 있어요
비어 있어요
비어 있어요
비어 있어요
아직 출력이 없어요
실행 전이에요. 함수 선언은 실행이 아니라 7줄부터 시작해요.
order가 스택에서 내려갔다가 마이크로태스크 큐를 거쳐 await 지점부터 복귀하는 게 핵심이에요. await가 "멈춰서 기다린다"처럼 읽히지만 실제로 멈추는 건 그 함수 하나뿐이고, 나머지 코드는 계속 달려요.
이쯤에서 맨 처음 퀴즈를 다시 보죠. 이번엔 시뮬레이터를 처음부터 끝까지 읽을 수 있을 거예요.
출력 순서 맞히기
console.log("A");setTimeout(() => {console.log("B");}, 0);Promise.resolve().then(() => {console.log("C");});console.log("D");
비어 있어요
비어 있어요
비어 있어요
비어 있어요
아직 출력이 없어요
실행 전이에요.
A는 동기, D도 동기, C는 마이크로태스크(새치기 줄), B는 태스크. 그래서 A → D → C → B. 처음에 틀렸던 문제가 이제는 당연해 보이면 성공이에요.
마지막 퀴즈
배운 걸 전부 섞은 문제예요. async 함수, setTimeout, then 체인이 다 들어가요.
async function run() {console.log("A");await Promise.resolve();console.log("B");}setTimeout(() => {console.log("C");}, 0);run().then(() => {console.log("D");});console.log("E");
풀이 요령은 하나예요. 동기 코드를 끝까지 따라가면서 각 콜백이 어느 줄에 서는지만 적어두면, 나머지는 "마이크로태스크 먼저, 다 비우고 태스크"라는 규칙이 알아서 정해줘요.
Node.js에서는 조금 다른 점
본문은 브라우저와 Node가 공유하는 공통 원리였는데, 백엔드 입장에서 알아두면 좋은 차이들이 있어요. 궁금한 것만 열어보면 돼요.
Node.js 이벤트 루프는 페이즈가 6개예요
Node의 이벤트 루프(libuv)는 태스크 큐가 하나가 아니라 용도별 페이즈 6개를 돌아요. 타이머(setTimeout·setInterval) → 펜딩 콜백 → 유휴/준비 → 폴(I/O 콜백, 대부분의 시간을 여기서 보내요) → 체크(setImmediate) → 클로즈 콜백 순서예요. 마이크로태스크는 페이즈 경계가 아니라 콜백 하나가 끝날 때마다 비워요(Node 11부터 브라우저와 같아졌어요). 그래서 같은 페이즈에 타이머 콜백 두 개가 대기 중이어도, 첫 콜백이 등록한 then 콜백이 두 번째 타이머 콜백보다 먼저 실행돼요. "마이크로태스크 우선" 규칙은 Node에서도 그대로 성립하고, 태스크 쪽이 여러 줄로 쪼개져 있다고 보면 돼요.
process.nextTick은 마이크로태스크보다도 먼저예요
Node에는 새치기 줄보다 더 앞에 서는 줄이 하나 더 있어요. process.nextTick으로 등록한 콜백은 프로미스 마이크로태스크보다도 먼저, 지금 실행 중인 콜백이 끝나는 즉시 처리돼요. 그래서 nextTick이 자기 자신을 재등록하면 프로미스조차 굶어요. Node 문서도 웬만하면 setImmediate를 쓰라고 권해요. 참고로 이름이 반대 같지만 setImmediate가 nextTick보다 늦어요. 역사적인 작명 실수예요. 한 가지 예외 — ES 모듈(type: module)의 최상위 코드는 그 자체가 마이크로태스크 큐 안에서 돌아서, 거기서는 이미 줄 서 있던 then 콜백이 nextTick보다 먼저일 수 있어요. CommonJS 최상위나 일반 콜백 안에서는 nextTick이 먼저라는 규칙 그대로예요.
await 한 번은 마이크로태스크 몇 개일까요
await x는 대략 Promise.resolve(x).then(나머지)로 변환돼요. x가 이미 이행된 프로미스면 재개까지 마이크로태스크 1개면 되는데, 예전 스펙에서는 여기에 2개가 더 들어가서 await가 then보다 항상 한 박자 늦었어요. 2019년에 스펙이 바뀌어서 지금은 차이가 거의 없어요. 시뮬레이터에서 "이어서 할 일"이 큐에 딱 한 번 들어가는 걸로 표현한 게 현재 스펙 기준이에요. 면접 단골 문제였던 "await vs then 순서"가 런타임 버전에 따라 답이 달랐던 이유이기도 해요.
정리하면 이래요. 자바스크립트는 한 번에 하나만 실행하고(콜스택), 기다리는 일은 런타임에 맡기고(Web API), 끝난 일은 줄을 서고(태스크 큐), 프로미스 뒷처리는 새치기 줄로 들어가고(마이크로태스크 큐), 이 줄들을 순서대로 비워주는 게 이벤트 루프예요. 다섯 문장이 전부인데, 이걸 그림 없이 글로만 배우려니 그렇게 헷갈렸던 것 같아요.
스터디에서 이 글로 발표할 예정이라, 각 섹션의 "생각해볼 점"은 토론 주제로 그대로 써먹어도 돼요. 시뮬레이터 눌러보다가 이상한 점을 찾으면 알려주세요. 스텝 데이터는 실제 실행 결과와 자동 대조하고 있긴 한데, 사람이 쓴 설명이 틀릴 수는 있으니까요.