본 포스팅에서는 자바스크립트에서 메모리가 어떻게 할당되고 관리되는지 살펴볼 것이다.
시작하기에 앞서, 본 포스팅은 이 링크(번역본은 여기)에 있는 포스팅을 바탕으로 작성되었으며 추가 설명이 필요하다고 느껴진 부분은 참고 자료에 명시한 자료들을 통해 보충하였음을 알려드립니다.
시작하기에 앞서, 자바스크립트에서는 heap과 stack에 각각 어떤 데이터들이 할당되는지 알아보자.
Stack에는 정적 데이터들이 할당된다. 정적 데이터는 컴파일 타임에 엔진이 그 크기를 이미 알고 있는 데이터를 의미한다. 엔진은 프로그램을 실행하기 직전에 정적 데이터에 고정된 양의 메모리를 할당한다. 자바스크립트에서 정적 데이터에는 원시 값(strings, numbers, booleans, undefined, null)과 객체 또는 함수를 가리키는 참조(references)가 있다.
Heap에는 객체와 함수들이 저장된다. 이러한 데이터의 크기는 런타임에 알 수 있으므로 엔진은 이러고정된 양의 메모리를 할당하지 않고 필요한 만큼 할당해준다.
Heap에는 동적 데이터들이 런타임 시 메모리를 할당받아 저장된다. 만약 사용이 완료된 데이터가 메모리에서 사라지지 않고 계속 남아있다면, 언젠가는 heap 공간이 부족해질 것이다. 따라서 더 이상 사용하지 않는 메모리 공간을 해제하는 작업이 필요하다. 자바스크립트 엔진의 가비지 콜렉터(garbage collector)에서 이 작업을 담당한다.
그런데 객체가 더 이상 사용되지 않을 것이란 걸 어떻게 알 수 있을까? 가장 많이 사용되는 두 알고리즘인 참조 횟수 계산 방식(reference-counting garbage collection)과, 마크 앤 스윕(mark and sweep) 알고리즘에 대해서 알아보자.
이 방식에서는 더 이상 참조되고 있지 않은 객체를 메모리에서 해제한다. 즉, 각 객체가 참조되는 횟수를 저장하고 이 횟수가 0이 되면 메모리에서 해제하는 방식으로 동작한다.
문제점
이 방식의 문제점은 순환 참조(cyclic references)가 발생하면 참조 횟수가 0으로 떨어지지 않아 메모리에서 해제되지 않는다는 것이다.
아래의 예시 코드를 보면,
let son = {
name: 'John',
};
let dad = {
name: 'Johnson',
}
son.dad = dad;
dad.son = son;
son = null;
dad = null;
son
과 dad
가 서로 참조하고 있다. 이 때 son
, dad
를 각각 null로 바꾼다 해도 순환 참조로 인한 참조 횟수가 존재하기 때문에 메모리에서 해제되지 못한다.이 알고리즘은 root
객체로 출발해서 도달 가능한 객체를 마킹하고, 마킹되지 않는 객체를 더 이상 사용하지 않는다고 간주한다. 브라우저에서 root
는 window
객체이며, NodeJS에서는 global
객체가 된다. 좀 더 자세히는 다음과 같은 과정으로 진행된다.
root
객체로부터 출발해서 depth-first-search 방식으로 도달 가능한 모든 객체(reachable objects)를 탐색하고 마킹한다.V8 엔진에서 heap은 좀 더 세분화되어 있고, 가비지 콜렉션 절차도 이에 따라 단계적으로 진행된다. V8에서의 메모리 구조와 가비지 콜렉션에 대해 좀 더 자세히 알아보자.
V8 엔진에서 실행되는 프로그램은 V8 프로세스의 메모리 공간 일부를 할당받는데 이를 Resident Set이라고 한다. 만약 service worker를 사용한다면 워커 당 한 개의 새로운 V8 프로세스가 생성된다.
Resident Set은 다음 그림과 같이 구성되어 있다.
각 공간이 어떻게 활용되는지 가비지 콜렉션 과정을 훑어보면서 알아보자.
New space
에 할당된다. 새 객체를 위한 공간을 할당하려고 할 때마다 할당 포인터(allocation pointer)의 값이 증가한다.New space
의 끝에 도달하면 마이너 GC가 발생한다.to-space
와 from-space
라는 두 개의 semi space로 나뉘어져 있다. 메모리 할당은 from-space
에서 진행되고, from-space
가 꽉 차면 마이너 GC가 발생한다.GC roots
)로부터 출발해서 도달 가능한 객체를 to-space
로 옮긴다. 옮겨진 객체들에 의해 참조되는 다른 객체들도 모두 to-space
로 옮기고, 해당 포인터의 값도 업데이트 된다. from-space의 모든 객체들이 탐색될 때까지 진행하며, 이 과정에서 to-space
는 자동으로 압축(compact) 된다.from-space
에 있는 객체들을 해제한다.to-space
와 from-space
를 스왑한다. 객체들은 from-space
에 저장되고, to-space
는 비어있다.from-space
의 메모리 공간에서 할당한다.위 마이너 GC가 두 번 발생할 동안 살아남은 객체들은 old space
로 이동한다.
위 그림에서 볼 수 있듯 old space
는 pointer space
와 data space
로 나뉜다.
메이저 GC는 V8에서 old space
가 충분하지 않다고 판단되면 앞서 설명한 마크앤스윕(mark-and sweep) 알고리즘으로 수행한다.
이 때 어플리케이션의 실행이 멈추는 것을 극복하기 위해서 V8에서는 다음과 같은 기술을 사용한다.
참고 자료
JavaScript's Memory Management Explained
V8 엔진(자바스크립트, NodeJS, Deno, WebAssembly) 내부의 메모리 관리 시각화하기
🚀 Visualizing memory management in V8 Engine (JavaScript, NodeJS, Deno, WebAssembly)
Memory Management - JavaScript | MDN