Nodejs, Browser: Javascript Runtime, Javascript Engine ‘이해하기’

bbangjo
17 min readDec 31, 2021

--

Node.js는 Chrome V8 JavaScript 엔진으로 빌드된 JavaScript 런타임입니다.

Node.js를 사용하시는 분들은 이 글귀를 어디선가 보신 적이 있을 겁니다. 바로 Node.js 홈페이지에 가시면 메인에서 확인하실 수 있는 글입니다.

흠….🤔 제가 처음 공부할 당시에는 저 문장에서 이해할 수 있는 용어가 Chrome, Javascript, 빌드밖에 없었습니다. 이제와서야 어찌저찌 말로는 설명할 수 있을 정도로 이해하고 지내고 있지만, 최근 들어 MDN docs를 볼 일이 많아지면서 의문이 들더군요.

❓ 어떻게 Node.js와 Chrome 브라우저가 내가 작성한 JS 코드를 ‘실행’하는 걸까?

이 질문에 답을 하기 위해 글을 쓰게 되었습니다. 틀린 내용은 언제든지 댓글 남겨주시면 감사하겠습니다!

Javascript Runtime

Node.js와 브라우저가 Javascript(js)를 실행시킬 수 있는 환경부터 생각해봅시다. Node.js는 홈페이지에서 말하듯이 Javascript 런타임이기 때문이고, 브라우저는 Javascript 런타임 환경을 가지고 있기 때문입니다. Javascript 런타임은 js 코드를 실행하기 위해 필요한 모든 것을 가지고 있는 큰 컨테이너, 프로그램, 플랫폼 등 입니다. 말 그대로 run-time에 필요한 프로그램을 말하는 거죠. js 코드를 실행시키는데 필요한 것은 어떤 것이 있을까요?

  • JS engine: 여기서는 V8
  • API: 브라우저의 경우 Web API, Node.js의 경우 Node API
  • 이벤트 루프

이벤트 루프는 싱글 쓰레드 언어인 javascript에서 비동기를 사용해 ‘병렬성을 보장되는 것처럼 보여지게’ 하는 핵심 요소입니다. 이 글에서 설명하고자 하는 바는 아니니 넘어가겠습니다.

🚀 이 영상에서 잘 설명하고 있는데, Javscript 런타임이 뭔지 개괄적인 이해도 하실 수 있을 것 같습니다.

다시 넘어와서, Javascript 런타임의 핵심 요소인 JS engine입니다. Node.js 홈페이지에서도 Node.js 설명의 절반 정도를 Chrome V8 JavaScript 엔진이라고 소개할 정도네요. 그래서 이 글은 거의 Javascript 엔진이 대체 뭔가!!를 알아보는 글이라고 생각하시면 될 것 같습니다.

아래 그림을 보시면 Javascript 런타임이 이렇게 구성되어 있구나~ 이해하실 수 있습니다. Web API를 사용하고 있으니 브라우저가 가지고 있는 Javascript 런타임을 나타냅니다.

저희가 다루지 않은 Callback Queue의 개념은 이벤트 루프를 설명하는 영상에서 함께 알아보실 수 있습니다.

Javascript Engine

아아아주 간단한 js 코드를 생각해봅시다.

const myName = "bbangjo";

💻컴퓨터: (어쩌라고..?)🥱😐

네,, 그렇다고 합니다. 결국 js 코드를 실행하려면 사람이 읽을 수 있는 코드에서 컴퓨터가 이해할 수 있는 형태, machine code로 바꾸는 과정이 필요합니다. machine code로 어떻게든 바꾸고 나면 그때서야 실행을 할 수 있는거죠. 그리고 이 일을 해주는 게 Javascript Engine입니다.

자바스크립트 엔진(JavaScript engine)은 자바스크립트 코드를 실행하는 프로그램 또는 인터프리터이다. 자바스크립트 엔진은 전통적인 인터프리터일 수도 있고, 특정한 방식으로 바이트코드JIT 컴파일을 할 수 있다.

