👀 JS 엔진과 구동 환경
자바스크립트를 실행하기 위해서는 자바스크립트 엔진이 필요하다.
Google에서 만든 V8 엔진이 가장 대표적인 예시이다.
자바스크립트 엔진은 Memory Heap과 Call Stack으로 이루어져 있다.
- Memory Heap : 메모리 할당이 일어나는 곳으로 변수와 함수 같은 객체 등이 담기는 곳이다.
- Call Stack : 함수를 호출들이 쌓이는 스택이다.
자바스크립트 엔진 자체는 싱글 스레드이다.
실제 자바스크립트가 구동되는 환경인 웹 브라우저는 멀티 스레드로 동작하기 때문에 비동기 처리가 가능한 것이다.
정확히는 Web API가 멀티 스레드로 동작하는 것이다.
따라서 자바스크립트의 런타임 환경은 위의 그림과 같다.
- Web APIs : 브라우저에서 제공하는 API들로, 비동기 처리를 담당한다.
- Callback Queue : 비동기 처리가 끝난 후에 실행되어야 할 콜백 함수가 차례대로 할당되는 곳이다.
콜백 함수가 큐안에 할당된다는 것이 핵심이다. - Event Loop : Callback Queue에 할당된 함수를 순서에 맞춰 Call Stack에 할당해준다.
👀 Callback Queue의 종류(이건 내가 헷갈려서 추가로 정리,,)
Callback Queue 안에는 Task Queue, Microtask Queue가 존재한다.
헷갈렸던 개념인데 Task Queue = Event Queue, Microtask Queue = Job Queue이다.
Microtask Queue에는 promise.then, process.nextTick와 같은 함수들이 들어간다.
Task Queue에는 setTimeout, setInterval, addEventListener와 같은 함수들이 들어간다.
브라우저의 큐에는 Callback Queue 뿐만 아니라 Animation Frames도 존재한다.
Animation Frames는 브라우저 애니메이션 작업에 대한 처리를 담당한다.
우선순위는 Microtask Queue > Animation Frames > Task Queue 순서이다.
Event Loop는 우선순위에 따라 Callback Queue에 있는 함수들을 Call Stack으로 할당한다.
👀 JS는 인터프리터 언어?
결론부터 말하자면 기본적으로는 인터프리터 언어이지만 필요한 경우 컴파일도 진행한다.
인터프리터 언어로 분류되는 이유는 콘솔에서 스크립트를 작성해서 실행하면 컴파일을 하지 않기 때문이다.
하지만 V8과 같이 JS 엔진에서는 컴파일이 필요한 경우 컴파일을 진행한다.
먼저, 컴파일러와 인터프리터의 차이에 대해 알아보자.
👀 컴파일러 vs 인터프리터
- 컴파일러
컴파일러는 프로그램 전체를 스캔하여 이를 모두 기계어로 번역한다.
초기 스캔 시간이 오래걸리지만, 전체 실행 시간만 따지면 인터프리터 보다 빠르다.
기계어 번역 과정에서 메모리를 많이 사용한다.
전체 코드를 검사한 뒤에 오류 메시지를 생성하기 때문에 프로그램 실행 전에 오류를 발견할 수 있다.
- 인터프리터
프로그램 실행 시 한 번에 한 문장씩 번역한다.
실행시간은 컴파일러보다 오래걸리지만 컴파일러와 같은 오브젝트 코드 생성과정이 없어서 메모리 효율이 더 좋다.
한 문장씩 해석할 때마다 오류를 만나게 되면 프로그램을 중지하기 때문에 프로그램을 실행해봐야지만 오류 발견이 가능하다.
오브젝트 코드?
컴파일의 결과물로 인터프리터는 소스 코드를 즉시 해석하고 실행하기 때문에
별도의 오브젝트 코드를 생성하지 않고, 모든 실행 과정이 메모리에서 직접 이루어짐
👀 JS 코드 -> 바이트 코드
소스 코드를 파싱하여 바이트 코드 형태로 변환한다.
- 자바스크립트 코드를 의미있는 단위인 토큰으로 나눈다.
- 토큰들을 자바스크립트 문법에 맞는 방식으로 AST(Abstract Syntax Tree)로 만든다.
( AST : 프로그램의 구조를 트리 형태로 표현한 것 ) - AST는 인터프리터를 거쳐 바이트 코드로 변환된다.
여기까지는 모두 동일하게 동작한다.
이제 변환된 바이트 코드를 실행하면 된다.
바이트 코드를 실행하는 방법은 아래를 봐주세용.
👀 인터프리터 vs Just-In-Time Compilation(JITC)
자바스크립트 엔진이 바이트 코드를 실행하는 방법으로는 인터프리터와 JITC 두가지 방법이 있다.
변환된 바이트 코드를
인터프리터 모드라면 바이트 코드를 한 줄 씩 읽어가며 동작을 수행하고,
JITC 모드라면 생성된 바이트 코드를 기반으로 native code로 컴파일하여 수행한다.
🤔 JITC가 뭔데?
JITC는 인터프리터와 정적 컴파일을 혼합한 방식으로, 프로그램 실행 시점에 바이트 코드를 native code로 번역하는 역할을 한다.
JITC 방법은 인터프리터 방식보다는 빠르고 정적 컴파일 방식보다는 느리지만
실행 시 컴파일하기 때문에 환경에 맞게 최적화 할 수 있다는 장점이 있다.
🤔 JITC 쓰면 돼?
인터프리터로 수행하는 방법보다 native code로 수행하는 것이 빠르다고 생각할 수 있지만 자바스크립트에서는 무조건 그런 것이 아니다.
자바스크립트는 동적 타입 언어로, 실행되고 나서야 타입이 결정된다.
또한 JITC는 프로그램 실행 중 컴파일이 수행되어서 오버헤드가 발생하고,
모든 코드를 native code로 컴파일을 해서 최적화가 필요하지 않은 코드에도 최적화를 한다.
자주 반복해서 수행되는 구간을 hotspot이라고 부르는데, 자바스크립트는 다른 언어들에 비해 hotspot이 적다.
따라서 자바스크립트에는 인터프리터 방식이 더 적합할 수도 있다.
하지만 요즘 웹에서는 이벤트 처리만 하지 않고 많은 일을 수행하기 때문에 JITC를 사용하지 않을 수 없다.
그래서 최근 자바스크립트 엔진들은 대부분 JITC를 개선한 Adaptive JIT Compilation 방식을 채택하였다.
👀 Adaptive JIT Compilation
Adaptive JIT Compilation은 모든 코드에 같은 수준의 최적화를 적용하는 것이 아닌,
반복 수행 정도(hotspot)에 따라 서로 다른 최적화 수준을 적용하는 방식이다.
또한 전체 코드를 native code로 변환하지 않고, 필요한 부분에 대해서만 컴파일을 하여 최적화를 한다.
- 기본적으로 모든 코드는 처음에 인터프리터로 수행한다.
- 그러다가 hotspot이 발견되면 JITC는 Baseline-JITC에게 컴파일을 요청하고
컴파일 된 정보는 JITC에 저장된다. - 처음에는 최소한의 JITC만 적용하다가 더 자주 반복되는 코드들에는
더 많은 최적화를 적용하는 Optimizing-JITC에게 컴파일을 요청한다.
마찬가지로 컴파일 된 정보는 JITC에 저장된다. - 이 때, Optimizing-JITC는 profiling을 수행하는 동안 변수의 타입이 변하지 않았다면
그 이후에도 그 변수의 타입은 변하지 않을 것이라고 가정을 하고 최적화를 진행한다. - 만약 변수의 타입이 바뀌게 되면 JIT는 최적화된 코드를 버리는데 이를 Deoptimization이라고 한다.
한 줄 요약 : 인터프리터가 생성한 바이트 코드에 최적화된 컴파일을 진행
👀 V8 엔진
V8 엔진은 구글이 개발한 자바스크립트 엔진으로, node.js 런타임 및 크롬 브라우저에서 사용된다.
우리가 사용하는 크롬 브라우저에서는 실제로 어떻게 동작하는지 알아보자.
❗️ 동작 전체 흐름
JS 코드를 파싱하고 AST를 만드는 과정까지는 동일하다.
생성된 AST를 Ignition에게 넘긴다.
Ignition이 AST를 바이트 코드로 변환하고 실행시키는데,
자주 사용되는 코드는 TurboFan으로 보내셔저 Optimized Machine Code(=최적화된 코드)로 컴파일된다.
👀 Ignition
Ignition은 자바스크립트 코드를 바이트 코드로 변환해주는 인터프리터이다.
모든 소스를 한 번에 컴파일하지 않고 한 줄 씩 해석하는 인터프리터 방식을 채택했다.
인터프리터 방식을 채택한 이유는 다음과 같다.
- 메모리 사용량 감소
- 파싱 시 오버헤드 감소
- 컴파일 파이프 라인의 복잡성 감소
function hello(name) {
return 'Hello,' + name;
}
console.log(hello('Evan'));
다음과 같은 간단한 코드를 바이트 코드로 변환해보자.
[generated bytecode for function: hello]
Parameter count 2
Frame size 8
15 E> 0x2ac4000d47b2 @ 0 : a0 StackCheck
30 S> 0x2ac4000d47b3 @ 1 : 12 00 LdaConstant [0]
0x2ac4000d47b5 @ 3 : 26 fb Star r0
0x2ac4000d47b7 @ 5 : 25 02 Ldar a0
46 E> 0x2ac4000d47b9 @ 7 : 32 fb 00 Add r0, [0]
53 S> 0x2ac4000d47bc @ 10 : a4 Return
...
Ignition의 bytecode compiler에 의해 이렇게 복잡한 바이트 코드로 변환된다.
스택을 확인하고 누산기에 값을 넣고 레지스터로 이동시키고 .. 등등 .. 복잡하다.
이렇게 CPU 내부의 하드웨어를 조작하는 명령문이기 때문에 컴퓨터 입장에서는 이해하기가 훨씬 편하다.
이렇게 변환된 바이트 코드를 Ignition의 인터프리터가 한 줄 씩 읽으면서 실행시킨다.
👀 TurboFan
TurboFan은 코드를 최적화 해주는 컴파일러의 역할을 한다.
V8의 Ignition은 바이트 코드를 실행하면서, profiler을 통해 함수나 호출의 빈도와 같은 데이터를 모은다.
이렇게 모은 데이터를 profiling data라 부르고,
Ignition은 profiling data와 바이트 코드를 TurboFan에 넘긴다.
TurboFan은 전달 받은 데이터로 최적화를 진행한다.
만약, 바이트 코드의 반복 실행이 많아 Ignition이 뜨거워지면
TurboFan은 최적화된 코드를 생성 및 실행 시켜서 Ignition의 쿨링팬 역할을 한다.
🤔 어떻게 최적화하는데?
TurboFan의 최적화 기법으로는 Hidden Class와 Inline Caching이 있다.
Hidden Class는 비슷한 것끼리 분류해놓고 가져다 쓰는 방법이고,
Inline Caching은 반복적인 코드를 기계어로 최적화시키고 캐싱해두었다가,
다음 코드 실행부터는 빠르게 실행될 수 있게 하는 방법이다.
👀 결론
- Hotspot이 없는 자바스크립트 프로그램에는 인터프리터
- 계산을 많이 해야하는 compute-intensive 자바스크립트 프로그램에는 JITC
- 두 가지 모두를 만족해야 하는 최근 엔진들은 Adaptive JITC 채용
'JavaScript' 카테고리의 다른 글
JS closure (0) | 2024.08.07 |
---|---|
JS This (0) | 2024.08.07 |
[JS]Reduce 함수 (4) | 2024.04.12 |
[JS] 유효성 검사 (0) | 2023.12.30 |
[JS]JavaScript 화살표 함수 (0) | 2023.05.29 |