[Javascript] 자바스크립트 엔진 (Javascript Engine - V8)

자바스크립트란?

JS는 경량, 인터프리터 혹은 JIT 컴파일 프로그래밍 언어로, 일급 함수를 지원한다. 프로토타입 기반, 다중 패러다임, 단일 스레드, 동적 언어이다. 객체지향형, 명령형, 함수형 프로그래밍 스타일을 지원한다.

JS를 공부하려는데 위 정의를 봐도 이해가 하나도 안된다. 그래서 이해를 해보고자 이 글을 작성했다. 위 단어들을 하나씩 꼬리에 꼬리를 물며 이해 될 때까지 보자.

자바스크립트 엔진, 그중에서도 V8 엔진을 알아보며 내가 작성한 .js 코드가 크롬 브라우저 혹은 node.js에서 어떻게 동작하나 파헤쳐보자.

V8 엔진이란?

2009년, C++을 이용해 개발된 고성능 자바스크립트 전용 웹 어셈블리 엔진이다. 자바스크립트 코드를 받아 컴파일을 실행하는 C++ 프로그램이다.

V8엔진은 구글에서 개발한 JS를 실행하는 엔진이고, 정말 많은 요소들로 구성되어 있다. V8 이 하는 중요한 일 몇 가지를 뽑았다.

  • 컴파일 - Ignition & TurboFan (인터프리터 & 컴파일러)
  • 힙 관리 - 동적 할당, 가비지 컬렉션
  • 콜 스택 핸들링, 실행 컨텍스트

하나씩 알아보자.

JIT(Just-in-Time) Compiler - V8

JIT 컴파일러란, Just-in-time의 줄임말로 엔진내부에서 컴파일을 진행한다. 인터프리터가 바이트 코드로 변환하고, 프로파일러를 통해 최적화 할 수 있는 코드를 컴파일 하여 최적화 한다.

기존 자바스크립트 실행 엔진은 코드 한 줄을 읽고 바로 실행하는 인터프리터 형식이었다.

왜냐? 자바스크립트의 탄생 배경으로는 동적 HTML파일을 실행하는게 제 1 목표였기 때문에 최대한 가볍게 구동하는 인터프리터 방식을 사용한 것이다. 이 방식은 메모리 관점에서 특별하게 바이트코드나 머신코드를 저장하지 않기 때문에 가벼울 수 있으나, 고성능 프로그램을 돌리기에는 느려서 부족했다.

구글은 구글 맵스를 구동시키기 위해 고성능 자바스크립트 실행기가 필요했고 그에 따라 V8 엔진을 개발하게 된다. 자세히 알아보기 전에 간단한 용어부터 해결하자.

컴파일러 (Compiler)

컴파일이란, 소스코드를 컴파일 하여 컴퓨터가 이해할 수 있는 기계어(0101)로 미리 변환하여 변환된 파일을 실행하는 것이다. 예를 들면 C언어에서 makefile을 통해 컴파일을 하고 오브젝트 파일을 만드는 것으로 보면 된다(물론 링킹, 로더 등 여러 단계가 더 있다). 즉, 미리 소스코드를 컴퓨터가 이해하기 쉬운 명령들로 변경을 한다.

인터프리터 (Interpreter)

인터프리터란 코드를 한 줄 씩 읽어서 바로 실행하는 프로그램이나 환경을 뜻한다. 혹은, 소스코드를 효율적인 다른 코드(ex: 바이트코드)로 변환하고 이를 바로 실행한다.

JIT Compile

V8의 컴파일러 구조는 다음 그림과 같다.

JIT Compiler.png

JSConf EU 2017 - Franziska Hinkelmann

  1. 우리가 작성한 .js 소스코드를 V8이 받아서 파서(Parser)에게 넘긴다
  2. 파서(Parser)가 소스코드 분석해서 AST(Abstract Syntax Tree)로 만든다. 그리고 Ignition(V8 인터프리터 이름)에게 넘긴다
  3. Ignition은 이를 바이트코드로 변환한다.
  4. 프로파일러(Profiler)는 자주 사용되는 코드를 TurboFan(V8 컴파일러)으로 넘겨서 컴파일 하여 최적화된 기계어로 변환한다.
  5. 최적화된 기계어는 해당 부분에 대한 바이트코드와 변환되어 성능을 높힌다.

