
웹 개발을 하다보면 V8엔진이란 단어를 많이 들어봤을 것이다. 대체 V8엔진은 무엇이고 어떤 역할을 하는지 이번 포스팅에서 알아보자
V8 엔진은 웹 브라우저를 만드는 데 기반을 제공하는 JavaScript 엔진이다.
그렇다면 JavaScript는 왜 엔진이 필요할까?
JavaScript는 인터프리터(Interpretor)언어이기 때문에 코드를 해석하고 실행하는 엔진이 필요하다.
따라서 V8은 JavaScript 코드를 해석하고 실행하는 엔진 역할을 해주며 컴파일을 해주고 컴퓨터가 읽을 수 있는 기계어(바이트 코드)로 변환해 준다.
인터프리터언어는 실행 즉시 인터프리터를 거쳐서 실행되는 프로그래밍 언어이다. 한 줄 한 줄 인터프리터가 해석해서 컴퓨터에게 전달해 주는 방식이어서 컴파일 언어보다 실행 속도가 느리다. 다만 여기서 느리다는 건 상대적으로 느린 것이다. 여기서 컴파일 언어에는 c, c++, c# 등이 있다.
V8은 여러가지 도움을 주는 역할을 하고 있다.
1. JavaScript 코드 실행
JavaScript코드를 컴파일 하고 실행한다.
입력된 JavaSCript코드를 기계어로 변환하여 실행하는 JIT(Just-In-Time)컴파이얼러를 사용하여 빠른 실행 속도를 제공한다.
2. 메모리 관리
JavaScript객체의 생성 및 소멸을 관리하고 메모리 할당 및 해제를 처리한다.
이를 통해 메모리의 효율적인 사용을 보장하고 메모리 누수를 최대한 방지한다.
3. 가비지 컬렉션(GC)
더 이상 필요하지 않은 메모리를 회수한다.
가비지 컬렉션은 자동으로 실행되며, 더 이상 참조되지 않는 객체를 식별하여 메모리에서 제거한다.
4. 웹 어셈블리
웹 어셈블리를 실행할 수 있게 한다.
5. 다중 스레딩 및 워커
웹 워커(Web worker)와 같은 다중 스레딩 기능을 제공하여 웹 애플리케이션의 성능을 향상시킨다.
6. 디버깅 지원
JavaScript코드의 디버깅을 지원한다.
개발자 도구를 통해 실행중인 변수의 값이나 함수의 호출 스택 등을 확인 할 수 있다.
이 중에서 V8의 메모리 관리에 대해서 자세하게 알아보자.

V8은 프로그램이 실행되면 Resident Set이라는 빈 메모리 공간을 할당한다.
해당 영역은 Stack, Heap으로 나뉜다.
이 중에서 가비지 컬렉션과 관련된 영역은 New space(Young Generation), Old space(Old Generation)이다.
Stack영역은 원시 값, 함수 프레인, 객체 포인터를 포함한 정적 데이터가 저장되는 곳이다.
Heap영역은 크게 7개의 영역으로 볼 수 있는데 이 중에서 가비지 컬렉션과 관련있는 New space와 Old space에 대해서 살펴볼 것이다.
New space영역은 새로 만들어진 모든 객체를 저장하고 해당 영역에 포함된 객체들은 짧은 생명 주기를 가진다.
또한, New space내부에는 2개의 Semi space영역을 가지고 있고 해당 영역은 Minor GC가 관리한다.
Old space영역은 Minor GC가 2번 발생하고 살아남은 객체들이 이동하는 영역이다.
Old space영역또한 New space영역과 마찬가지로 2개의 영역으로 나눠지는데 해당 영역은 각각 Old pointer space, Old data space로 나눠진다.
Old Pointer space
다른 객체를 참조하고 있는 객체들이 있는 영역이다.
Old Data space
데이터만 가진 객체들만 있는 영역이다.(다른 객체를 참조하지 않는다.)
두번의 Minor GC가 발생하고 살아남은 객체가 해당 영역으로 이동한다.
다른 영역의 제한된 크기보다 큰 객체들이 살고 있는 영역이다.
컴파일러가 컴파일된 코드를 저장하는 곳이다.

New space는 2개의 Semi space영역으로 나눠진다.
그 중, 생성된 객체들이 존재하고 있는 영역을 From space라고 부른다.
비어 있는 영역을 To space라고 부른다.