출처: 위키피디아

JS Engine이 대체 어떻게 동작하는지 알아봅시다. 이 글에서는 V8을 기준으로 설명하겠습니다.

V8 아키텍쳐는 다음 그림과 같습니다:

각 단계별로 좀 더 자세히 알아봅시다.

Parsing

이 단계에서 소스코드는 AST(Abstract Syntax Tree) 형태로 변하게 됩니다.

컴퓨터 과학에서 추상 구문 트리(abstract syntax tree, AST), 또는 간단히 구문 트리(syntax tree)는 프로그래밍 언어로 작성된 소스 코드의 추상 구문 구조의 트리이다. 이 트리의 각 노드는 소스 코드에서 발생되는 구조를 나타낸다.

출처: 위키피디아

AST에 대한 개념은 따로 설명드리지 않겠습니다. 코런갑다~ 하고 넘어가셔도 전체적인 흐름을 이해하는데는 무리가 없을 겁니다.

그림으로 보면 좀 더 이해가 쉽겠죠:

가장 왼쪽의 JavaScript Source가 Parse 과정을 거치면서 중간의 AST 형태로 변하게 됩니다.

🚀 https://astexplorer.net/ : 이 사이트에서 내가 작성한 소스코드가 어떻게 AST로 표현되는지 볼 수 있습니다.

소스코드:

const myName = "bbangjo";

AST:

참고: Scanner

사실 소스코드가 바로 Parser에게 전달되어 AST로 변하게 되는 건 아니고, 중간에 Scanner라는 친구가 먼저 소스코드를 Token 단위로 분해하게 됩니다. Token은 ‘의미있는 문자 혹은 문자열’을 의미합니다. 문자열 그 자체, 연산자, 식별자 등.

const myName = "bbangjo";
// Tokens: ['const', 'myName', '=', '"bbangjo"', ';']

가령 이런 식인거죠. 위의 과정은 v8의 특징이 아니고 일반적인 컴파일러 개념의 일부입니다.

Compilation(컴파일) & Execution(실행)

Parser가 만든 AST를 어떻게 실행할 수 있을까요?

이를 설명할 런타임의 코어인 JS Engine, 그 중의 코어인 Compilation(컴파일) 단계에 왔습니다. 컴파일과 실행은 맞물려 돌아가는 개념이어서 묶어 설명하겠습니다.

JS Engine에서는 JIT(Just In Time) Compilation 방법을 사용합니다.

❓ 엥? javascript는 interpreter 언어 아닌가요? compile은 갑자기 왜?..

이를 이해하기 위해 Interpreter, Compiler의 개념을 간략하게 알아보고 갑시다.

Interpreter: 파일의 내용을 한 줄씩 읽어가며 바로 실행합니다. 초기에 js는 이런 방식으로 동작했습니다. 인터프리터 언어의 예로 유명한 Python이 있습니다. 그래서 파이썬의 경우 문법 오류가 있더라도, 문법 오류가 발생하기 전까지는 실행 결과를 확인할 수 있죠.

Compiler: Compiler가 소스코드를 Object file(object 별 machine code, 컴퓨터가 이해할 수 있는 형태)로 변환해줍니다. 그리고 이 Object file들을 Linker라는 녀석이 하나로 묶어 하나의 거대한 executable file(.exe)을 만들게 됩니다. 후에 이 파일을 실행할 때 Loader가 실행해야 할 machine code를 가상 메모리에 load하고 instruction pointer(실행이 이루어지는 주소를 담고 있음)를 적절한 시작점으로 세팅해줌으로써 가능합니다.

  • 이런 컴파일 방식을 AoT(Ahead of Time) 컴파일이라고 합니다. “컴파일은 이미 해 놨어, 그냥 실행만 해.”라는 의미입니다.

각 방식에는 장단점이 있습니다.

