일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | 5 | 6 | 7 |
8 | 9 | 10 | 11 | 12 | 13 | 14 |
15 | 16 | 17 | 18 | 19 | 20 | 21 |
22 | 23 | 24 | 25 | 26 | 27 | 28 |
29 | 30 | 31 |
- CS
- 명시적 타입 변환
- Microtask Queue
- Dockerfile
- AJIT
- 주니어개발자
- linux 배포판
- prettier
- useLayoutEffect
- Headless 컴포넌트
- react
- React.memo
- JavaScript
- Custom Hook
- useCallback
- type assertion
- task queue
- useEffect
- useMemo
- Event Loop
- 암묵적 타입 변환
- Render Queue
- 타입 단언
- 좋은 PR
- docker
- 프로세스
- prettier-plugin-tailwindcss
- TypeScript
- Compound Component
- Sparkplug
- Today
- Total
구리
[브라우저] 자바스크립트 엔진은 내 코드를 어떻게 실행할까? 본문
이번 글은 JavaScript 엔진(구글 V8 엔진 기준)이 코드를 어떻게 실행하는지에 대해 알아보는 글입니다.
자바스크립트 엔진의 동작 원리를 크게 2단계로 나눠 (2) 소스 코드가 컴파일되기까지의 과정의 주제를 현재 글에서 다루며 (2) 컴파일 후 실행되는 과정은 다음 글에서 다룰 예정입니다.
사전 지식
본문
브라우저 동작원리
브라우저는 서버로부터 전달 받은 HTML 파일을 파싱하다 script 태그를 만나면 네트워크 레이어를 통해 JavaScript 파일을 로드 후 JavaScript 엔진에게 제어권을 넘겨 파싱과 실행 단계를 진행하게 됩니다.
(브라우저의 동작원리에 대해 자세히 알고 싶다면 정리글을 참고하시면 좋을 것 같습니다)
이때 파싱과 실행은 어떤 과정을 통해 진행될까요?
JavaScript 동작 원리
JavaScript는 기본적으로 인터프리터 언어이기에 아래의 과정을 통해 파싱이 진행됩니다.
- 토크나이징
- 토크나이저가 어휘 분석을 통해 문법적 의미를 갖는 코드의 최소 단위인 토큰들로 분해
- 렉싱이라고도 하며 해당 과정에서 스코프가 결정됨 (렉시컬 스코프라 부루는 이유)
- 파싱
- 파서가 토큰들의 집합을 구문분석하여 AST(Abstract Syntax Tree)를 생성
- AST는 토큰에 문법적인 의미와 구조를 반영한 트리구조의 자료구조
- bytecode의 생성과 실행
- 파싱의 결과물인 AST를 bytecode로 변환 후 인터프린터에 의해 실행
위 과정을 거치게 되면 장점으로는 컴파일 과정이 없고 소스 코드를 즉시 실행하지만, 한줄씩 해석하며 실행되기에 컴파일된 언어에 비해 속도가 느린 단점이 존재합니다.
따라서 V8 엔진에서는 인터프리터와 컴파일러를 같이 사용해 인터프리터 언어의 장점인 동적 기능 지원을 살리면서 실행 속도가 느리다는 단점을 극복합니다.
참고로 JavaScript는 런타임에 컴파일되며 실행 파일이 생성되지 않고 인터프리터의 도움 없이 실행할 수 없기에 컴파일러 언어라고는 볼 수 없습니다.
JITC (Just-In-Time Compiler)
JIT 컴파일러는 컴파일러와 인터프리터의 장점을 합치고자 만들어진 개념으로 현재 많이 사용되고 있는 JavaScript 엔진은 모두 JITC 방식을 사용합니다.
JITC는 프로그램 실행 중(런타임) 그 순간에 코드를 컴파일하는 코드 실행 방법을 뜻합니다.
자바스크립트 JITC는 아래 그림과 같은 방식으로 수행됩니다.
1. JavaScript → IR
자바스크립트는 text 형태로 배포되기에 이를 수행하기 위한 변환이 필요합니다. 따라서 소스 코드를 파싱해 중간 언어(IR, Intermediate representation)인 bytecode 형태로 먼저 변환합니다.
2. IR → Native Code
변환된 IR을 인터프리터 모드라면 bytecode를 하나씩 읽어가며 동작을 수행하고, JIT 모드라면 생성된 bytecode를 기반으로 네이티브 코드로 컴파일해 수행합니다.
네이티브 코드(native code)
C,C++처럼 인터프리터 없이 운영체제가 읽을 수 있는 형태로 컴파일해 사용 가능한 코드를 의미
당연히 인터프리터로 수행하는 것보다 네이티브 코드로 수행하는 것이 빠르기에 JavaScript에서도 JITC만을 사용하면 되지 않을까요?
JavaScript에서도 JITC만이 과연 정답일까?
(1) 동적 타입의 언어
JavaScript는 매우 동적인 언어이기에 JITC는 모든 예외 케이스를 고려해 네이티브 코드를 생성해야 합니다.
function carculate(a,b){
return a + b;
};
carculate(a,b);
// 간단한 덧셈 함수이다. 하지만 JavaScript에서는 변수 `a` 와`b`에 다양한 타입의 데이터가 들어갈 수 있다.
만약 a,b가 둘다 int형이라면 그냥 더하면 되지만, 둘 중 하나라도 int형이 아니거나 integer 범위에 벗어나면 예외 케이스가 발생하고 slow case
로 건너뛰게 됩니다.
slow case
native code로 생성하기 어려운(native code로 표현하면 양이 많아지는) 동작들을 native code로 뽑아내는 대신 미리 엔진 내부에 C로 구현된 helper function을 호출해 동작을 수행하는 경우
그러면 helper function을 호출하게 되는데 이런 경우 인터프리터 모드로 수행할 때와 똑같은 코드를 사용하게 되며 native code로 수행한다 해도, 많은 부분은 인터프리터와 차이가 없어지며 심지어 컴파일 오버헤드가 더해져 JavaScript JITC는 비효율적입니다.
(2) JavaScript의 변화
초기에는 JavaScript가 웹페이지의 레이아웃을 건드리거나 사용자 입력에 반응하는 방식의 프로그램이 많았기에 hotspot
이 적었습니다.
hotspot
자주 반복돼서 수행되는 구간(hotspot)이 얼마나 많은지 나타내는 척도 → 최적화 필요성이 높은 부분
예전에는 hotspot이 별로 없는 JavaScript였기에 인터프리터로 수행하는 것이 나았습니다.
하지만 최근에는 JavaScript가 비즈니스 로직에도 어느정도 관여를 할만큼 많은 일들을 수행하기에 JITC 방식도 완전히 버릴 수는 없었습니다.
그렇다면 고전적인 방식의 JavaScript 코드와 복잡한 코드들의 수행 성능을 모두 만족 시키는 방법은 무엇일까요?
Adaptive JIT Compilation
최근의 JS 엔진들은 JIT의 업그레이드 버전인 Adaptive JITC
를 사용합니다. AJITC는 모든 코드를 일괄적으로 같은 최적화를 적용하지 않고, 반복 수행 정도에 따라 유동적으로(adaptive) 서로 다른 최적화 수준을 적용하는 방식입니다.
기본적으로 모든 코드는 처음에 인터프리터로 수행합니다. 그러다 자주 반복되는 부분(hotspot)이 발견되면, 그 부분만 JITC를 적용해 native code로 수행합니다.
V8 엔진 작동 원리
V8 엔진은 Ignition
이라는 인터프리터, TurboFan
이라는 AJIT 컴파일러를 사용합니다.
- 파서(
Parser
)는 소스코드를 분석 후 AST로 변환 - 인터프리터(
Ignition
)는 AST를 bytecode로 변환- 모든 소스 코드를 컴파일하지 않고 코드 한줄씩 실행할 때마다 해석하는 인터프리트 방식 채택시 이점
- 메모리 사용량 감소
- JavaScript 코드에서는 기계어로 컴파일하는 것보다 bytecode로 컴파일 하는 것이 더 편함
- 전체 프로그램을 컴파일하는 컴파일러와 달리 인터프리터는 필요한 라인만 컴파일하기에 메모리 사용량 감소
- 파싱시 오버헤드 감소
- bytecode는 간결하기에 다시 파싱하기도 편함
- 컴파일 파이프 라인의 복잡성 감소
- 최적화(Optimizing)든 최적화 해제(Deoptimizing)든 bytecode만 고려하면 되기에 편함
- 메모리 사용량 감소
- 모든 소스 코드를 컴파일하지 않고 코드 한줄씩 실행할 때마다 해석하는 인터프리트 방식 채택시 이점
- 인터프리터(
Ignition
)가 코드를 실시간으로 변환하면서 브라우저에게 작업을 지시하는 동안프로파일러
가 최적화 할 수 있는 부분을 찾아서 기록- 최적화가 가능한 부분(자주 사용되는 부분)을 찾으면 프로파일러는 이를 컴파일러(
TurboFan
)에게 전달 - 함수나 변수들의 호출 빈도와 같은 매트릭을 수집
- 최적화가 가능한 부분(자주 사용되는 부분)을 찾으면 프로파일러는 이를 컴파일러(
- 컴파일러(TurboFan)는 프로파일러에게 전달 받은 내용을 토대로 기계어로 변환해 최적화 진행
- 인터프리터에 의해 실시간으로 웹사이트가 구동되는 동안 필요한 부분을 기계어로 변환해 최적화(Optimizing)진행
- 최적화한 코드를 수행할 차례가 오면, bytecode 대신 컴파일러가 변환한 최적화된 코드가 그 자리를 대체해 실행
- 사용 빈도가 낮은 코드는 최적화 해제(
Deoptimizing
-> Bytecode로 변환) 진행 - 최적화 기법에는 히든 클래스, 인라인 캐싱이 존재
- 히든 클래스 : 비슷하게 생긴 객체들을 그룹화
- 인라인 캐싱 : 자주 사용되는 코드를 캐싱
- 최적화한 코드를 수행할 차례가 오면, bytecode 대신 컴파일러가 변환한 최적화된 코드가 그 자리를 대체해 실행
그러면 V8 엔진은 어떤 조건으로 최적화를 진행할까요?
V8엔진 최적화 조건
- 코드가 뜨겁고 안정적인 것
- 자주 호출되며(뜨거운) 변하지 않는(안정적) 코드를 의미하며 대표적으로 매번 같은 행동을 수행하면 반복문 내에 있는 코드
- 작은 함수
- 인터프리팅된 바이트 코드의 길이를 기준으로 특정 임계점을 넘지 않으면 작은 함수라고 판단해 최적화 진행
- 작고 단순한 함수는 크고 복잡한 함수보다 동작이 추상적이거나 제한적인 확률이 높아 안정적
비최적화 컴파일러
V8 버전 9.1, 크롬 버전 91부터 기존의 Ignition과 Turbofan의 중간 단계에 위치한 Sparkplug
라는 비최적화 컴파일러가 도입되었습니다. 도입 이유는 다음과 같습니다.
기존의 Ignition와 Turbofan의 사이에는 큰 성능 차이가 존재했습니다.
즉, 코드가 Ignition 인터프리터에 너무 오래 머무르면 최적화로 인한 성능 향상의 효과를 누릴 수 없고, 반대로 적절하지 않은 시점에 TurboFan을 통해 너무 빨리 최적화를 해버리면 실제로 아직 hot하지 않은(자주 사용되지 않은) 함수들을 최적화하는 문제가 발생할 수 있고 심지어 미리 최적화한 코드를 이전으로 되돌리는(Deoptimizing) 상황이 발생할 수 있습니다.
따라서 우리는 이러한 간극을 줄이고자 빠르고 심플한 비최적화 컴파일러인 Sparkplug를 도입하였습니다.
Sparkplug
는 AST가 아닌 Ignition이 생성한 바이트 코드를 기반으로 기계어 코드로 만들기에 AST 분석 등의 작업을 수행할 필요가 없고, TurboFan과는 다르게 별다른 최적화 작업을 수행하지 않기에 빠르게 동작할 수 있다고 합니다.
V8 개발팀에 따르면 Sparkplug를 도입함으로써 약 5~15% 가량의 성능 향상과 CPU 점유율 감소를 통한 모바일 기기 등의 배터리 소모 감소 효과가 있다고 합니다.
결론
- Hotspot이 별로 없는 고전적인 JavaScript 프로그램들에는
interpreter
가 JITC보다 효율이 좋을수 있습니다. - 최근 많이 사용되는 compute-intensive한 JavaScript 프로그램들에는
JITC
가 좋습니다. - 두 가지 성향의 코드에 대한 성능을 모두 만족하기 위해 최근 엔진들은
adaptive JITC
를 채용합니다. - Adaptive JITC는 type profiling을 수행하므로, 변수의 type이 변하지 않는다면 높은 성능을 얻을 수 있습니다.
V8 엔진 작동 원리 정리
- 파싱 : 파서(
Parser
)는 소스코드 분석 후 AST로 변환 - bytecode 변환 및 실행 :
Ignition
(인터프리터)를 통해 bytecode로 변환 후 실행 - 최적화 :
Profiler
를 통해 hot spot을 전달 받은TurboFan
(AJIT 컴파일러)은 최적화된 코드로 컴파일 혹은 사용 빈도수가 낮으면 최적화 해제
정원기님 NHN엔터테인먼트 / TOAST앱개발팀
위 결론을 통해 성능이 좋은 JavaScript 코드를 만들고 싶다면, JavaScript 코드도 C나 Java처럼 static typing 언어라 생각하고
특히 하나의 Array에는 하나의 type만 넣어주는 것이 좋을 것 같습니다.
참고 자료
https://ko.wikipedia.org/wiki/%EC%B6%94%EC%83%81_%EA%B5%AC%EB%AC%B8_%ED%8A%B8%EB%A6%AC
https://v8.dev/blog/background-compilation
https://blog.bitsrc.io/secret-behind-javascript-performance-v8-hidden-classes-ba4d0ebfb89d
https://dev.to/lydiahallie/javascript-visualized-the-javascript-engine-4cdf
https://meetup.nhncloud.com/posts/77
https://medium.com/@yanguly/sparkplug-v8-baseline-javascript-compiler-758a7bc96e84
'Web' 카테고리의 다른 글
[브라우저] 자바스크립트 엔진은 어떤 기준으로 코드를 순서대로 실행할까? (0) | 2023.10.03 |
---|---|
[SEO] JSON-LD, 구조화된 데이터 (0) | 2023.08.31 |
[Browser] 웹성능 최적화 (0) | 2023.06.30 |
[Browser] 브라우저 렌더링 과정 (0) | 2023.06.23 |