Node.js를 처음 사용할 때 기술에 대해 더 알기 위해 공부하다보면 핵심 개념인 Event loop를 마주하게 된다.
처음에는 ‘아 Event loop라는게 이런거구나’ 라고 슬쩍 보고 넘어갔는데, 개발을 하다보니 node.js의 싱글스레드 환경에 대해 더욱 궁금해져서 이 부분에 대해 딥다이브 해보기 시작했다. 그러다보니 결국 Node.js → Event loop → 자바스크립트 런타임까지 도달하게 되었다. 오늘은 공부하면서 알게 된 자바스크립트 런타임과 그 구성요소들에 대해서 정리해보려고 한다.
먼저 런타임이란 무엇인지, 그리고 자바스크립트 엔진이 무엇이고 어떤 차이가 있는지부터 알아보자.
런타임이란, 프로그램이 실행되고 있는 시간을 의미한다.(running phase of a program) 즉, 자바스크립트 런타임은 ‘자바스크립트 언어로 작성한 프로그램이 실행되고 있는 시간’이다. 근데 보통 우리가 사용하는 런타임 이란 ‘런타임 환경’을 의미하는 경우가 대부분이다.
런타임 환경은 (자바스크립트로 작성한 프로그램이) 구동될 수 있는 실행 환경을 의미한다. 우리는 이 런타임 환경을 통해서 우리가 작성한 프로그램이 시스템 자원 (ex: RAM, 시스템 변수, 환경 변수 등과 같은 시스템 리소스)에 액세스 할 수 있게 만든다.
우리가 아는 대표적인 자바스크립트 런타임 환경에는 ‘웹 브라우저’, ‘Node.js’ 등이 있다.
(이제부터 ‘런타임 환경’은 줄여서 ‘런타임’이라고 부르겠다.)
개략적인 자바스크립트 런타임 환경 구조도
그러면 자바스크립트 엔진은 무엇일까? 엔진은 런타임 내에서 자바스크립트 코드를 실행하기 위해 사용하는 도구라고 볼 수 있는데, 코드를 실행하기 위해서는 코드를 해석하고 변화하는 컴파일 단계가 필요하다.
즉, 자바스크립트 엔진은 자바스크립트 코드를 parse, interpret하고 compile 해주는 역할이다.
자바스크립트 엔진의 주요 구성 요소에 대해 알아보자.
1. Parser : 작성한 코드를 한 줄씩 읽고, AST(Abstract Syntax Tree)로 변환해주는 프로그램. 파싱을 통해 코드가 자바스크립트가 정의한 syntax와 rule에 부합하는지 분석한다.
AST란 Tree 구조의 자료 구조를 의미한다. 코드에 있는 구문(expressions, statements, and declarations 등)이 각 트리에 있는 노드와 매치된다. 트리의 구조를 보면 코드 작성 구조에 대해 알 수 있다.
Parser에 대한 좀 더 자세한 내용은 이 글들을 추천한다.
2. Interpreter : Parser가 AST를 만든 뒤, Interpreter는 bytecode라고 부르는 (Interpreter가 실행할 수 있는) 중간 단계의 명령어를 생성한 뒤, 라인 바이 라인으로 명령어를 실행한다.
3. Compiler : 모던 자바스크립트 엔진의 경우 JIT Compiler를 사용한다. 컴파일러는 bytecode를 optimized machine code로 변환해서 실행 속도를 향상시키는 역할을 한다. (보통은 Profiler라는 것도 같이 사용해서 Profiler가 바이트코드를 계속 관찰하면서 자주 사용되는 함수 및 타입 정보등의 프로파일링 데이터와 바이트코드를 Compiler에게 보내면 Compiler는 Optimized machine code를 생성한다.)
JIT Compiler : Just In Time Compiler의 약자로 필요할 때마다 컴파일을하는 Compiler를 의미한다.전체 코드를 컴파일하기 위해 기다리지 않고, 자주 사용하는 부분(hotspots)은 캐싱해서 더 빠르게 실행할 수 있게 도와준다.
구글의 V8 자바스크립트 엔진의 프로세스, Yan Huly
4. Memory Heap : 프로그램을 실행하면서 Object나 Function 등에 의해 할당되고 관리해야하는 메모리 풀. Array, Object, Function 등의 데이터는 힙에 저장한다.
(Primitive type이라고 하는 숫자, 문자 등의 데이터, 함수 인자, 함수 내부의 변수 식별자 등은 힙이 아니라 콜스택에 보관하는 Execution context에 저장한다.
5. Call Stack : 현재 실행하고 있는 함수의 실행 컨텍스트를 추적하는 스택.(스택은 LIFO 형태의 자료구조이다.) 코드가 실행되기 시작하면 global execution context라는게 만들어지고, 콜 스택에 처음 들어온다.
스택의 특성에 따라 함수가 실행 될 때마다 스택에는 새로운 execution context가 쌓인다. 그 함수가 실행이 끝나면, execution context는 스택에서 빠져나간다.
모든 함수의 실행이 끝나면 마지막 execution context가 스택에서 빠져나가고, 마지막으로 global execution context가 스택에서 빠져나가면서 자바스크립트 코드 실행이 종료 된다.
보통의 자바스크립트 엔진은 단일 콜 스택을 사용한다. 즉, 요청이 들어올 때마다 해당 요청을 순차적으로 호출 스택에 담아 처리한다.
Execution Context: 코드 실행하는데 필요한 조건이나 상태를 모아둔 객체. 매개변수 식별자, 함수 내부 변수 식별자, this value 프로퍼티 등이 담겨있다.
Execution context는 크게 2가지로 나뉜다.
Global : 자바스크립트 코드가 실행 될 때 set up 되는 첫번째 execution context.
Function : 함수가 실행되면 새로운 execution context가 만들어진다. 이 context에서는 그 함수의 로컬 변수들을 사용할 수 있게 만들어주고, scope를 외부와 분리 시켜준다.
Execution Context에 대해 더 자세히 알고 싶으면 아래의 자료들을 읽어보길 바란다.
콜 스택은 중요한 부분이라 좀 더 자세하게 실행 원리에 대해 설명해보고싶으나, 이 글은 자바스크립트 엔진의 모든 구성 요소들을 다루는게 목적이니 별도의 글로 다뤄보도록 하겠다.
6. Garbage Collector : 더 이상 사용하지 않는 메모리 주소들을 식별하고, free 시키는 메커니즘. 메모리 누수를 방지하기 위해 코드에 의해 참조가 더 이상 되지 않는 변수나 객체들을 찾아서 free 시킨다.
대표적인 자바스크립트 엔진으로는 구글에서 만든 v8 엔진이 있다. Chrome 브라우저, Node.js에 쓰이는 엔진이다.
이제 엔진을 설명했으니, 나머지 개념들을 다뤄보자.
런타임에는 코드를 실행해주는 엔진 뿐만 아니라 이벤트 루프, Callback queue, 고유 API(Web API, Node.js API) 등의 구성요소가 있다.
아래 그림은 Node.js 실행 환경에 대한 구조도이다.
그림에 나와있듯이 이벤트 루프는 Node.js 내에서 asynchronous I/O를 처리하기 위해 사용하는 개념이다.
동작방식에 대해 알아보기 전에, 자바스크립트의 Run to Completion
특징에 대해 알아보자.
Run to Completion
은 하나의 함수가 실행되면 이 함수의 실행이 끝날때까지는 다른 어떤 작업도 중간에 끼어들지 못한다는 의미이다.
앞서 말했듯이 자바스크립트 엔진은 하나의 콜 스택을 사용하기 때문에, 현재 스택에 쌓여있는 모든 함수들이 실행을 마치고 스택에서 제거되기 전까지는 다른 어떠한 함수도 실행될 수 없다. 다음의 예제를 보자.
function makeDelay() {
for (var i = 0; i < 100000; i++);
}
function two() {
makeDelay();
three();
console.log("two!");
}
function three() {
makeDelay();
console.log("three!");
}
function one() {
console.log("one!");
}
setTimeout(one, 10);
two();
이 함수를 실행해보면 ‘two!’가 ‘one!’보다 먼저 콘솔에 찍힌다. 즉, two
내부에서 three()
를 호출하기 전에 10ms이 지났다고 해도 one
이 먼저 호출되지 않는다.
위의 예제를 실행하면 콘솔에는 'three!' -> 'two!' -> 'one!'의 순서로 출력이 된다.
각 시점의 콜 스택으로 살펴보자.
1
| setTimeout |
|global execution context |
2
| console.log("three") |
| three() |
| two() |
|global execution context |
3
| console.log("two") |
| two() |
|global execution context |
4
| console.log("one") |
| one() |
|global execution context |
1.setTimeout
함수는 런타임에게 타이머 이벤트를 요청한 후에 바로 스택에서 제거된다.
2.그 후에 two
함수가 스택에 추가되고, two
함수가 내부적으로 실행 하는 함수들이 차례로 스택에 추가되었다가 제거된다.
3.two
함수가 실행을 마치면서 호출 스택이 비워진다.
4.그 이후에 one
함수가 스택에 추가되어 콘솔에 'one!'이 출력 된다.
(one
함수는 10ms보다 더 늦게 실행될 수 있다.)
그런데 setTimeout
함수를 통해 넘긴 one
함수는 어떻게 two
함수가 끝나자 마자 실행될 수 있는걸까?
여기서 이벤트 루프와 Callback queue가 등장한다.
이벤트 루프는 콜 스택을 감시하면서 콜 스택이 비워질 때마다 Callback queue에서 콜백 함수를 꺼내와서 실행하는 역할을 담당한다.
Callback queue는 말 그대로 콜백 함수들이 대기하는 FIFO 형태의 큐이다.
앞의 예제 코드를 다시 살펴보자. 해당 자바스크립트 코드가 처음 실행되면 setTimeout
함수를 실행한다.
함수를 실행하고 10ms이 지나면 런타임의 타이머 스레드가 one
을 바로 실행하지 않고 Callback queue에 추가한다. 이벤트 루프는 콜 스택을 지켜보면서 콜 스택이 비워지면 Callback queue에서 대기중인 첫번째 callback을 실행할 것이다. two
가 실행을 마치고 콜 스택이 비워지면, 그 때 이벤트 루프가 Callback queue에 대기중인 첫번째 callback인 one
을 실행해서 콜 스택에 추가한다.
즉,
이벤트 루프를 통해 자바스크립트 런타임은 콜 스택이 하나 있는 싱글스레드 자바스크립트 엔진이 비동기 작업을 처리할 수 있게 도와준다.
예를 들어 콜 스택만 있다면 런타임(ex.웹 브라우저)에서 파일을 다운 받거나 키보드를 타이핑 하는 동안 자바스크립트 엔진은 해당 작업만 계속 실행하게 되어 사용자는 그 외의 아무 동작을 하지 못할 것이다.
그러나 이벤트 루프가 이러한 작업을 런타임의 다른 스레드에게 인가하여 비동기로 처리해주기 때문에 우리는 동시에 다른 작업들도 처리할 수 있다.
Microtask queue는 자바스크립트 Promise 객체의 콜백 함수 등이 쌓이는 곳이다. Microtask queue는 callback queue보다 우선해서 처리된다.
아래의 예제를 통해 동작 원리를 이해해보자.
setTimeout(function() {
// one
console.log("one");
}, 0);
Promise.resolve()
.then(function() {
// two
console.log("two");
})
.then(function() {
// three
console.log("three");
});
이 코드의 console 출력 순서는 two
→ three
→ one
이 된다.
좀 더 자세한 동작 구조를 살펴보자.
setTimeout()
함수가 실행 된다. 타이머 종료 시 콜백 함수 one
은 Callback queue에 추가된다. then
핸들러를 통해 콜백함수 two
를 Callback 큐가 아닌 Microtask queue에 추가한다. two
를 콜 스택에 추가해서 실행한다. two
가 실행 완료되면 두번째 then
핸들러가 콜백 함수 three
를 마이크로 Microtask queue에 추가한다. three
를 콜 스택에 추가해서 실행한다. one
을 꺼내서 콜 스택에 추가하고 실행한다.참고로, Microtask queue는 한번 이벤트 루프가 체크할 때 queue에 있는 모든 task들을 처리 하지만, Callback queue의 경우에는 이벤트 루프가 한번 체크할 때(하나의 라운드) 하나의 task를 처리한다.
위에서는 심플한 설명을 위해 Microtask queue에 Promise 콜백만 담긴다고 했지만, 브라우저의 MutationObserver API, Node.js의 process.nextTick API 등도 포함 된다.
또한 앞에서 다뤘던 setTimeout 은 microtask와 반대의 의미로macrotask
라고 한다. macrotask에는 setInterval, setImmediate, requestAnimationFrame 등이 있다.(callback queue는 task queue라고도 부를 수 있다.)
Microtask queue를 다룰 때 한 가지 조심해야 할 점이 있다.
Microtask queue의 콜백은 브라우저의 렌더링, task queue보다 높은 우선순위로 처리 되기 때문에 만약 Microtask queue가 무한 루프를 일으키게 되면 브라우저가 먹통이 되어버릴 수 있다.
(왜냐하면 MicroTask Queue가 비어있지 않으므로 다른 macrotask나 렌더링 작업이 실행될 수 없기 때문이다. 끊임없이 MicroTask Queue에 콜백이 적재되면 이벤트 루프가 이 콜백들을 브라우저 화면 렌더링 전에 우선 처리하기 때문에, 결국 사용자 이벤트에 반응하지 못하고, 페이지가 멈추거나 응답하지 않게 된다.)
위에서 setTimeout
을 설명하면서 런타임 고유의 API를 잠깐 언급했었다.
런타임이 브라우저일 경우 Web API를 가지고 있고, Node.js일 경우 Node.js API를 가지고 있다.
이 API들은 자바스크립트(ECMAScript)의 문법 이외에 별도로 존재하는 API로서 웹 애플리케이션을 만들기 위해 런타임에서 제공하는 기능들이라고 볼 수 있다.
예를 들어 브라우저에서는 DOM, XMLHttpRequest, Fetch,setTimeout 등의 API를 제공하고, Node.js에서는 process, path, fs, setTimeout 등의 API를 제공한다.
(참고로, Node.js의 setTimeout
같은 타이머 관련 API는 브라우저의 setTimeout
을 본따서 만든 API이다.)
이 API 덕분에 자바스크립트 개발자들은 DOM과 인터랙션하거나 타이머로 스케쥴링 작업을 하거나 fetch로 네트워크 요청 등의 작업을 진행할 수 있는 것이다.
비동기 작업을 처리하는 API(네트워크 요청, 타이머, 파일 읽/쓰기 등)의 경우, 별도의 스레드(thread pool)가 백그라운드에서 처리하고, 그 작업이 끝나면 콜백 함수를 Callback queue에 추가하는 방식이기 때문에 개발자는 싱글 스레드(이벤트 루프)가 아닌 멀티 스레드(이벤트 루프, thread pool) 환경에서 자바스크립트 코드를 실행할 수 있게 된다.
다만, 멀티 스레드를 사용할 수 있으니 무조건 동시에 더 많은 작업을 실행할 수 있다라고 생각할 수 있지만, 이벤트 루프이든, thread pool이든 실행 시간이 긴 하나의 작업을 처리할 경우 다른 작업을 처리하지 못하고 ‘blocking’ 될 수 있으니 주의해야 한다.
지금까지 자바스크립트 엔진, 이벤트 루프, Callback queue, Microtask queue, 고유 API 등의 요소들을 통해 자바스크립트 런타임에 대해 알아보았다.
이제 아래의 이미지를 보면 어떤 그림인지 이해가 갈 것이다.
부디 자바스크립트 개발을 할 때 오늘 배운 지식이 유용하게 쓰이길 바란다.