Interpreter

  • 👍장점: 코드를 컴파일 없이 바로 실행하기 때문에 실행을 시작하는 데에 드는 시간(전처리 시간)이 없다.
  • 👎단점: 최적화가 부족해서 사이즈가 큰 애플리케이션에서 사용하기에는 부적합하다.

Compiler

  • 👍장점: 실행하기 전에 compile 과정을 거치기 때문에 최적화된 코드를 만들 수 있다.
  • 👎단점: 컴파일하는 시간이 매우 길 수 있다. (참고: Go lang이 나온 배경 중 하나이기도 함.)

그래서 모던 브라우저에서는 Interpreter & Compiler를 모두 사용하는 방식을 채택했습니다.

위에서 봤던 V8 아키텍쳐를 다시 보죠.

V8에서는 Ignition 이라는 인터프리터와 TurboFan 이라는 컴파일러를 사용합니다. 빨간 박스에서 이뤄지는 일을 살펴보면

  1. 인터프리터는 AST를 통해 Bytecode를 생성하고 실행한다.
  2. 실행이 이루어지는 동안, Runtime Profiler가 함수의 동작을 분석한다.(호출 횟수, 함수의 바이트 코드 길이, ..)
  3. 컴파일러는 분석 결과(메타 데이터)를 바탕으로 최적화된 머신 코드를 생성한다. 그리고 이 코드를 다음 함수 호출 시 사용한다.

와 같습니다. 메인 쓰레드에서 인터프리터가 코드를 실행하는 동안 다른 쓰레드에서는 코드를 분석하고, 필요에 따라 최적화를 수행하고 있는 겁니다. 이 때문에 “js코드가 컴파일 언어에 가까운 성능을 보여줄 수 있다”고 하는 것 같습니다.

1. 인터프리터는 AST를 통해 Bytecode를 생성하고 실행한다.

nodejs--print-bytecode라는 옵션을 제공합니다. 이를 통해 함수가 어떤 bytecode로 변환되는 지 알 수 있습니다.

test.js:

function test(name) {
return 'Hello ' + name;
}
console.log(test('World'));
$ node --print-bytecode --print-bytecode-filter=test test1.js
[generated bytecode for function: test]
Parameter count 2
Frame size 8
13 E> 0x3134efd3049a @ 0 : a0 StackCheck
27 S> 0x3134efd3049b @ 1 : 12 00 LdaConstant [0]
0x3134efd3049d @ 3 : 26 fb Star r0
0x3134efd3049f @ 5 : 25 02 Ldar a0
43 E> 0x3134efd304a1 @ 7 : 32 fb 00 Add r0, [0]
50 S> 0x3134efd304a4 @ 10 : a4 Return
Constant pool (size = 1)
Handler Table (size = 0)
Hello World

Parameter count : this + name = 2

  1. StackCheck: 스택 오버플로우 발생하는지 체크
  2. LdaConstant [0]: 상수값('Hello ') Accumulator에 load
  3. Star r0: Accumulator에 있는 값 r0으로 이동
  4. Ldar a0: a0에 있는 값을 Accumulator에 load
  5. Add r0, [0]: r0에 있는 값과 [0] 덧셈(문자열 concat)
  6. Return: Accumulator의 값 return.

어셈블리 언어를 공부해보신 분은 쉽게 이해하실 수 있겠지만, 아닌 분들은 “소스코드를 컴퓨터가 보기 편한 형태로 바꿔놓는구나” 정도로 이해하셔도 충분할 것 같습니다. 이제, CPU는 순서대로 instruction을 수행할 수 있습니다.

2. 실행이 이루어지는 동안, Runtime Profiler가 함수의 동작을 분석한다.(호출 횟수, 함수의 바이트 코드 길이, ..)

위에서 다른 쓰레드에서는 코드를 분석하고, 필요에 따라 최적화를 수행한다고 했습니다. 그럼 언제 최적화를 수행할 지 결정을 해야 하는데, 잠시 v8 github에서 코드를 살펴봅시다.

