이번 편의 결과물: console.log, setTimeout, Promise를 섞은 스크립트의 실행 순서를 먼저 예측하고, 실행해 검증합니다. 08편(파일 저장)에서 쓸 async/await의 동작 원리를 이해합니다. · 다루는 개념: 콜스택, 태스크 큐·마이크로태스크 큐, 동기·비동기 실행 순서
이 편에서 만드는 파일
이번 편은 이론 편입니다. todo-cli 본체는 수정하지 않고, 실행 순서를 확인하는 임시 스크립트만 하나 만듭니다.
todo-cli/
└── playground/
└── event-loop-order.js (+, 확인용, 08편 이후에는 쓰지 않음)개념 정리
JavaScript는 싱글 스레드다
JavaScript 엔진은 한 번에 한 가지 코드만 실행하는 콜스택(call stack) 하나를 씁니다. 함수를 호출하면 스택에 쌓이고, 끝나면 스택에서 빠집니다. 콜스택이 비어 있어야 다음 작업을 실행할 수 있습니다.
태스크 큐와 마이크로태스크 큐
setTimeout, Promise처럼 즉시 끝나지 않는 작업은 결과가 준비되어도 바로 실행되지 않고 대기열에 들어갑니다. 대기열은 두 종류가 있고 우선순위가 다릅니다.
| 대기열 | 들어가는 작업 | 실행 시점 |
|---|---|---|
| 마이크로태스크 큐 | Promise.then/catch/finally, async 함수의 await 이후 코드 | 콜스택이 빌 때마다, 다음 매크로태스크보다 먼저 |
| 태스크 큐(매크로태스크 큐) | setTimeout, setInterval, I/O 콜백 | 콜스택이 비고 마이크로태스크 큐도 빈 뒤 |
이벤트 루프(event loop)는 “콜스택이 비었는지 반복 확인하다가, 비어 있으면 마이크로태스크 큐를 모두 비우고, 그다음 태스크 큐에서 하나를 꺼내 실행”하는 절차입니다. 마이크로태스크 큐는 한 번에 전부 비우지만, 태스크 큐는 한 번에 하나씩만 꺼냅니다.
실행 순서 규칙 정리
| 순서 | 코드 종류 |
|---|---|
| 1 | 동기 코드(위에서 아래로 즉시 실행) |
| 2 | 마이크로태스크(Promise 후속 처리) 전부 |
| 3 | 태스크(setTimeout 콜백 등) 하나, 그 뒤 다시 2번부터 반복 |
await 뒤의 코드는 Promise.then 안의 코드와 같은 취급을 받습니다. async 함수 안에서 await를 만나면 그 시점에 함수 실행을 멈추고 제어권을 반환하며, await가 끝나는 시점에 마이크로태스크로 이어서 실행됩니다. 08편에서 fs/promises와 await로 파일을 읽고 쓸 때, 파일 읽기가 끝나기를 기다리는 동안 다른 동기 코드가 먼저 실행되는 이유가 이 순서 때문입니다.
실습
1. playground 폴더 만들기
todo-cli/playground/event-loop-order.js 파일을 만듭니다.
2. 코드 작성 전 순서 예측
아래 코드를 실행하기 전에 출력 순서를 먼저 종이에 적어봅니다.
// playground/event-loop-order.js
console.log('1. 동기 코드 시작');
setTimeout(() => {
console.log('2. setTimeout 콜백(태스크)');
}, 0);
Promise.resolve().then(() => {
console.log('3. Promise.then(마이크로태스크)');
});
console.log('4. 동기 코드 끝');3. 실행
node playground/event-loop-order.js4. 확인
- 실제 출력 순서는 아래와 같습니다. 예측과 비교합니다.
1. 동기 코드 시작
4. 동기 코드 끝
3. Promise.then(마이크로태스크)
2. setTimeout 콜백(태스크)setTimeout(..., 0)처럼 지연 시간을 0으로 줘도 동기 코드와 마이크로태스크보다 항상 늦게 실행됩니다. “0초 뒤 실행”이 아니라 “지금 실행 중인 코드와 마이크로태스크가 끝난 뒤 태스크 큐에 넣는다”는 뜻입니다.
직접 해보기
event-loop-order.js에Promise.resolve().then(...)을 하나 더 추가해, 마이크로태스크 두 개가 태스크(setTimeout)보다 먼저 실행되는지 확인합니다.async function main() { console.log('A'); await null; console.log('B'); } main(); console.log('C');를 실행해A,C,B순서로 나오는 이유를await이후 코드가 마이크로태스크로 처리되기 때문이라고 설명해봅니다.
정답 보기
await null 이후의 console.log('B')는 Promise.resolve().then(() => console.log('B'))와 동일하게 취급되어 마이크로태스크 큐에 들어갑니다. main() 호출 시 await를 만나기 전까지(console.log('A'))는 동기로 실행되고, 그 뒤 제어권이 호출한 쪽으로 돌아가 console.log('C')가 먼저 실행됩니다. 콜스택이 빈 뒤 마이크로태스크 큐의 console.log('B')가 실행되어 A, C, B 순서가 됩니다.
자주 하는 실수
| 증상 | 원인 | 고치는 법 |
|---|---|---|
setTimeout(fn, 0)이 즉시 실행될 거라 기대함 | ”0초”를 “즉시”로 착각 | setTimeout은 지연 시간과 무관하게 항상 태스크 큐를 거친다고 이해한다 |
Promise.then 콜백이 setTimeout보다 늦게 실행될 거라 예상 | 마이크로태스크와 태스크 우선순위를 반대로 기억함 | 마이크로태스크 큐가 항상 태스크 큐보다 먼저 전부 비워진다는 규칙을 외운다 |
await 이후 코드가 즉시 실행될 거라 기대 | await가 코드를 멈추고 마이크로태스크로 넘긴다는 점을 놓침 | await 뒤 코드는 .then() 콜백과 같은 취급이라고 생각한다 |