V8 엔진(자바스크립트, NodeJS, Deno, WebAssembly) 내부의 메모리 관리 시각화하기


원문 : https://deepu.tech/memory-management-in-v8/

트위터 계정을 팔로우하고 글에서 개선할 내용이 있다면 알려달라.


이번 연재물에서는 메모리 관리 개념을 이해하고 현대 프로그래밍 언어에서 사용하는 메모리 관리 방법에 대해 자세히 살펴보고자 한다. 이 연재물이 메모리 관리 관점에서 언어마다 일어날 수 있는 일에 대한 통찰력을 줄 수 있길 바란다.


이번 장에서는 NodeJS, Deno와 Electron과 같은 런타임 및 Chrome, Chromium, Brave, Opera, Microsoft Edge와 같은 웹 브라우저에서 사용되는 ECMAScriptWebAssembly를 위한 V8 엔진의 메모리 관리 방법에 대해 살펴볼 것이다.

자바스크립트는 인터프리터 언어기 때문에 코드를 해석하고 실행하는 엔진이 필요하다. V8 엔진은 자바스크립트를 해석하고 컴파일하여 기계어로 변환한다. V8은 C++로 작성되었으며 모든 C++ 애플리케이션에서 내장할 수 있다.

이 연재물의 첫 번째 글에서 이 글을 이해하는데 도움을 줄 스택 메모리와 힙 메모리의 차이점을 설명하고 있으므로, 먼저 읽고 오는 것을 추천한다.

V8 메모리 구조

먼저, 우리는 V8 엔진의 메모리 구조를 살펴볼 것이다. 자바스크립트가 단일 스레드이기 때문에 V8은 자바스크립트 컨텍스트 당 한 개의 프로세스를 사용한다. 따라서 만약 서비스 워커를 사용한다면 워커 당 한 개의 새로운 V8 프로세스를 생성하게 될 것이다. 실행 중인 프로그램은 V8 프로세스에서 할당된 일정량의 메모리로 표현되고 이를 Resident Set이라고 한다. Resident Set은 아래와 같이 더 세부적으로 나누어진다.

Resident Set은 이전 글에서 살펴본 JVM 메모리 구조와 조금 유사하다. 각 세그먼트마다 무엇이 다른지 함께 살펴보자.

힙 메모리

V8 엔진은 힙 메모리에 객체나 동적 데이터를 저장한다. 힙 메모리는 메모리 영역에서 가장 큰 블록이면서 가비지 컬렉션(GC)이 발생하는 곳이다. 힙 메모리 전체에서 가비지 컬렉션이 실행되는 것은 아니다. Young과 Old 영역에서만 실행된다. 힙 메모리는 다음과 같이 더 세부적으로 나눌 수 있다.

  • New 영역: New 영역 또는 "Young 제너레이션"은 새로 만들어진 모든 객체를 저장하고 이 객체들은 짧은 생명 주기를 가진다. 이 영역은 크기가 작고 JVM에서 S0와 S1과 같은 2개의 세미(semi) 영역을 가진다. 이 영역은 이후에 살펴볼 스캐벤져(Scavenger, 마이너 GC)가 관리한다. New 영역의 크기는 --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개의 영역으로 나누어진다.

    • Old 포인터 영역: 살아남은 객체들을 가지며, 이 객체들은 다른 객체를 참조한다.
    • Old 데이터 영역: 데이터만 가진 객체들(다른 객체를 참조하지 않는다)을 가진다. 문자열, 박싱(boxing)된 숫자, 실수형(double)로 언박싱(unboxing)된 배열은 마이너 GC가 두 번 발생하면서 "New 영역"에서 살아남아 이 영역으로 이동한다.
  • 라지 오브젝트 영역: 다른 영역의 제한된 크기보다 큰 객체들이 살고 있는 영역이다. 각 객체는 자체 mmap 메모리 영역을 갖는다. 라지 오브젝트들은 가비지 컬렉터로 이동하지 않는다.
  • 코드 영역: 실시간(JIT) 컴파일러가 컴파일된 코드들을 저장하는 곳이다. 유일하게 실행 가능한 메모리가 있는 영역이다. (코드들은 "라지 오브젝트 영역"에 할당될 수도 있고 실행도 가능하다)
  • 셀 영역, 속성 셀 영역, 맵 영역: 이 영역들은 각각 Cells, PropertyCells, Maps을 포함한다. 각 영역은 모두 같은 크기의 객체들을 포함하며, 어떤 종류의 객체를 참조하는지에 대한 제약이 있어서 수집을 단순하게 만든다.

각 영역은 페이지들로 구성되어 있다. 페이지는 운영 체제에서 mmap(또는 Windows에서 MapViewOfFile)로 할당된 연속된 메모리 청크를 의미한다. 각 페이지 크기는 라지 오브젝트 영역을 제외하고 1MB를 차지한다.