v8/src/execution/runtime-profiler.cc:

  • kHotAndStable: Hot하고 Stable 하다. 즉 자주 호출되고, 코드가 변하지 않음을 의미합니다. 반복문의 경우가 이에 해당할 것 같습니다.
  • kSmallFunction: 바이트코드의 길이가 작으면 최적화를 진행합니다. 길이가 작은 함수는 최적화하기 쉽고, 다른 코드에서 부품처럼 사용될 가능성이 크기 때문인 것 같습니다.

3. 컴파일러는 분석 결과(메타 데이터)를 바탕으로 최적화된 머신 코드를 생성한다. 그리고 이 코드를 다음 함수 호출 시 사용한다.

자 그럼, 2.에서 확인한 조건을 가지고 실제 최적화를 진행하는 과정을 살펴봅시다. 1.에서와 마찬가지로 nodejs--trace-opt 옵션을 이용해 최적화 과정을 추적할 수 있습니다.

test.js:

출력 결과를 보면

$ node --trace-opt test1.js
[*] Loop 0
[*] Loop 1000
[*] Loop 2000
[*] Loop 3000
[*] Loop 4000
[*] Loop 5000
[*] Loop 6000
[marking 0x053a8c5856d9 <JSFunction (sfi = 0x2dd76222ff69)> for optimized recompilation, reason: small function, ICs with typeinfo: 10/10 (100%), generic ICs: 0/10 (0%)]
[*] Loop 7000
[*] Loop 8000
[*] Loop 9000
[compiling method 0x053a8c5856d9 <JSFunction (sfi = 0x2dd76222ff69)> using TurboFan OSR]
[optimizing 0x053a8c5856d9 <JSFunction (sfi = 0x2dd76222ff69)> - took 0.778, 0.775, 0.038 ms]

반복이 계속됨에 따라 small function을 최적화해야겠다고 marking합니다. 그리고 후에 실제 마킹해놓은 함수를 compiling하고 있습니다. 그리고 이 과정은 TurboFan 즉 컴파일러에 의해 수행되고 있다고 말해주네요. 그럼 함수 byte code 길이를 늘려 Hot and Stable function도 확인해봅시다.

test.js:

인자 타입 체크하고, 무지성 연산하고 배열로 바꾼 뒤 다시 문자열로 바꿔주는 함수입니다.

❓ 네? 이게 뭐하는 함수냐구요?

저도 몰라요 ㅋㅋㅋㅋ 🤣그냥 길이 늘리려고 아무거나 써봤습니다. 어쨋든 출력 결과를 확인해봅시다. 필요한 부분만 발췌하겠습니다.

$ node --trace-opt test1.js
...
[marking 0x1e54b1fd5e09 <JSFunction addMulDivSubArr (sfi = 0x650626b0349)> for optimized recompilation, reason: hot and stable, ICs with typeinfo: 9/10 (90%), generic ICs: 0/10 (0%)]
[compiling method 0x1e54b1fd5e09 <JSFunction addMulDivSubArr (sfi = 0x650626b0349)> using TurboFan][*] Loop 6000[compiling method 0x1e54b1fd5ec1 <JSFunction (sfi = 0x650626b0251)> using TurboFan OSR][optimizing 0x1e54b1fd5ec1 <JSFunction (sfi = 0x650626b0251)> - took 0.375, 1.317, 0.057 ms][optimizing 0x1e54b1fd5e09 <JSFunction addMulDivSubArr (sfi = 0x650626b0349)> - took 0.206, 0.718, 0.023 ms][completed optimizing 0x1e54b1fd5e09 <JSFunction addMulDivSubArr (sfi = 0x650626b0349)>]
...

내부적으로 쓰인 함수들이 많기 때문에 다른 함수들도 위와 비슷하게 최적화되는 모습을 확인할 수 있습니다. 여기서는 addMulDivSubArr 함수만 보겠습니다. 보면 hot and stable 이라는 이유 때문에 함수를 recompile하여 최적화하고 있습니다.

