트위터 계정을 팔로우하고 글에서 개선할 내용이 있다면 알려달라.
이번 연재물에서는 메모리 관리 개념을 이해하고 현대 프로그래밍 언어에서 사용하는 메모리 관리 방법에 대해 자세히 살펴보고자 한다. 이 연재물이 메모리 관리 관점에서 언어마다 일어날 수 있는 일에 대한 통찰력을 줄 수 있길 바란다.
이번 장에서는 NodeJS, Deno와 Electron과 같은 런타임 및 Chrome, Chromium, Brave, Opera, Microsoft Edge와 같은 웹 브라우저에서 사용되는 ECMAScript와 WebAssembly를 위한 V8 엔진의 메모리 관리 방법에 대해 살펴볼 것이다.
자바스크립트는 인터프리터 언어기 때문에 코드를 해석하고 실행하는 엔진이 필요하다. V8 엔진은 자바스크립트를 해석하고 컴파일하여 기계어로 변환한다. V8은 C++로 작성되었으며 모든 C++ 애플리케이션에서 내장할 수 있다.
이 연재물의 첫 번째 글에서 이 글을 이해하는데 도움을 줄 스택 메모리와 힙 메모리의 차이점을 설명하고 있으므로, 먼저 읽고 오는 것을 추천한다.
먼저, 우리는 V8 엔진의 메모리 구조를 살펴볼 것이다. 자바스크립트가 단일 스레드이기 때문에 V8은 자바스크립트 컨텍스트 당 한 개의 프로세스를 사용한다. 따라서 만약 서비스 워커를 사용한다면 워커 당 한 개의 새로운 V8 프로세스를 생성하게 될 것이다. 실행 중인 프로그램은 V8 프로세스에서 할당된 일정량의 메모리로 표현되고 이를 Resident Set이라고 한다. Resident Set은 아래와 같이 더 세부적으로 나누어진다.
Resident Set은 이전 글에서 살펴본 JVM 메모리 구조와 조금 유사하다. 각 세그먼트마다 무엇이 다른지 함께 살펴보자.
V8 엔진은 힙 메모리에 객체나 동적 데이터를 저장한다. 힙 메모리는 메모리 영역에서 가장 큰 블록이면서 가비지 컬렉션(GC)이 발생하는 곳이다. 힙 메모리 전체에서 가비지 컬렉션이 실행되는 것은 아니다. Young과 Old 영역에서만 실행된다. 힙 메모리는 다음과 같이 더 세부적으로 나눌 수 있다.
--min_semi_space_size
(초기값)와 --max_semi_space_size
(최대값) V8 엔진의 플래그 값을 사용해 조정할 수 있다.Old 영역: Old 영역 또는 "Old 제너레이션"은 마이너 GC가 두 번 발생할 동안 "New 영역"에서 살아남은 객체들이 이동하는 영역이다. 이 영역은 이후에 살펴볼 메이저 GC(Mark-Sweep 및 Mark-Compact)가 관리한다. Old 영역의 크기는 V8 엔진의 플래그 값 --initial_old_space_size
(초기값)와 max_old_space_size
(최대값)을 사용해 조정할 수 있다. 이 영역은 다시 2개의 영역으로 나누어진다.
Cells
, PropertyCells
, Maps
을 포함한다. 각 영역은 모두 같은 크기의 객체들을 포함하며, 어떤 종류의 객체를 참조하는지에 대한 제약이 있어서 수집을 단순하게 만든다.각 영역은 페이지들로 구성되어 있다. 페이지는 운영 체제에서 mmap
(또는 Windows에서 MapViewOfFile
)로 할당된 연속된 메모리 청크를 의미한다. 각 페이지 크기는 라지 오브젝트 영역을 제외하고 1MB를 차지한다.
스택은 메모리 영역이고 V8 프로세스마다 하나의 스택을 가진다. 스택은 메서드와 함수 프레임, 원시 값, 객체 포인터를 포함한 정적 데이터가 저장되는 곳이다. 스택 메모리의 크기 제한은 --stack_size
V8 플래그 값을 사용해 설정할 수 있다.
이제 메모리가 어떻게 구성되어 있는지 명확하게 알았으니, 프로그램이 실행될 때 가장 중요한 부분이 어떻게 사용되는지에 대해 살펴보자.
아래와 같은 자바스크립트 프로그램을 사용하는데, 이 코드는 최적화되어 있지 않으므로 불필요한 중간 변수들과 같은 문제는 무시하고 스택과 힙 메모리 사용을 시각화하는 데 집중하기로 하자.
class Employee {
constructor(name, salary, sales) {
this.name = name;
this.salary = salary;
this.sales = sales;
}
}
const BONUS_PERCENTAGE = 10;
function getBonusPercentage(salary) {
const percentage = (salary * BONUS_PERCENTAGE) / 100;
return percentage;
}
function findEmployeeBonus(salary, noOfSales) {
const bonusPercentage = getBonusPercentage(salary);
const bonus = bonusPercentage * noOfSales;
return bonus;
}
let john = new Employee("John", 5000, 5);
john.bonus = findEmployeeBonus(john.salary, john.sales);
console.log(john.bonus);
아래 슬라이드를 클릭한 후 화살표 키를 사용해서 앞/뒤로 움직이면, 위 프로그램이 실행되는 순서와 스택 및 힙 메모리가 사용되는 방법을 이해할 수 있다.
이 슬라이드에서 확인할 수 있는 것은
int
와 string
과 같은 모든 원시 타입 값은 스택에 바로 저장된다. 이는 전역 스코프에서도 적용되며, 자바스크립트에서 문자열은 원시 타입에 해당한다.Employee
와 Function
과 같은 객체 타입의 값은 힙에서 생성되고 스택 포인터를 사용해 힙에서 스택을 참조한다. 함수들은 자바스크립트에서 객체이다. 전역 스코프에도 적용된다.보다시피 스택은 자동으로 관리되고 V8 엔진 자체가 아닌 운영 체제가 수행하므로 걱정하지 않아도 된다. 반면에 힙은 운영 체제에 의해 자동으로 관리되지 않고 가장 큰 메모리 영역과 동적 데이터를 보유하고 있기 때문에, 시간이 지남에 따라 프로그램의 메모리가 기하급수적으로 증가할 수 있다. 또한 시간이 지나면서 조각화되어 애플리케이션 속도를 느리게 만든다.
힙에서 포인터와 데이터를 구분하는 것은 가비지 컬렉션에서 중요하며 이를 처리하기 위해, V8 엔진은 "태그된 포인터(Tagged pointers)" 접근 방식을 사용한다. 태그된 포인터는 각 단어의 끝에 포인터 또는 데이터인지를 나타내는 비트 값을 저장하는 방식이다.
이 접근 방식은 제한적인 컴파일러 지원이 필요하지만, 굉장히 효율적이면서 구현하기 쉽다.
V8 엔진이 메모리를 할당하는 방식을 알았으니, 이제는 애플리케이션 성능에서 매우 중요한 부분인 힙 메모리가 어떻게 자동으로 관리되는지에 대해 살펴보자.
프로그램이 사용 가능한 것보다 더 많은 메모리를 힙에 할당하려고 할 때 메모리 부족 오류가 발생한다. 힙이 잘못 관리되면 메모리 누수가 발생할 수 있다.
V8 엔진은 가비지 컬렉션을 사용해 힙 메모리를 관리한다. 간단하게 말하면, 가비지 컬렉션은 참조 없는 객체들이 사용하는 메모리를 비워서 새로운 객체를 생성하기 위한 공간을 만드는 역할을 한다. 참조 없는 객체(orphan object)란, 스택으로부터 (다른 객체 내부의 참조를 통해) 더 이상 직접 혹은 간접적으로 참조되지 않는 객체를 말한다.
Orinoco는 가비지 컬렉션에 병렬(parallel), 인크리멘탈(incremental) 및 동시(concurrent) 기술을 사용하여 메인 스레드를 방해하지 않도록 하는 V8 GC 프로젝트의 코드명이다.
V8 엔진의 가비지 컬렉터의 역할은 V8 프로세스에서 재사용하기 위해 사용되지 않은 메모리를 회수하는 것이다.
V8 가비지 컬렉터는 세대적이다(힙 메모리 내의 객체들은 수명에 따라 그룹화되고 다른 단계에서 제거된다). 다음은 V8 엔진의 가비지 컬렉션에서 사용되는 2단계와 3개의 알고리즘에 대한 설명이다.
마이너 GC는 New 영역(또는 Young 제너레이션)을 작고 깨끗하게 유지시킨다. 객체들은 New 영역에 할당되고 크기가 매우 작다(상황에 따라 1~8MB를 차지한다). "New 영역"에 대한 할당 비용은 매우 저렴하다. 새 객체에 대한 공간을 예약하려고 할 때마다 증가하는 할당 포인터가 있기 때문이다. 이 할당 포인터가 New 영역의 마지막에 도달하면, 마이너 GC가 발생한다. 이 과정을 스캐벤저(Scavenger)라고 하며, Cheney의 알고리즘을 사용해 구현되었다. 스캐벤저는 매우 자주 발생하고 병렬 헬퍼 스레드를 사용하며, 굉장히 빠르다.
마이너 GC 과정을 살펴보자.
New 영역은 크기가 같은 2개의 세미 영역으로 나뉜다. To 영역과 From 영역이다. 대부분의 할당은 To 영역에서 만들어진다(항상 Old 영역에서 할당된 실행 코드와 같은 특정 종류의 객체 제외). To 영역이 가득 차면 마이너 GC가 발생한다.
아래 슬라이드에서 화살표를 따라가 보면서 마이너 GC 과정을 살펴보자.
마이너 GC가 Young 제너레이션에서 공간을 회수하고 깨끗하게 유지하는 방법에 대해 살펴보았다. 마이너 GC는 stop-the-world 프로세스지만, 굉장히 빠르고 효율적이므로 무시할 수 있다. 이 프로세스는 "New 영역"의 참조를 위해 "Old 영역"에서 객체들을 찾지 않기 때문에, "Old 영역"에서 "New 영역"까지 모든 포인터의 레지스터를 사용한다. 이것은 Write barrier라고 하는 프로세스를 통해 저장 버퍼에 기록된다.
메이저 GC는 Old 제너레이션 영역을 작고 깨끗하게 유지시킨다. 메이저 CG는 V8에서 Old 영역의 메모리가 충분하지 않다고 판단될 때 발생한다. Old 영역은 동적으로 계산된 크기에 기반하며, 마이너 GC 주기에서 채워진다. 스캐벤저 알고리즘은 작은 데이터 크기에는 적합하지만 Old 영역과 같이 큰 힙 메모리에는 적합하지 않다. 메모리 오버헤드가 있기 때문에 메이저 GC는 Mark-Sweep-Compact 알고리즘을 사용하여 처리된다. 메이저 GC는 Tri-color(흰색-회색-검은색) 마킹 시스템을 사용한다. 따라서 메이저 GC는 세 단계의 프로세스를 거치며, 세 번째 단계는 조각화 휴리스틱(fragmentation heuristic)에 따라 실행된다.
또한 메이저 GC는 GC를 수행하는 동안 애플리케이션 실행을 멈추므로 stop-the-world GC라고도 한다. 이를 피하기 위해 V8에서는 다음과 같은 기술을 사용한다.
메이저 GC 과정을 살펴보자.
이번 글에서는 V8 엔진의 메모리 구조와 관리 방법에 대해서 소개했다. v8.dev에서 더 많은 고급 개념들을 배울 수 있다. 그러나 대부분의 자바스크립트/웹 어셈블리 개발자에게는 이 정도의 정보만으로 충분할 것이다. 오늘 다룬 내용을 숙지하여 메모리 누수 문제를 예방하고 더 나은 프로그램을 작성하는 데 도움이 되기를 바란다.
당신이 V8 엔진 내부에 대해 재미를 느꼈으면 좋겠고 시리즈의 다음 글을 계속 지켜봐 주길 바란다.
이번 글이 좋았다면 추천 또는 코멘트를 남겨달라.
이 글은 2020년 1월 26일에 블로그와 https://dev.to에 게시되었다.