스택

스택은 메모리 영역이고 V8 프로세스마다 하나의 스택을 가진다. 스택은 메서드와 함수 프레임, 원시 값, 객체 포인터를 포함한 정적 데이터가 저장되는 곳이다. 스택 메모리의 크기 제한은 --stack_size V8 플래그 값을 사용해 설정할 수 있다.


V8 메모리 사용 (스택 vs 힙)

이제 메모리가 어떻게 구성되어 있는지 명확하게 알았으니, 프로그램이 실행될 때 가장 중요한 부분이 어떻게 사용되는지에 대해 살펴보자.

아래와 같은 자바스크립트 프로그램을 사용하는데, 이 코드는 최적화되어 있지 않으므로 불필요한 중간 변수들과 같은 문제는 무시하고 스택과 힙 메모리 사용을 시각화하는 데 집중하기로 하자.

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);

아래 슬라이드를 클릭한 후 화살표 키를 사용해서 앞/뒤로 움직이면, 위 프로그램이 실행되는 순서와 스택 및 힙 메모리가 사용되는 방법을 이해할 수 있다.

V8 메모리 사용에 대한 슬라이드

이 슬라이드에서 확인할 수 있는 것은

  • 전역 스코프는 스택에서 "전역 프레임"에 보관된다.
  • 모든 함수 호출은 프레임 블록으로 스택 메모리에 추가된다.
  • 반환 값과 인자를 포함한 모든 지역 변수들은 스택에서 함수 프레임 블록 안에 저장된다.
  • intstring과 같은 모든 원시 타입 값은 스택에 바로 저장된다. 이는 전역 스코프에서도 적용되며, 자바스크립트에서 문자열은 원시 타입에 해당한다.
  • EmployeeFunction과 같은 객체 타입의 값은 힙에서 생성되고 스택 포인터를 사용해 힙에서 스택을 참조한다. 함수들은 자바스크립트에서 객체이다. 전역 스코프에도 적용된다.
  • 현재 함수에서 호출된 함수들은 스택의 최상단에 추가된다.
  • 함수 프레임이 반환(역자주: 함수가 종료)될 때 스택에서 제거된다.
  • 주요 프로세스가 완료될 때 힙에 있는 객체들은 어떤 포인터도 가지고 있지 않고 혼자 남게 된다.
  • 명시적으로 복사하지 않으면, 다른 객체 내의 모든 객체 참조들은 참조 포인터를 사용해 연결된다.

보다시피 스택은 자동으로 관리되고 V8 엔진 자체가 아닌 운영 체제가 수행하므로 걱정하지 않아도 된다. 반면에 힙은 운영 체제에 의해 자동으로 관리되지 않고 가장 큰 메모리 영역과 동적 데이터를 보유하고 있기 때문에, 시간이 지남에 따라 프로그램의 메모리가 기하급수적으로 증가할 수 있다. 또한 시간이 지나면서 조각화되어 애플리케이션 속도를 느리게 만든다.

힙에서 포인터와 데이터를 구분하는 것은 가비지 컬렉션에서 중요하며 이를 처리하기 위해, V8 엔진은 "태그된 포인터(Tagged pointers)" 접근 방식을 사용한다. 태그된 포인터는 각 단어의 끝에 포인터 또는 데이터인지를 나타내는 비트 값을 저장하는 방식이다.

이 접근 방식은 제한적인 컴파일러 지원이 필요하지만, 굉장히 효율적이면서 구현하기 쉽다.

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

V8 엔진이 메모리를 할당하는 방식을 알았으니, 이제는 애플리케이션 성능에서 매우 중요한 부분인 힙 메모리가 어떻게 자동으로 관리되는지에 대해 살펴보자.

프로그램이 사용 가능한 것보다 더 많은 메모리를 힙에 할당하려고 할 때 메모리 부족 오류가 발생한다. 힙이 잘못 관리되면 메모리 누수가 발생할 수 있다.

V8 엔진은 가비지 컬렉션을 사용해 힙 메모리를 관리한다. 간단하게 말하면, 가비지 컬렉션은 참조 없는 객체들이 사용하는 메모리를 비워서 새로운 객체를 생성하기 위한 공간을 만드는 역할을 한다. 참조 없는 객체(orphan object)란, 스택으로부터 (다른 객체 내부의 참조를 통해) 더 이상 직접 혹은 간접적으로 참조되지 않는 객체를 말한다.

Orinoco는 가비지 컬렉션에 병렬(parallel), 인크리멘탈(incremental) 및 동시(concurrent) 기술을 사용하여 메인 스레드를 방해하지 않도록 하는 V8 GC 프로젝트의 코드명이다.

