들어가며
JavaScript로 개발을 하는 대부분의 프론트엔드 개발자 분들은 setTimeout 함수를 사용해본 경험이 있으실 거라고 생각합니다. 주로 일정 시간 후에 코드를 실행하거나, 애니메이션 효과를 구현하는데에도 종종 사용하는 함수이죠. 이 setTimeout 함수를 setTimeout(fn, 0)과 같이 사용하면 함수가 즉시 실행될까요? 직관적으로 생각해보면 즉시 실행될 것 같지만, 실제로는 그렇지 않기 때문에 많은 입문 개발자들에게 혼란스럽게 다가올 수 있을 것 같습니다.
이 현상을 이해하려면 JavaScript의 이벤트 루프와 비동기 처리 메커니즘을 살펴볼 필요가 있습니다. 이 개념들은 JavaScript에서 작업들이 어떻게 처리되고, 실행 순서를 어떻게 결정하는지 설명해주는 원리인데요. 이 글에서는 setTimeout(0)의 동작 결과에서 시작해 JavaScript 내부 동작까지 탐구해 보겠습니다.
0ms 인데도 지연이 되나요?
setTimeout(fn, 0)은 함수 실행을 '최소한의 지연'이라는 의미로 '예약'하게 됩니다. 여기서 0ms는 직관적으로 ‘즉시 실행’을 의미하는 것 같지만, 실제로는 가능한 한 빨리, 현재 실행 중인 코드 다음에 실행하라는 의미입니다.
중요한 점은, 0ms로 설정했더라도 setTimeout을 사용하는 것 자체가 해당 콜백함수를 매크로태스크 큐에 넣게 된다는 것입니다. 이는 setTimeout으로 실행하는 함수가 항상 지연되어 실행되는 근본적인 이유입니다. 매크로태스크 큐의 특성상, 이 큐의 태스크들은 현재 실행 중인 스크립트와 모든 마이크로태스크가 완료된 후에야 처리됩니다.
console.log('시작');
setTimeout(() => console.log('setTimeout 콜백'), 0);
console.log('종료');
// 출력:
// 시작
// 종료
// setTimeout 콜백
위 코드에서 ‘setTimeout 콜백’은 마지막에 출력되고 있습니다. 이 현상을 이해하기 위해서는 JavaScript의 이벤트 루프 동작 방식을 자세히 알아볼 필요가 있습니다.
이벤트 루프
이벤트 루프는 JavaScript의 동시성 모델의 핵심으로, 단일 스레드 언어인 JavaScript가 비동기 작업을 효율적으로 처리할 수 있게 해줍니다. 이벤트 루프는 다음과 같은 주요 요소로 구성됩니다.
이 글에서는 일관성을 위해 '콜백 큐(Callback Queue)' 또는 '태스크 큐(Task Queue)'라는 용어 대신 '매크로태스크 큐(Macrotask Queue)'라는 용어를 사용하겠습니다. 본질적으로 같은 개념입니다.
- 콜 스택 (Call Stack): 현재 실행 중인 함수들이 쌓이는 곳입니다.
- 웹 API (브라우저 환경) 또는 C++ API (Node.js 환경): setTimeout 같은 비동기 작업을 처리합니다.
- 매크로태스크 큐 (Macrotask Queue): setTimeout, setInterval 등의 콜백이 대기하는 곳입니다.
- 마이크로태스크 큐 (Microtask Queue): Promise 콜백 등이 대기하는 곳입니다.
이벤트 루프의 기본 동작 순서는 다음과 같습니다.
- 콜 스택의 모든 작업을 실행합니다. 콜 스택이 빌 때까지 현재 실행 중인 스크립트를 계속 실행합니다.
- 콜 스택이 비면, 마이크로태스크 큐를 확인합니다. 마이크로태스크가 있다면 가장 오래된 태스크부터 하나씩 실행하면서 마이크로태스크 큐가 빌 때까지 계속 진행합니다.
- 만약 브라우저 환경이라면, 이 시점에 필요 시 렌더링 업데이트를 진행합니다.
- 마이크로태스크 큐가 비면, 매크로태스크 큐에서 가장 오래된 하나의 태스크를 가져와 실행합니다.
- 이 과정을 반복합니다!
이러한 동작 방식 때문에 setTimeout(0)이 즉시 실행되지 않고, 현재 실행 중인 스크립트와 모든 마이크로태스크가 완료된 후에야 실행되는 것입니다.
이벤트 루프의 동작 방식을 이해했으니, 이제 이벤트 루프가 처리하는 두 가지 주요 태스크 유형인 매크로태스크와 마이크로태스크에 대해 더 자세히 알아보겠습니다. 이 두 유형의 태스크는 이벤트 루프 내에서 서로 다른 방식으로 처리됩니다.
매크로태스크 vs 마이크로태스크
JavaScript에서 비동기 작업은 매크로태스크와 마이크로태스크로 나뉩니다.
- 매크로태스크: setTimeout, setInterval, setImmediate, I/O 작업 등
- 이벤트 루프의 한 사이클에 하나씩 처리됩니다.
- 마이크로태스크: Promise, process.nextTick, MutationObserver 등
- 현재 실행 중인 스크립트나 태스크가 끝난 직후 모두 처리됩니다.
이 둘의 가장 큰 차이점은 실행 우선순위입니다. 마이크로태스크는 매크로태스크보다 항상 먼저 실행됩니다.
좀 더 구체적으로 정리하자면 다음과 같습니다.
- 현재 실행 중인 매크로태스크가 완료됩니다.
- 마이크로태스크 큐의 모든 태스크가 처리됩니다.
- 다음 매크로태스크가 처리됩니다.
- 다시 마이크로태스크 큐의 태스크들이 처리됩니다.
따라서 마이크로태스크는 매크로태스크보다 더 빨리, 자주 실행되는 편입니다.
아래의 코드를 통해서 매크로태스크와 마이크로태스크의 실행 순서를 비교해볼 수 있습니다.
console.log('스크립트 시작');
setTimeout(() => console.log('setTimeout'), 0);
Promise.resolve().then(() => console.log('프로미스'));
console.log('스크립트 종료');
// 출력:
// 스크립트 시작
// 스크립트 종료
// 프로미스
// setTimeout
위 예제에서 동기 코드에 해당하는 ‘스크립트 시작’과 ‘스크립트 종료’가 출력된 후, Promise (마이크로태스크)가 setTimeout (매크로태스크)보다 먼저 실행되는 것을 볼 수 있습니다.
이제 매크로태스크와 마이크로태스크의 차이를 이해했으니, 이벤트 루프 내에서 setTimeout(0)이 어떻게 처리되는지 자세히 살펴보겠습니다.
이벤트 루프 속에서의 setTimeout(0)
이제 setTimeout(0)이 어떻게 처리되는지 단계별로 살펴보겠습니다.
- setTimeout(0)이 호출되면, 콜백 함수는 Web API(또는 Node.js의 C++ API)로 전달됩니다.
- 0ms로 설정된 타이머는 즉시 만료되고, 콜백 함수는 매크로태스크 큐에 추가됩니다.
- 콜 스택이 비워지며 현재 실행 중인 스크립트가 완료됩니다.
- 콜 스택이 비워지면, 이벤트 루프는 먼저 마이크로태스크 큐를 확인하고, 마이크로태스크 큐에 태스크가 있다면 순서대로 모두 실행합니다. 이 과정은 마이크로태스크 큐가 완전히 비워질 때까지 진행됩니다.
- 브라우저 환경에서 화면 렌더링이 필요할 시 수행됩니다. 이는 DOM 변경사항을 화면에 반영하는 과정입니다.
- 마이크로태스크 처리와 렌더링이 완료된 후, 이벤트 루프는 매크로태스크 큐를 확인합니다. 매크로태스크 큐에서 첫 번째 태스크(setTimeout)를 가져와 실행합니다. 이 시점에서 비로소 setTimeout(0)의 콜백 함수가 실행됩니다.
이 과정을 이해한다면, setTimeout(0)이 왜 즉시 실행되지 않고, 현재 스크립트 실행과 모든 마이크로태스크, 그리고 잠재적인 렌더링 이후에 실행되는지에 대한 이유를 알 수 있을 것입니다.
마치며
setTimeout(0)이 즉시 실행되지 않는 이유를 탐구하면서, JavaScript의 이벤트 루프와 매크로태스크 등의 개념에 대해 깊이 있게 알아볼 수 있었습니다. 이 과정에서 setTimeout(0)이 단순한 지연 도구가 아니라, 코드 실행을 다음 이벤트 루프 사이클로 미루는 흥미로운 개념임을 알 수 있었습니다.
이번 탐구를 통해 JavaScript의 비동기 처리 메커니즘에 대한 이해도가 한층 높아진 것 같습니다. 특히 이벤트 루프와 태스크 큐의 개념은 복잡한 비동기 로직을 다룰 때의 기본기가 될 수 있을 것 같아요. 앞으로 비동기 코드를 작성할 때 이러한 지식을 바탕으로 더 신중하게 접근할 수 있을 것 같습니다!
ref