결론적으로, V8은 JIT 컴파일 방식을 통해 바이트코드, 최적화 기계어를 통해 규모가 큰 js 파일을 고성능으로 돌릴 수 있게 되었다.

콜 스택 (Call Stack)

코드가 실행되며 스택 프레임이 쌓이는 곳. 즉, 서브루틴에 관한 정보를 저장하는 곳

자바스크립트에 대한 부분 중 싱글 스레드(Single-threaded) 에 대해 이야기 하는 부분이다. V8 엔진은 단일 콜스택을 사용하여, 요청이 들어올 때마다 요청을 순차적으로 스택에 담아서 처리한다.

프로세스 (Process)

메모리상에 올라간 프로그램이다. OS로부터 메모리 공간을 할당받아 실행되는 프로그램이다.

쓰레드 (Thread)

프로세스 내에서 실행되는 코드 흐름의 단위이다. 프로세스가 할당받은 자원을 공유하며 사용하는데, stack과 register set은 개별적으로 할당 받는다. 여기서 register set에는 program counter(pc), stack pointer 등 “코드 실행” 에 관련된 포인터들이 저장된다. 그래서 각 쓰레드는 다음 코드 어디를 실행해야 하는지를 pc에 저장하고 있고 이게 개별적으로 운영되기 때문에 쓰레드는 코드 흐름의 단위라고 표현하는 것이다.

V8의 콜 스택 사용법

콜 스택 (call stack)이란 function call을 통해 움직이는 “실행 흐름”을 저장하여 해당 함수가 끝나면 어디로 돌아가서 다음 코드를 실행시킬지에 대한 내용을 저장하여 추적하는 스택 자료구조이다. 콜 스택은 각 스레드마다 존재한다. “실행 흐름” 을 추적하는 것이니까. 해당 실행 흐름은 “실행 컨텍스트(execution context)”에 따라 관리 되고 이 순서대로 콜스택에 쌓였다가 없어지는 것이다. 자바스크립트는 run-to-complete라는 방식으로 함수가 실행된다. 이 방식은, 하나의 함수가 실행되면 끝날 때까지 다른 함수가 중간에 끼어들지 못한다. 그래서 요청이 들어온, 즉 콜 스택이 처리하는 순서대로 실행될 수 있는 것이다.

그렇다면 자연스럽게 콜 스택이 한 개밖에 없고, run-to-complete 방식이라면 한 번에 한 개의 일(실행)만 할 수 있다는 것이다. 그러므로 CPU-intensive(CPU를 엄청 오래 점유하는) 작업을 돌리게 되면 전체적으로 성능이 저하되는 상황이 발생하는 것이 특징이다.

이를 해결하기 위해서는 V8 외에 런타임 환경에 존재하는 이벤트 루프, 태스크 큐등을 이해해야 한다. 이는 node.js 런타임에 대해 정리할 때 따로 포스팅 하겠음.

힙 메모리 (Heap Memory)

Heap이란 메모리에서 동적으로 생성되는 데이터를 할당하는 공간이다.

resident set.png

V8 Resident Set

위 그림은 V8이 메모리를 사용하는 방식을 보여준다. 힙 메모리는 가비지 컬렉션이 진행되는 공간이다.

  • Old Space, New Space에서 가비지 컬렉션이 진행된다.
    • Old 영역에서는 메이저 GC가 일어난다.
    • New 영역에서는 마이너 GC가 일어나며 여기서 살아남은 객체는 old로 이동되어 관리된다.
  • Code Space는 코드 영역으로 JIT 컴파일러가 컴파일된 코드를 저장하는 공간이다.

아래 링크는 V8 엔진이 힙 메모리와 콜 스택을 어떻게 사용하는지 슬라이드로 보기 쉽게 표현하였다. 좌우 키를 이용하여 넘기면서 알아보자.

V8 Memory usage(Stack & Heap)

힙 메모리 관리: 가비지 컬렉션

가비지 컬렉션이란, 사용하지 않는 메모리를 자동으로 할당 해제하는 것이다.