Minor GC는 From space에 있는 객체들을 Mark and Sweep알고리즘을 통해서 Roots에서 이어진 참조객체들을 To space로 이동시킨다.
이동시키는 객체들은 새로운 메모리 주소값으로 포인터가 갱신된다.
그리고 참조되지 않은 객체들은 수집한다.
즉, 자기 자신을 다른객체에서 참조하고 있지 않은 객체들을 수집하는 것이다.
자기 자신을 참조하고 있지 않다는 건 결국 프로그램 어느곳에서도 사용되지 않는 것이고, 메모리에 있을 필요가 없다.

수집된 객체들은 프로그램 내부에서 사용되지 않는 객체들이므로 버려진다.
즉, 메모리 할당을 해제한다.
그 후, 기존의 From space, To space의 역할을 바꿔준다.
즉, 기존의 From space는 To space로 바뀌고 To space는 From space로 바뀐다.
앞선 프로세스를 반복해서 수집된 객체는 메모리에서 해제하고 살아남은 객체는 2번 생존했을 시 Old space로 이동시키고 1번 생존했을 시에는 To space로 이동시킨다.
그럼 2번생존했을시에만 Old space로 보내는 이유가 뭘까?(개인적인 궁금증)
개인적인 생각으로는
New space는 짧은 생명주기를 가진 객체들을 할당하기 때문에 Old space보다 상대적으로 적은 메모리 용량을 가지고 있다.
그러므로 2번 생존했다는건 곧 장기간 사용될 객체이기 때문에 Old space로 가서 객체를 관리하고 또한 해당 객체들은 보통의 객체들보다 큰 메모리를 차지할 확률이 높으므로 2번이라는 기준을 통해 Old space로 보내고
가비지컬렉션 작업은 오버헤드가 많이 발생하므로 상대적으로 많은 오버헤드가 일어나는 Minor GC보다는 적은횟수로 오버헤드가 일어나는 Old space의 Major GC로 처리하려는게 좋다고 생각한게 아닐까?
Major GC는 Old space의 메모리가 충분하지 않다고 판단될 때 발생한다.
Major GC는 Mark Sweep Compact알고리즘과 Tri-color알고리즘을 사용한다.
마찬가지로 참조되지 않은 객체는 더 이상 사용하지 않는 객체로 메모리에서 해제한다.
Major GC는 총 3단계로 이뤄진다.
[ 1단계 ]
1단계는 어떤 객체가 메모리 해제의 대상인지 마킹하는 단계이다.
항상 접근가능한 객체인 "Roots"로부터 시작한 객체들을 순회하여 Tri-color 즉, white, black, gray로 마킹한다.
black
방문이 완료된 객체이다.
즉, 해당 객체를 검사하고 인접한 객체들까지 검사한 상태이다.
gray
방문이 시작된 객체를 나타낸다.
즉, 해당 객체를 검사한 상태이고 인접한 객체를 검사하기 시작했지만, 아직 완전히 검사하지 않은 상태이다.
white
방문하지 않은 객체를 나타낸다.
먼저, 가비지 컬렉션 시작시 모든 객체를 흰색으로 표시한다.
그 후, 가비지 컬렉션 알고리즘이 실행되면서, "Roots"로부터 시작하여 접근 가능한 모든 객체를 회색으로 표시하고, 해당 객체가 참조하는 다른 객체들을 순회한다.
순회 과정에서 이미 회색으로 표시된 객체를 만나면 검은색으로 표시하고, 해당 객체가 참조하는 다른 객체들에 대해서 같은 작업을 반복한다.
순회가 끝나면, 흰색으로 남아있는 객체들은 도달 불가능한(unreachable)객체로 판단하고 회수해야 하는 메모리로 표시한다.
[ 2단계 ]
여전히 흰색으로 마킹된 객체들의 메모리 주소를 제거하여 "free-list"라고 부르는 자료구조에 추가한다.
이제 이 주소들의 메모리 공간은 새로운 객체로 저장이 가능하다.
free-list자료구조는 메모리 할당 및 해제를 관리하기 위한 자료구조이다. 할당된 메모리 추적, 메모리 할당, 메모리 해제 기능을 수행하며 동적 메모리 할당 및 해제를 효율적으로 관리한다.
[ 3단계 ]
메모리 단편화가 심한 페이지들을 재배치하여 추가적인 메모리를 확보한다.
즉, 메모리에서 생성된 공백을 한곳으로 모아 메모리의 단편화를 최소화하는 작업이다.
이 작업을 통해서 연속된 메모리 영역을 확보하여 메모리 할당 및 관리를 효율적으로 만든다.
Minor GC, Major GC를 수행할 때 프로그램이 멈추게 된다.
왜 그럴까?
JavaScript는 싱글스레드 언어이다.
즉, 하나의 스레드가 곧 메인스레드여서 우리가 짠 코드를 해당 스레드로 브라우저를 동작하게 하는 것이다.
하지만 가비지 컬렉션은 메인스레드에서 동작한다.
그 말은 사용자 이벤트와 페인트 작업을 처리했던 메인스레드가 가비지 컬렉션 작업을 해야 하기 때문에 기존의 작업이 멈추는 것이다.
이러한 현상을 stop-the-world라고 한다.
가비지 컬렉션으로 인하여 브라우저가 멈추거나 동작이 버벅이면 사용자에게 좋지 않은 UX 경험을 제공하게 된다.
이를 위해 "Orinoco 프로젝트"를 통해 여러가지 기술이 추가되면서 GC의 최적화가 이뤄졌다.
기존에 메인스레드 혼자서 하던 GC 작업을 헬퍼스레드 들과 균등하게 나누어 일을한다.
해당 작업은 헬퍼스레드와 메인스레드 간의 동기화를 처리해야 해서 오버헤드가 발생하지만 stop-the-world시간이 크게 감소한다.
메인스레드가 전체 가비지 컬렉션 작업을 수행하지 않고, 가비지 컬렉션에 필요한 전체 작업 중 작은 일부만 수행한다.
이 방식은 어려운 방식이다.
왜냐하면, JavaScript가 각 Incremental Work Segment사이에서 실행되기 때문에 Heap의 상태가 변경되고 이전에 Incremental하게 수행된 작업이 무효화 될 수 있다.
사실 상 메인 스레드에서 사용된 시간을 줄이지는 않지만 단지 분산되어 처리한다는 방식때문에 사용자에게 좋은 UX를 제공한다.
메인스레드가 JavaScript를 지속적으로 실행하는 동안, 헬퍼스레드가 완전히 백그라운드에서 가비지 컬렉션 작업을 수행하는 방식이다.
앞선 두 방법보다 어려운 방법이므로, JavaScript의 Heap에 있는 모든것이 언제든지 변경될 수 있으므로, 이전에 수행한 작업을 무효화 할 수 있다.
이 방식의 장점은 메인스레드가 JavaScript를 실행하는데 완전히 자유롭다는 점이다.
비록 헬퍼 스레드와의 동기화로 인한 약간의 오버헤드가 발생할 수 있다.
가비지 컬렉션에 직접적으로 접근할 수 있는 권한을 가지고 있지 않다.
하지만 V8은 크롬과 같은 임베더(embedder)가 가비지 컬렉션을 트리거할 수 있는 메커니즘을 제공한다.
이러한 메커니즘은 가비지 컬렉션이 유휴시간(Idle-time)에 추가적인 작업을 수행할 수 있도록 하는 "Idle Tasks"를 생성한다.
예를 들어, Chrome은 초당 60프레임으로 애니메이션의 각 프레임을 렌더링 하는데 약 16.6ms를 소요한다.
만약 애니메이션 작업이 16.6ms보다 빨리 완료되면 Chrome은 다음 프레임이 오기 전에 가비지 컬렉션이 생성한 작업들을 수행할 수 있다.
임베더(embedder)는 시스템에 다른 소프트웨어나 컴포넌트를 내장하는 역할을 하는 주체를 가르킨다.
V8 엔진이 node.js의 근간으로만 알고 있어서 메모리 관리에 대해서 어떤 식으로 GC를 동작하는지 몰랐는데 단순 GC가 아닌 새롭게 생긴 객체가 들어가는 New space와 계속해서 사용하는 객체는 Old space를 통해서 서로 다른 GC가 동작하고 그 안에서도 JavaScript가 싱글스 레드인 점을 감안해 어떤 식으로 최적화를 진행했는지 알 수 있었다.
이번 포스팅이 다음 프로젝트를 진행할 때 메모리 관리에 도움이 되기를 바라며 이번 포스팅을 마무리한다.
https://developer.mozilla.org/ko/docs/Web/JavaScript/Memory_management
https://v8.dev/docs
https://ui.toast.com/weekly-pick/ko_20200228