V8 엔진 내부의 메모리 관리

박세영·2022년 8월 14일
2
post-thumbnail

JavaScript V8 엔진 내부에서 메모리가 어떻게 관리되고 사용되는지 알아보자. V8 엔진은 NodeJS, Deno, Electron과 같은 런타임 및 Chrome, Chromium, Brave와 같은 웹 브라우저에서 사용된다. V8 엔진은 C++로 작성되었기 때문에 C++ 어플리케이션을 이식할 수 있다.

V8 메모리 구조

먼저 V8에서 메모리 구조가 어떻게 구성되어 있는지 살펴보자. JavaScript가 싱글 스레드 언어인만큼, V8 역시 자바스크립트 context당 하나의 프로세스를 사용한다. 단, 서비스 워커를 사용하는 경우에만 워커의 개수만큼 프로세스를 증식한다. 실행중인 프로그램은 V8 프로세스에서 할당된 일정량의 메모리로 표현되고 이를 Resident set이라고 한다. Resident set은 아래와 같은 구조로 구성된다.

힙 메모리

힙 메모리는 메모리 영역에서 가장 큰 블록이고 가비지 컬렉션이 발생하는 곳이다. 힙 메모리 전체에서 가비지 컬렉션이 동작하는 것은 아니고 Young generation과 Old generation영역에서만 실행된다. 힙 메모리는 더 세부적으로 나눌 수 있다.

  • New 영역: 새로 만들어진 모든 객체를 저장하고 이 객체들은 짧은 생명 주기를 갖는다. 이 영역은 스캐벤져라는 이름의 마이너 GC를 통해 관리한다. New 영역의 크기는 --min_semi_space_size(초기값)와 --max_semi_space_size(최대값) V8엔진의 플래그 값을 사용해 조정할 수 있다.
  • Old 영역: Old영역은 마이너 GC가 두 번 순회할 동안 “new 영역"에서 살아남은 객체들이 이동하는 영역이다. 메이저 GC(Mark-Sweep & Mark-Compact)가 관리한다.

💡 2019년도 기준으로는 GC를 개발자가 임의로 실행시킬 수 없었으나, 최신 V8엔진에서는 node --expose-gc index.js옵션을 통해 global.gc() 메서드를 노출시킬 수 있다.

스택

스택은 메모리 영역이고 V8마다 하나의 스택을 가진다. 스택은 메서드와 함수 프레임, 원시 값, 객체 포인터를 포함한 정적 데이터가 저장된다. V8의 Stack영역의 관리는 OS에 위임하여 관리한다.


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

https://speakerdeck.com/deepu105/v8-memory-usage-stack-and-heap

  • 전역 스코프는 스택의 전역 프레임 안에서 관리한다.
  • 모든 함수호출은 스택 메모리에 프레임 블록 형태로 담긴다.
  • 인수와 리턴 값을 포함한 모든 지역 변수는 함수 프레임 블록에 담겨서 스택에 올라간다.
  • Number나 String같은 원시 값들은 스택에 바로 담긴다.
  • class나 function들은 모두 Object이다. Object는 힙에서 생성되어서 stack에서 stack pointer를 통해 참조된다.
  • 함수가 리턴되면, 함수 프레임 블록은 스택에서 제거된다.
  • 객체를 명시적으로 복사하지 않는다면, 레퍼런스만 복사된다.

V8 메모리 관리: Garbage Collection

앞서 언급했듯이 스택 메모리는 V8이 아닌 OS가 관리한다. 반면에 Heap 영역은 OS가 관리하지 않는다. 더군다나 Heap 메모리는 가장 큰 메모리 영역을 갖고 있기 때문에, 프로그램이 커진 경우 ROM(run out of memory)문제가 발생할 위험 역시 커진다. 이러한 이유 때문에 가비지 컬렉션이 필요하게 된다.

힙 영역에서 포인터와 데이터를 구분하는 것은 가비지 컬렉션에서 중요한 부분이다. V8 엔진은 Tagged pointers를 사용해서, 단어의 마지막 비트를 통해 포인터와 데이터를 구분한다.

메이저 GC (Mark-Sweep-Compact Alogrithm)

  • 동작방식 시각화


메모리 누수

메모리 누수가 발생하기 쉬운 위치

전역변수

Mark-Sweep-Compact 알고리즘에서도 살펴 보았듯이, 전역객채의 Referencing chain에 해당하지 않는 값들이 garbage collection의 대상이 된다. 하지만 전역 변수와 같이 global scope에 존재하는 레퍼런스 값들은 무조건 전역객체가 레퍼런싱하게 되어, 가비지 컬렌션의 대상에 포함되지 않는다. 이러한 동작 때문에 전역변수는 메모리 누수의 위험이 있다.

다중참조

하나의 값을 여러 변수가 참조하는 경우, 하나의 레퍼런스 변수를 해제하더라도, 다른 레퍼런스 변수가 여전히 데이터를 참조하고 있기 때문에, 가비지 컬렉션의 대상이 되지 않는다. 이러한 이유 때문에, 다중참조에서

클로저

클로저를 통해 스택 영역에 담긴 지역변수는 힙 영역으로 이동된다. 따라서 클로저가 제거되기 전까지는 가비지 컬렉터의 대상이 되지 않는다.

Callback 함수

callback을 참조하는 함수가 종료되지 않는 한, callback 함수에 존재하는 모든 참조 값들은 가비지 컬렉터의 대상이 되지 않는다.

메모리 누수 방지 방법

  • 전역 변수 사용을 최소화한다.
  • 비구조화 할당을 통해 heap영역의 데이터를 stack영역에서 사용한다.

💡 Stack영역에서 데이터를 사용하면, 성능상의 이점도 존재한다.

  • 클로저를 사용하는 경우 local 변수가 heap영역으로 이동되서 관리된다. 따라서 메모리 누수의 위험을 생긴다
  • 다중 참조를 방지하기 위해 객체를 사용할 때, spread 연산자나, Object.assign() 메서드를 통해 Deep copy한다.

누수검사

브라우저에 내장된 메모리 프로파일러를 사용하면, 메모리 누수를 쉽게 확인할 수 있다. 메모리 누수는 생성만 되고 반환은 안되는 상태이기 때문에, 메모리 사용량이 상승곡선을 그리면 메모리 누수를 의심 해볼만 하다.

References

0개의 댓글