C, C++ 과 같이 직접 메모리에 포인터를 이용하여 접근할 수 있는 언어는 생성자, 소멸자를 통해 프로그래머가 직접 메모리를 관리한다. 반면 자바스크립트는 이를 허용하지 않는다. 따라서 프로그래머가 할당한 메모리를 자동으로 지워주는 가비지 컬렉션이라는 내용이 존재한다.

가비지 컬렉션을 하기 위해서 가장 중요한 내용은, 지금 이 메모리가 사용되고 있는가? 에 대한 내용을 판별하는 것이고, 이는 “참조” 를 기준으로 한다. 즉, 전역 객체로부터 더 이상 접근할 수 없는 부분을 도달할 수 없는 (unreachables)로 지정하고 할당을 해제한다.

가비지 컬렉션은 힙 메모리를 위 그림처럼 2개로 나눠서 사용한다. new space에서는 마이너 GC가, old space에서는 메이저 GC가 발생한다.

Minor GC (Scavenger)

마이너 GC는 new 영역을 이용한다. 2개의 semi 영역으로 나누어 관리하며 Cheney 알고리즘을 통해 다음과 같이 동작한다.

  • 2개의 semi 영역중 하나를 from, 하나를 to로 설정한다.
  • from에 여러 할당된 객체들이 위치한다.
  • 마이너 GC가 발생하면 “도달 가능한 객체” 를 to 영역으로 보내고, 나머지는 할당 해제한다. 새로운 객체는 to영역에 할당된다.
  • To 영역이 가득 차면, 그리고 from과 to를 바꾸어서 마이너 GC를 다시 실행한다.
  • 여기서 2번의 마이너 GC에서 살아남으면 Old 영역으로 이동된다.

아래의 링크를 통해 GC를 시각화 하여 보자.

V8 Minor GC

V8의 마이너 GC는 stop-the-world 프로세스이다. 왜냐하면 1개의 쓰레드이므로, 전부가 멈추고 마이너 GC가 실행된다고 보면 된다. 하지만, 매우 빠르고 효율적이므로 무시할만 하다고 한다.

stop-the-world: 모든 프로세스가 멈추고 GC만 수행하는 상태.

Major GC (Mark-and-Sweep, Mark-Sweep-Compact)

메이저 GC는 old 영역을 관리한다. old 영역의 공간이 충분하지 않다고 생각될 때 발생한다. 메이저 GC는 스캐밴져와 다른 알고리즘을 사용한다. 왜냐하면 이는 실제 메모리 이동이 있기 때문에 적은 양일때는 오버헤드가 적어 무시할만 하지만 old 영역과 같이 큰 메모리에는 오버헤드를 무시할 수 없다.

메이저 GC는 mark-sweep-compact 알고리즘을 사용한다.

  1. 마킹(Marking): 가비지 컬렉터가 어떤 객체가 사용중인지(도달 가능한 지) 판별한다. 사용중이거나 도달 가능하면, 활성 상태로 표시한다. 마킹을 하는 방법으로는 DFS를 사용한다.
  2. 스위핑(Sweeping): 가비지 컬렉터가 힙 메모리를 순회하며 활성 상태로 표시되지 않은 객체들의 메모리 주소를 기록한다. 이 공간들을 free-list, 즉 다른 객체가 저장될 수 있는 사용 가능한 공간으로 표기한다.
  3. 압축(Compaction): 스위핑이 일어나면 객체들은 단편화(fragmentation) 되어있을 것이다. 메모리 여기저기 흩어져 있는 객체들을 이동하여 새로운 객체의 할당 효율을 높인다.

Write Barrier

만약 Old 영역에 있는 객체가 New 영역의 객체를 참조하는데, New 영역의 객체가 마이너 GC에 의해 삭제된다면? Dangling Pointer 문제가 발생한다.

그렇다면 new 영역을 삭제할때 모든 old 영역까지 검사해야 하는가? → 오버헤드가 크다

따라서 참조를 대입하거나 수정할 때, 객체의 참조 정보를 기록해 두는 방법을 사용하게 되었고 이를 Write Barrier라고 한다.