최적화를 어떻게 하냐?에 대한 답은 Hidden Class, Inline Caching 등의 방법을 사용하는데 이건 저도 아직 잘 몰라서 다음에 쓰든 하겠습니다 ㅠ..😅

So What?

자 이제 저희는 “JS Engine에서는 JIT(Just In Time) Compilation 방법을 사용합니다.” 라는 말의 의미를 알 수 있습니다. 컴파일러가 run-time 동안 컴파일을 진행하기 때문입니다. 코드 최적화를 위해 말이죠. 동작 과정을 잘 나타낸 그림이 있어 가져와봤습니다.

위 그림에서 Full-codegen은 현재 Ignition으로 대체되었고, Crankshaft는 사라지고 TurboFan만 사용하고 있다고 합니다.

👀 Then, finished?

지금까지 JS runtime의 가장 중요한 부분인 JS Engine을 v8 기준으로 설명했습니다. 하지만 runtime 즉 js 코드를 실행시키기 위해 필요한 요소가 JS Engine 밖에 없었나요? 아닙니다. API, 이벤트 루프, 콜백 큐, ... 등이 남아있습니다. (please stop...😥😥) 넵,,, 이 관련 내용은 너어어어무 잘 설명하고 있는 유튜브 영상이 있어 링크 남기겠습니다.

🚀 https://www.youtube.com/watch?v=8aGhZQkoFbQ

❗ Conclusion

저희는 지금까지 브라우저와 nodejs에서 어떻게 js를 실행하는지에 대한 답을 찾기 위해 여기까지 왔습니다. 정리하자면 js를 실행시키기 위해서는 js를 실행하는데 필요한, run-time(실행하는 동안)에 실행되어야 하는 프로그램 즉 JS Runtime enviornment가 필요했습니다. 구성요소로는 JS Engine, API, 이벤트루프, 콜백큐 등이 있지만 이 글에서는 JS Engine(V8)을 중점적으로 다뤘고, 나머지 부분은 킹갓 유튜브 링크를 달아드렸습니다. (밥 먹으면서 보면 밥도둑 ㅋㅋ).

JS Engine은 소스코드를 AST로 변환하고 Interpreter는 메인 쓰레드에서 코드 실행, 컴파일러는 다른 쓰레드에서 코드 최적화에 힘쓰고 있다는 것을 배웠습니다. 그래서 JS Engine은 JIT Compilation을 사용한다고도 했었구요. 예제는 모두 nodejs로 보여드렸지만 브라우저의 경우도 별반 다를 거 없습니다. HTML Parser가 <script> 태그를 인식할 거고, 태그 안에 있는 js 혹은 src 속성에 있는 링크에 요청을 보내 받아온 js 코드를 파싱하여 나머지 동작은 똑같이 진행합니다.

저와 같은 궁금증이 있으셨던 분이 계셨길 바라면서 그리고 궁금증이 해결되었길 바라면서 글 마무리하겠습니다. 틀린 점, 오타, 궁금한 점 모두 알려주시면 감사하겠습니다!

긴 글 읽어주셔서 감사합니다! 🎉🎉

Reference

🚀 https://v8.dev/blog/background-compilation

🚀 https://www.youtube.com/watch?v=2WJL19wDH68

🚀 https://codeburst.io/node-js-v8-internals-an-illustrative-primer-83766e983bf6

🚀 https://v8.dev/blog/scanner

🚀 https://www.knowledgehut.com/blog/web-development/nodejs-and-v8

🚀 https://medium.com/@gemma.stiles/understanding-the-javascript-runtime-environment-4dd8f52f6fca

🚀 https://evan-moon.github.io/2019/06/28/v8-analysis/

🚀 https://www.youtube.com/watch?v=8aGhZQkoFbQ

--

--