V8 엔진은 NodeJS 외에도 chrome과 같은 웹 브라우저에서 사용되는 자바스크립트 엔진이다. V8 엔진을 간단하게 시각화해서 나타내면 다음과 같다.
Memory heap: 메모리 할당이 일어나는 곳
Call stack: 코드가 실행되면서 스택 프레임이 생성되는 곳
앞선 포스팅의 heap, stack 개념을 떠올리면 된다.
자바스크립트는 싱글 스레드 언어다. 이는 call stack이 하나뿐이라는 뜻이기 때문에 한 번에 한 가지 작업밖에 처리하지 못한다. 외부에서 데이터를 받아오는 것처럼 시간이 걸리는 작업을 모두 하나의 call stack에서 동기적으로 처리한다면 프로그램이 굉장히 느려질 것이다.
이 포스팅에서 설명하였듯 이러한 무거운 작업들은 비동기적으로 처리할 수 있다. 하지만 call stack이 하나뿐이라면 비동기 처리는 어떻게 이루어지는가?
자바스크립트 엔진은 단독으로 실행되지 않는다. 이는 웹 브라우저나 Node.js와 같은 호스팅 환경(hosting environment) 내에서 실행된다.
이러한 호스팅 환경은 자바스크립트 코드가 실행될 때 발생되는 이벤트(비동기 함수 처리)들을 스케줄링하는 역할을 한다. 간단하게 그 과정을 설명하자면 다음과 같다.
서버에서 데이터를 받아오기 위해 fetch 함수를 통해서 서버에 요청을 보내고, 응답이 오면 콜백 함수를 실행하고자 한다. 자바스크립트 엔진은 “이 콜백 함수의 실행은 잠시 미룰테니, 만약 네트워크 요청에 대한 응답이 오면 이 콜백 함수를 실행할 수 있도록 해줘”라고 호스팅 환경에게 알려준다. 호스팅 환경은 응답이 오면 콜백 함수가 실행될 수 있도록 이를 callback queue라는 곳에 넣는다. 이벤트 루프(event loop)는 callback queue를 주기적으로 체크하면서, 콜백 함수를 call stack에 넣어 실행되도록 한다.
관련 요소들을 나타낸 다이어그램은 다음과 같다.
좀 더 자세히 살펴보도록 하자.
이벤트 루프는 call stack과 callback queue(=task queue)를 주기적으로 모니터링 한다.
Web API는 비동기 처리가 종료되면, 실행할 콜백 함수를 callback queue에 넣는다.
Callback queue는 Web API가 보낸 콜백 함수들을 순서대로 저장한다. FIFO(first in first out) 방식으로 작동한다.
이벤트 루프는 call stack이 비어있는지 체크하고, 비어있을 경우 callback queue의 첫 번째 이벤트(콜백 함수)를 call stack에 넣는다.
이벤트 루프의 한 반복(iteration)을 tick이라고 한다.
❗ ES6에서부터는 이벤트 루프가 자바스크립트 엔진의 역할에 포함되었다고 한다.
🤔 왜 call stack이 빌 때까지 기다리는가? (관련 링크)
Call stack이 빌 때까지 기다리지 않고 랜덤한 순간에 push 된다면 상황이 복잡해진다. 만약 push된 함수가 실행될 때 또다른 함수가 또 push되어서 실행된다면? 점점 스케줄링이 복잡해질 것이고, 이처럼 다른 함수에 의해 인터럽트 되는 상황까지 추가적으로 고려해서 함수를 설계해야될 것이다.
그렇다면 setTimeout(f1, 1000ms)
와 같이 작성하였을 때 f1이 정확히 1000ms 후에 실행되지 않을 수 있다는 말인가? 그렇다. 이는 1000ms 후에 f1이 실행된다는 의미가 아니라, 1000ms 후에 callback queue에 추가된다는 의미이다.
다음과 같은 코드를 실행했을 때
console.log('Hi');
setTimeout(function() {
console.log('callback');
}, 0);
console.log('Bye');
실행 결과는 다음과 같다.
Hi
Bye
callback
setTimeout에 등록한 콜백 함수는 callback queue에 들어가고, 이벤트 루프는 call stack이 빌 때까지 기다린 다음 call stack에 push하기 때문이다.
🤔 Event loop은 언제 empty 상태가 되는가? (관련 링크)
다음과 같은 코드를 실행했을 때, bar
가 bye
보다 먼저 출력될까, 아니면 bye
가 bar
보다 먼저 출력될까?
function f() {
console.log("foo");
setTimeout(g, 0);
console.log("foo again");
}
function g() {
console.log("bar");
}
function b() {
console.log("bye");
}
f();
b();
foo
foo again
bye
bar
이벤트 루프는 call stack이 비었을 때 콜백 함수를 call stack에 넣는다고 했다. 그러면 f()
의 실행이 끝나고 b()
가 실행되기 전 call stack이 비어있는 것이 아닐까? 이 때 콜백 함수 g
를 call stack에 넣어야 되는 게 아닌가?
이 질문에 대한 답변은 다음과 같았다. 위 코드가 함수로 싸여있지 않더라도 코드 전체를 자바스크립트가 실행해야할 global function이라고 생각하자. 그러면 위 코드가 끝까지 실행되고 난 후에야 call stack이 빈다고 생각할 수 있다.
ES6에서 promise가 새로 추가되면서 job queue도 함께 등장했다. 앞서 비동기 함수 실행이 끝나면 Web API가 콜백 함수를 callback queue에 넣는다고 하였다. 이제는 콜백 함수가 task와 job의 두 종류로 나뉜다. Job은 job queue에 들어가고 task는 callback queue(=task queue, 이제부터는 task queue라는 용어를 사용하겠다)에 들어간다.
Macrotasks = tasks, MicroTasks = jobs이다.
Macrotasks의 예에는 setTimeout
, setInterval
(에 등록한 콜백함수), I/O tasks
등이 있다.
Microtasks의 예에는 promises
, processes.nextTick
등이 있다.
Tasks를 관리하는 queue는 task queue라고 한다.
Jobs를 관리하는 queue는 job queue라고 한다.
Job의 우선순위가 task의 우선순위보다 높다. 이벤트 루프는 한 번에 하나의 job/task를 꺼내서 call stack에 넣는데, 만약 task queue와 job queue에 모두 task와 job이 들어있을 경우 job을 먼저 꺼내서 call stack에 넣는다.
다음과 같은 코드를 실행했을 때
const tom = () => console.log('Tom');
const jerry = () => console.log('Jerry');
const cartoon = () => {
console.log('Cartoon');
setTimeout(tom, 0);
new Promise((resolve, reject) =>
resolve('should it be right after Tom, before Jerry?')
).then(resolve => console.log(resolve))
jerry();
}
cartoon();
결과는 다음과 같다.
Cartoon
Jerry
should it be right after Tom, before Jerry?
Tom
cartoon
함수가 먼저 call stack에 들어가 실행된다.
cartoon
함수에서 우선 “cartoon”이란 텍스트가 출력된다.
WebAPI는 call stack 밖에서 0초 후 등록된 콜백 함수인 tom
을 task queue에 넣는다.
그 동안 promise에 등록된 콜백 함수는 job queue로 들어간다.
이후 jerry
가 call stack에 들어가 실행되고 빠져나온다.
cartoon
함수가 call stack에서 빠져나온다.
Call stack이 빈 상태가 되었고, task queue와 job queue에 함수가 하나씩 있는 상태이다. 이벤트 루프는 task보다 job을 먼저 꺼내서 실행한다. 따라서 promise에 등록된 콜백 함수가 먼저 call stack으로 들어가 실행된다음, task queue에 들어있는 함수 tom
이 call stack으로 들어가 실행된다.
Job, task의 우선순위에 대한 이해를 도울 수 있는 gif가 있어서 함께 첨부한다. (링크) Job이 task보다 우선순위가 높으므로 (그림에선 job queue = microtask queue 안의 job도 모두 task로 표기되었음에 유의) 모든 job이 microtask queue에서 빠져나온 후 task가 call stack에 push된다.
Microtask queue, macrotask queue 외에 하나의 큐가 더 있다. 바로 animation frame callback queue인데, requestAnimationFrame
의 콜백 함수가 해당 큐에 등록되어 실행된다.
Animation Frame Callback Queue는
setInterval()
대신 requestAnimationFrame()
사용을 권장하는 이유
setInterval()
은 브라우저가 실제로 화면을 그리는지 여부에 관계없이 실행된다. 따라서 앱을 화면에 띄우지 않은 상태여도 백그라운드에서 계속 실행되기 때문에 CPU를 소모하고 디바이스의 배터리를 낭비하게 된다. 이렇듯 다음 렌더링 사이클에 도달하기도 전에 setInterval()
에 등록된 함수가 실행되면 그 결과가 그려지지 않고 그냥 버려진다.requestAnimationFrame()
은 브라우저가 다음에 그릴 프레임을 준비할 때까지 기다린 후 실행된다. 따라서 해당 앱이 화면에 띄워지지 않고 백그라운드에서 실행되고 있는 상태라면 실행되지 않는다.참고 자료
How JavaScript works: an overview of the engine, the runtime, and the call stack
Understanding the JavaScript runtime environment
Task Queue and Job Queue - Deep dive into Javascript Event Loop Model
자바스크립트와 이벤트 루프 : NHN Cloud Meetup
⭐️🎀 JavaScript Visualized: Promises & Async/Await
Performance fundamentals - Web performance | MDN
Better Performance With requestAnimationFrame
Understand JavaScript's Event Loop
좋은 정보 감사합니다:)