V8 엔진의 가비지 컬렉터의 역할은 V8 프로세스에서 재사용하기 위해 사용되지 않은 메모리를 회수하는 것이다.

V8 가비지 컬렉터는 세대적이다(힙 메모리 내의 객체들은 수명에 따라 그룹화되고 다른 단계에서 제거된다). 다음은 V8 엔진의 가비지 컬렉션에서 사용되는 2단계와 3개의 알고리즘에 대한 설명이다.

마이너 GC (Scavenger)

마이너 GC는 New 영역(또는 Young 제너레이션)을 작고 깨끗하게 유지시킨다. 객체들은 New 영역에 할당되고 크기가 매우 작다(상황에 따라 1~8MB를 차지한다). "New 영역"에 대한 할당 비용은 매우 저렴하다. 새 객체에 대한 공간을 예약하려고 할 때마다 증가하는 할당 포인터가 있기 때문이다. 이 할당 포인터가 New 영역의 마지막에 도달하면, 마이너 GC가 발생한다. 이 과정을 스캐벤저(Scavenger)라고 하며, Cheney의 알고리즘을 사용해 구현되었다. 스캐벤저는 매우 자주 발생하고 병렬 헬퍼 스레드를 사용하며, 굉장히 빠르다.

마이너 GC 과정을 살펴보자.

New 영역은 크기가 같은 2개의 세미 영역으로 나뉜다. To 영역From 영역이다. 대부분의 할당은 To 영역에서 만들어진다(항상 Old 영역에서 할당된 실행 코드와 같은 특정 종류의 객체 제외). To 영역이 가득 차면 마이너 GC가 발생한다.

아래 슬라이드에서 화살표를 따라가 보면서 마이너 GC 과정을 살펴보자.

V8 마이너 GC에 대한 슬라이드

  1. 시작할 때 To 영역에 객체가 이미 있다고 가정해보자(01~06 블록은 사용된 메모리로 표시됨).
  2. 새 객체(07 블록)를 생성한다.
  3. V8은 To 영역에서 필요한 메모리를 가져오려고 시도하지만, 객체들을 모두 수용할 수 없기 때문에 V8은 마이너 GC를 발생시킨다.
  4. 마이너 GC는 객체들을 To 영역에서 From 영역으로 이동시킨다. 이제 모든 객체는 From 영역에 있고 To 영역은 비워진다.
  5. 마이너 GC는 스택 포인터(GC 루트)부터 From 영역까지 객체 그래프를 재귀적으로 순회하면서 메모리를 사용한 객체들을 찾는다. 이 객체들은 To 영역의 페이지로 이동된다. 이 객체들을 참조하는 객체들은 To 영역의 페이지로 이동되고 포인터들은 갱신된다. From 영역의 모든 객체들을 찾을 때까지 이 과정이 반복된다. 마지막 객체까지 찾으면 To 영역은 자동으로 압축되어 조각화를 줄인다.
  6. 이제 From 영역에 남아있는 객체는 가비지이므로 마이너 GC는 From 영역을 비운다.
  7. 새 객체는 To 영역 메모리에 할당된다.
  8. 어느 정도 시간이 지나 "To 영역"에 더 많은 객체가 생겼다고 가정해보자(07~09 블록은 사용된 메모리로 표시됨).
  9. 애플리케이션이 새 객체(10 블록)을 생성한다.
  10. V8은 To 영역에서 필요한 메모리를 가져오려고 시도하지만, 객체들을 모두 수용할 수 없기 때문에 V8은 두 번째 마이너 GC를 발생시킨다.
  11. 위 과정은 반복되고 두 번째 마이너 GC에서 생존한 객체들은 "Old 영역"으로 이동한다. 첫 번째 마이너 GC에서 생존한 객체들은 "To 영역"으로 이동하고 남아있는 객체들은 "From 영역"에서 제거된다.
  12. 새 객체는 "To 영역"에 할당된다.

마이너 GC가 Young 제너레이션에서 공간을 회수하고 깨끗하게 유지하는 방법에 대해 살펴보았다. 마이너 GC는 stop-the-world 프로세스지만, 굉장히 빠르고 효율적이므로 무시할 수 있다. 이 프로세스는 "New 영역"의 참조를 위해 "Old 영역"에서 객체들을 찾지 않기 때문에, "Old 영역"에서 "New 영역"까지 모든 포인터의 레지스터를 사용한다. 이것은 Write barrier라고 하는 프로세스를 통해 저장 버퍼에 기록된다.

메이저 GC

메이저 GC는 Old 제너레이션 영역을 작고 깨끗하게 유지시킨다. 메이저 CG는 V8에서 Old 영역의 메모리가 충분하지 않다고 판단될 때 발생한다. Old 영역은 동적으로 계산된 크기에 기반하며, 마이너 GC 주기에서 채워진다. 스캐벤저 알고리즘은 작은 데이터 크기에는 적합하지만 Old 영역과 같이 큰 힙 메모리에는 적합하지 않다. 메모리 오버헤드가 있기 때문에 메이저 GC는 Mark-Sweep-Compact 알고리즘을 사용하여 처리된다. 메이저 GC는 Tri-color(흰색-회색-검은색) 마킹 시스템을 사용한다. 따라서 메이저 GC는 세 단계의 프로세스를 거치며, 세 번째 단계는 조각화 휴리스틱(fragmentation heuristic)에 따라 실행된다.

  • 마킹(Marking): 두 알고리즘의 공통적인 첫 번째 단계로, 가비지 컬렉터가 어떤 객체가 사용중인지 식별한다. 사용중이거나 GC 루트(스택 포인터)에 재귀적으로 도달할 수 있는 객체들은 활성 상태로 표시된다. 마킹은 기술적으로 힙 메모리를 방향 그래프(directed graph)로 간주해 깊이 우선 탐색(depth first search)를 수행한다.
  • 스위핑(Sweeping): 가비지 컬렉터가 힙 메모리를 순회하면서 활성 상태로 표시되지 않은 객체들의 메모리 주소를 기록한다. 이 공간은 이제 사용 가능한 목록(free-list)에서 사용 가능하다고 표시되며 다른 객체들을 저장하는 데 사용될 수 있다.
  • 압축(Compacting): 스위핑이 일어난 다음, 필요하다면 모든 활성 상태의 객체들이 함께 이동될 것이다. 압축 단계는 조각화를 줄이고 새 객체들에 대한 메모리 할당 성능을 증가시킨다.

또한 메이저 GC는 GC를 수행하는 동안 애플리케이션 실행을 멈추므로 stop-the-world GC라고도 한다. 이를 피하기 위해 V8에서는 다음과 같은 기술을 사용한다.

  • 인크리멘탈 GC(Incremental GC): GC는 여러 개의 인크리멘탈 단계로 수행된다.
  • 동시 마킹(Concurrent marking): 마킹은 자바스크립트 메인 스레드에 영향을 주지 않고 다중 헬프 스레드를 사용해 동시에 수행된다. Write barrier는 헬퍼들이 동시에 마킹하는 동안 자바스크립트가 생성한 객체 간 참조를 추적하는 데 사용된다.
  • 동시 스위핑/압축(Concurrent sweeping/compacting): 스위핑과 압축은 자바스크립트 메인 스레드에 영향을 주지 않고 헬퍼 스레드에서 동시에 수행된다.
  • 레이지 스위핑(Lazy sweeping): 레이지 스위핑은 메모리가 필요할 때까지 페이지에서 가비지 삭제를 지연시킨다.

메이저 GC 과정을 살펴보자.

  1. 많은 마이너 GC 주기를 거치고 Old 영역이 거의 다 찼으며 V8이 "메이저 GC"를 발생시킨다고 가정해보자.
  2. 메이저 GC는 스택 포인터에서 시작해 재귀적으로 객체 그래프를 순회하면서, Old 영역 내 메모리를 사용한 객체와 남아있는 객체를 가비지로 표시한다.
  3. 동시 마킹이 완료되거나 메모리 제한에 도달하면 GC는 메인 스레드를 사용하여 마킹의 마지막 단계를 수행한다. 이 때 일시 정지 시간이 발생한다.
  4. 메이저 GC는 동시 스위프 스레드를 사용해 모든 참조 없는 객체들의 메모리를 사용 가능한 상태로 표시한다. 또한 조각화를 피하기 위해 관련 메모리 블록을 동일한 페이지로 이동하도록 병렬 압축 작업도 발생한다. 포인터들은 이 세 단계를 통해 갱신된다.

결론

이번 글에서는 V8 엔진의 메모리 구조와 관리 방법에 대해서 소개했다. v8.dev에서 더 많은 고급 개념들을 배울 수 있다. 그러나 대부분의 자바스크립트/웹 어셈블리 개발자에게는 이 정도의 정보만으로 충분할 것이다. 오늘 다룬 내용을 숙지하여 메모리 누수 문제를 예방하고 더 나은 프로그램을 작성하는 데 도움이 되기를 바란다.

참조

당신이 V8 엔진 내부에 대해 재미를 느꼈으면 좋겠고 시리즈의 다음 글을 계속 지켜봐 주길 바란다.

이번 글이 좋았다면 추천 또는 코멘트를 남겨달라.

트위터링크드 인에서 필자를 팔로우할 수 있다.

이 글은 2020년 1월 26일에 블로그https://dev.to에 게시되었다.