자바스크립트와 V8 엔진의 메모리 관리 프로세스 [ko]

Minjae Lee
네이버 플레이스 개발 블로그
30 min readSep 28, 2020

안녕하세요? 네이버 Glace 예약&주문 개발팀 소속 이민재입니다.

네이버 주문 개발을 재작년 여름에 처음 시작했다고 믿기 힘들 정도로 새로운 기능과 컨텐츠들이 매일같이 탄생하고 있는데요. 개발시 중요하지만 쉽게 잊혀질 수 있는 메모리 관리에 대해 소개해드립니다 😆

Image 0. Memory Leak source: https://westergaard.eu/wp-content/uploads/2017/07/edorasware-illustration-memory-leak-horizontal.jpg

Table of Contents

1. Introduction
2. Random-access memory
3. Memory Management
4. Memory Leak
5. V8 엔진의 메모리 관리 시각화
6. 크롬 메모리 프로파일링

Introduction

웹 서핑을 하거나 애플리케이션을 사용할때에, 메모리 관리가 잘 되지 않아 속도가 저하된다거나 프로그램이 갑자기 종료(OutOfMemory 에러)되는 케이스가 존재합니다. 퍼포먼스가 서서히 낮아지는 형태의 메모리 누수 (memory leak-웹상에서 트위터를 사용해보시면 금방 경험해보실 수 있습니다), 퍼포먼스가 일정하게 낮은 형태의 memory bloat 가 일반적으로 메모리 관리가 성공적으로 되지 않았을 시에 경험할 수 있는 현상입니다. 이를 방지하기 위해 메모리가 어떻게 관리되고 있는지, 메모리 누수를 어떻게 발견할 수 있는지 알아보았습니다.

Image 1. 리그오브레전드 클라이언트 메모리 이슈로 인한 갑작스런 클라이언트 셧다운 (ㅜㅜ) 이후 안내 메세지 승급전만은 아니였기를…

Random-access memory

소프트웨어는
1. 바이트 코드를 로드하기위해
2. 실행되는 프로그램이 사용하는 데이터와 데이터 구조를 임시 저장하기 위해
3. 런타임 시스템을 로드하기 위해
컴퓨터의 Random-access memory의 접근을 필요로하는데, 일반적으로 메모리는 stack메모리와 heap 메모리로 분류됩니다.

Stack Memory

스택(stack)메모리는 LIFO(Last-In-First-Out) or 후입선출 방식으로 정적 메모리 할당시 사용됩니다.

Image 2. LIFO 출처: http://bluegalaxy.info/codewalk/wp-content/uploads/2018/08/stack.jpg

데이터 서치를 하지 않기때문에 빠른 속도로 데이터를 저장하고 회수할 수 있는 것이 최대 장점이며, 저장되는 데이터의 크기가 유한(finite)하고 정적(static)입니다 (데이터의 크기가 컴파일타임에 계산됩니다).

함수의 호출 정보 또한 스택 프레임으로 저장이되는데요. 각 프레임은 함수를 호출시 필요로하는 데이터를 저장한 한 블록의 공간을 의미합니다. 해당 함수가 새 변수를 생성하면, 그 데이터는 스택 맨 위의 블록에 저장됩니다. 함수 호출이 종료되면 스택 맨 위 블록이 제거됩니다.

멀티 스레딩이 가능한 어플리케이션은 스레드 수 만큼 스택을 여러개 가질 수 있습니다. 일반적으로 스택에 저장되는 데이터 종류로는 지역변수(local variables), 포인터, 함수 프레임이 있습니다. 거의 모든 프로그래밍 언어에는 스택에 저장될 수 있는 데이터의 사이즈에 상한이 있으며, 스택의 사이즈를 초과하는 경우에 StackOverflowError 가 발생합니다.

Heap Memory

힙(heap)메모리는 동적 메모리 할당에 사용되며, 포인터를 사용하여 데이터 위치를 저장합니다. 스택과 비교하여 데이터를 처리하고 접근하는 속도는 느리지만, 더 큰 용량의 데이터를 저장할 수 있습니다.

하지만 할당하고자 하는 메모리의 사이즈가 동적으로 변동 가능하기에 컴파일타임에 메모리 사이즈를 정확히 예측할 수 없습니다. 힙 메모리는 쓰레드간 공유가 됩니다. 힙 메모리의 동적 성질때문에 메모리 관리가 더 복잡해집니다.

일반적으로 힙에 저장되는 데이터 종류로는 전역 변수 (global variables), 참조 타입(objects, strings, map와 같은 reference types)이 있습니다. 할당된 힙 공간을 초과하는 메모리를 사용하려고 하면 OutOfMemory 에러가 발생합니다. 일반적으로 힙 메모리 용량에 제한은 따로 없습니다 (메모리 총 용량을 초과할 수는 없겠죠).

RAM의 용량은 무한하지 않습니다. 프로그램이 메모리를 더 이상 사용하지 않을 시에는 해당 메모리를 해제(free)해주어야합니다.

Memory Management

메모리를 관리하는 방법은 여러가지가 존재합니다.

C와 같은 언어는 디폴트로 메모리를 관리해주지 않습니다. Malloc, realloc, calloc, free 와 같은 함수들로 메모리를 직접 할당하고 해제하며 관리해주어야합니다.

메모리의 라이프사이클은 다음과 같습니다

할당 => 사용 => 해제

  • 할당: 운영체제가 메모리를 할당합니다. 자바스크립트의 경우 자동으로 할당합니다.(숫자,문자,객체, 함수 등)
var n = 123
var s = 'minjae'
var o = {a:1, b:2}
function returnArgument (a) { return a }
elementA.addEventListner('click',()=>console.log('clicked'))
var date = new Date()
var elementDiv = document.createElement('div')
  • 사용: 메모리가 할당된 변수가 실질적으로 사용됩니다. (읽기,쓰기 작업)
  • 해제: 프로그램에서 필요하지 않는 메모리를 반환하여 다시 사용할 수 있게끔 해주는 단계입니다.

자바스크립트에서의 메모리 사용은 메모리를 읽거나 쓰는 행위를 의미하며, 객체의 속성이나 변수값을 사용하거나, 함수에 인수를 넘겨줄 때에도 발생합니다.

메모리가 더 이상 필요하지 않을때 해제해주어야하지만, 언제 해당 메모리가 필요없는지 알아내는 것은 알고리즘으로 풀기 어려운 문제입니다.

Garbage Collection

가비지 컬렉션 (garbage collection) or GC 는 힙 메모리에 할당되어있는 객체, 문자열 등의 데이터가 더 이상 사용되지 않을 때 '자동으로' 메모리를 해제하여 반환하도록 관리하는, 현대 언어들(JVM, Javascript, C#, etc.)이 사용하는 가장 일반적인 메모리 관리 방식입니다.

Mark & Sweep GC

마크 앤 스윕 GC는 2단계 가비지 컬렉션 알고리즘입니다.

  1. Mark: GC 루트 목록을 생성합니다. 자바스크립트의 경우 window객체가 대표적인 루트입니다. 루트란 코드에서 참조되는 전역 변수를 뜻합니다. 모든 루트들은 acitve 혹은 alive(가비지가 아님)로 표시되며, 이들의 자식들 또한 재귀적(recursively)으로 동일하게 처리됩니다. 즉, 루트에서 접근 가능하다면 가비지가 아니라고 판단됩니다. 실행중인 쓰레드, 정적 변수, 로컬 변수 또한 GC 루트가 될 수 있습니다.
  2. Sweep: active로 표기되지 않은 메모리를 모두 가비지로 간주하여 반환합니다.

정리하자면, 루트에서 접근 가능한 메모리는 active, 나머지는 가비지를 뜻하며, active root 에 원치 않는 참조가 포함되어있다면, 필요 없는 메모리가 할당된 만큼 메모리 누수가 발생함을 뜻합니다.

Reference Counting

레퍼런스 카운팅 (reference counting)은 하나의 가비지 컬렉션 방식입니다.

이름과 같이 객체를 참조하는 변수의 수를 추적하는 방법이며, 레퍼런스가 하나 증가/복사 될 때마다 카운트가 1만큼 증가합니다. 즉, 레퍼런스 카운트가 0이 되는 순간 객체는 더이상 참조되고 있지 않기때문에 쓸모가 없다고 판단되어 메모리가 반환됩니다.

레퍼런스 카운팅은 매우 심플하고 빠른 가비지 컬렉션 방식이지만, cyclic/circular reference 를 처리하지 못하는 등의 단점을 가지고 있습니다. (레퍼런스 카운팅 가비지 컬렉션을 사용 시 이를 해결하기 위한 대안을 필요로합니다).

Image3. Reference Counting 출처: https://en.wikipedia.org/wiki/Circular_reference

이들 외에도 Resources Acquisition Is Initialization (RAII)를 기반한 객체의 라이프 사이클을 통해 메모리를 관리하는 방법 (생성 or initialization 시 constructor가 메모리 할당을 해주며, 객체 파괴시 destructor에서 메모리를 반환하는 방식)이나, 자동 레퍼런스 카운팅 (Automatic Reference Counting)과 같은 런타임이 아닌 컴파일 타임에 레퍼런스 카운팅을 하여 메모리 할당 및 반환을 계획하는 방식같은 다양한 메모리 관리 방법들이 존재합니다.

Memory Leak

이 섹션에서는 자바스크립트 언어 내에서 발생할 수 있는 일반적인 메모리 누수 예시들 4가지에 대해 다뤄보도록 하겠습니다.

전역변수 (global variables)

선언되지 않는 변수를 참조한다면 글로벌 객체에 의도하지 않은 새로운 변수를 생성할 수 있습니다. 브라우저상이라면, 전역 객체는 ‘window’가 됩니다.

function foo(){
name = 'minjae'
}
function bar(){
window.name = 'minjae'
}

foo함수내의 스콥을 가져야할 name변수이지만, 선언을 실수로 하지 않은 경우에, foo함수는 bar함수와 같이 동작하며 전역 변수를 생성하게됩니다.

아래 this를 사용할 시 thiswindow 객체를 포인트하게 된다면 동일한 원리로 글로벌 변수가 생성될 수 있습니다.

fucntion foo(){
this.name = 'minjae'
}
foo()

예제 수준의 전역 변수에 할당되는 메모리 크기 정도로는 사실 크게 문제가 될 일이 없겠지만, 사이즈가 큰 전역 변수를 다룰시에는, 데이터가 필요 없어지는 시점에 null처리하거나 재할당하여 메모리를 해제해주어야합니다.

여러군데에서 반복적으로 사용될 수 있는 데이터는 캐시 (cache) 로 저장하고는 하는데, 특히 전역으로 사용되면서 메모리 사이즈를 많이 차지할 수 있는 캐시 관리에 실패한다면 심각한 메모리 문제가 발생할 수 있습니다.

타이머/콜백

타이머 내의 객체는 해당하는 코드를 미래에 실행하기 위해서 레퍼런스를 저장하게됩니다.
아래의 코드를 보시면

var myObj = {
callMeMaybe: function () {
var myRef = this
var val = setTimeout(function () {
console.log('Time is running out!')
myRef.callMeMaybe()
}, 1000);
}
}

함수가 실행되면

myObj.callMeMaybe()

1초마다 ‘Time is running out!’ 이라는 문구가 콘솔에 기록됩니다.

아래의 코드를 실행하여도 위의 타이머 함수는 멈추지 않습니다.

myObj = null

setTimeout의 클로저가 myRef를 통해 myObj의 레퍼런스를 필요로하기 때문이죠.

Image 5. setTimer 함수 실행 출처: https://medium.com/outsystems-experts/beyond-memory-leaks-in-javascript-d27fd48ae67e

인터벌 함수가 실행되고 있는 이상 연관된 레퍼런스들의 메모리는 묶여있게 됩니다.

클로저 (closure)

위 타이머 함수에서 언급한 클로저 사용에 의한 메모리 누수 또한 흔한 케이스중 하나입니다. 아래 예제를 살펴보겠습니다 출처:Meteor Blog
(본 예제에 대한 자세한 설명은 링크의 블로그를 참고해주세요)

var theThing = null;
var replaceThing = function () {
var originalThing = theThing;
var unused = function () {
if (originalThing)
console.log("hi");
};
theThing = {
longStr: new Array(1000000).join('*'),
someMethod: function () {
console.log(someMessage);
}
};
};
setInterval(replaceThing, 1000);

Step by step process:

  • setInterval함수로 인하여 replaceThing함수가 1초마다 실행됩니다
  • theThing은 매우 큰 어레이 longStr와 클로저 someMethod에 대한 레퍼런스를 가지게 됩니다.
  • unusedoriginalThing에 레퍼런스를 가지고 있는 클로저가 됩니다.
  • someMethod가 가지고 있는 스콥은 unused의 스콥과 공유가 되고 (동일한 lexical environment), unused는 사용이 되지도 않았지만, 그가 가지고 있는 originalThing의 레퍼런스는 active, 즉 메모리가 할당된 상태로 유지되게됩니다.
  • 실제로 위의 코드가 실행되면 메모리 사용량이 점진적으로 증가하는 모습을 볼 수 있습니다. (뒷 내용의 힙 스냅샷 부분 참고)

위 예제의 메모리 누수를 해결하기 위해서는, 객체를 immutable copy하여 로컬 변수가 의도치 않은 스콥의 객체를 레퍼런스 하지 않게 하거나, 아래와 같이 reassign 해주어야합니다.

originalThing=null

복수의 레퍼런스

하나의 객체가 여러개의 객체로부터 레퍼런스되고있었지만, 사용 후 전부 메모리 반환 처리를 하지 않았다면, 매모리 누수가 발생할 수 있습니다.

예를 들어 button이라는 DOM 엘리먼트를 조작하기 위해 임시 변수 temps 에 저장을 합니다.

var temps = {
button: document.getElementyById('button'),
...,
}

temps 를 사용한 이후에 버튼을 조작할 필요가 없어져서 DOM body에서 제거합니다.

document.body.removeChild(document.getElementyById('button'))

이 시점에서 button이라는 객체는 돔 트리에서 제거가 되었지만, temps 내에서 아직 레퍼런스를 가지고 있기 때문에, 해당 메모리는 GC에 의해 반환되지 않습니다.

개발을 하면서 위와같은 메모리 누수가 실수로 발생하기 쉬운데요. 방지 하기 위해서 객체들을 필요 기간 이상으로 가지고 있지 않도록 주의하는 것이 좋겠습니다. 변수를 알맞은 스코프 이내에서만 사용하고 항상 컴포넌트화를 지향하며, 글로벌 함수를 라이프사이클동안 관리하는 것 보다 지정된 스코브 내의 로컬 변수를 사용하여 메모리 관리를 위해 해당 변수를 트랙킹할 필요 자체를 없애는 것이 더 좋습니다. 비슷한 맥락으로 필요가 없어진 이벤트리스너는 해제해주고, 로컬 캐시에 데이터를 저장하는 경우, 재사용할 가능성이 적거나 없는 캐시는 aging mechanism 을 통해 주기적으로 삭제해줄 필요가 있습니다.

V8 엔진의 메모리 관리 시각화

크롬 브라우저, Node.js등이 사용하는 V8 엔진의 메모리 구조와 가비지 컬렉션 과정에 대해 알아보도록 하겠습니다.

자바스크립트는 싱글-스레드를 사용합니다. 실행되고 있는 프로그램은 항상 V8 과정에서 Resident Set 이라는 메모리가 할당됩니다.

Image 6. Resident Set 출처:https://dev.to/deepu105/visualizing-memory-management-in-v8-engine-javascript-nodejs-deno-webassembly-105p

V8 힙 메모리

V8 엔진이 동적으로 데이터를 저장하는 장소이며, 가장 큰 메모리가 할당되어있고, GC가 발생하는 장소이기도합니다. GC는 young & old 공간들을 관리하며 가비지 컬렉션 과정이 이루어지는데 용어에 대한 설명은 아래와 같습니다.

  • NEW SPACE: (young generation) 새롭게 생성된 객체들이 존재하는 공간이며, 이들 중 대부분은 수명이 짧습니다. 이 저장공간은 비교적으로 작으며, 두개의 semi-space 로 나뉘어져있습니다. 이 공간은 뒤에 설명될 스캐빈저(minor GC)가비지 컬렉션에 의해 관리됩니다.
  • OLD SPACE: (old generation) NEW SPACE 에서 두개의 minor GC 사이클을 살아남은 객체들이 이동하는 공간입니다. major GC(mark-sweep & mark-compact)에 의해 관리되며 2개의 공간으로 나뉘어집니다: 1. OLD POINTER SPACE: 다른 객체를 포인트하는 객체들이 존재하는 공간 2. OLD DATA SPACE: 데이터만 가지고 있는 객체들이 존재하는 공간 (string, boxed numbers, etc.)
  • LARGE OBJECT SPACE: 다른 공간에 담을 수 없을정도로 큰 사이즈의 객체들을 저장하는 공간입니다.각 객체는 mmap할당된 메모리 공간을 가지며, GC에 의해 관리될 수 없습니다)
  • CODE-SPACE: 저스트인타임 (JIT) 컴파일러가 컴파일된 코드 블록들을 저장하는 공간입니다.
  • Cell SPACE, PROPERTY CELL SPACE, MAP SPACE: 각각 cells, propertyCells, maps를 저장하는 공간이며, 이 공간에 저장되는 객체들은 전부 같은 사이즈이며 레퍼런스 하는 객체의 형식에 대한 제약이 있습니다. 이는 객체의 분기 및 저장 절차에 도움이 됩니다.

각각의 공간 SPACE는 (LARGE OBJECT SPACE 제외) OS가 mmap할당한 여러개의 1MB크기의 페이지( page)들로 이루어져있습니다.

V8 스택 메모리

각각의 V8 과정은 1개의 스택 공간을 가지고 있으며, 이 공간에 메소드/함수 프레임(methods/ function frames), 원시 값들(primitive values), 포인터(poitners)들이 저장됩니다.

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

예재를 통해 프로그램이 실행될때 메모리가 할당되는 과정을 알아보도록 하겠습니다 (출처: Dev Coummunity)

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);
Image 7. V8 Memory usage step by step 출처:https://dev.to/deepu105/visualizing-memory-management-in-v8-engine-javascript-nodejs-deno-webassembly-105p

링크 를 클릭하시면 단계별로 시각화된 자료를 참고하실 수 있습니다

  1. global scope은 스택의 global frame내에 저장됩니다.
  2. 모든 함수 호출은 스택에 frame-block형태로 추가됩니다.
  3. 모든 지역 변수들 (arguments포함) 및 리턴값들은 이 function frame-block내에 저장됩니다.
  4. intstring같은 원시(primitive) 타입의 값들은 스택에 그대로 저장됩니다.
  5. Employee와 같은 객체들과 함수들은 힙 공간에 저장되며, 스택으로부터 스택 포인터를 통해 레퍼런스됩니다. (자바스크립트에서 함수는 객체와 같이 취급됩니다)
  6. 함수가 리턴을 하면, 스택에서 제거됩니다.
  7. 메인 프로세스가 종료되면, 힙에 있는 객체들은 스택으로부터 레퍼런스되어있지 않고 orphan상태가 됩니다.
  8. 명시적 복사를 하지 않는 이상 객체 레퍼런스는 레퍼런스 포인팅를 통해 이루어집니다.

스택 메모리는 V8 엔진에 의해서라기 보다는 OS에 의해 자동으로 관리되기때문에 이 시점에서 스택에 메모리에 대해서는 크게 신경 쓸 필요가 없습니다.

힙 메모리는 OS가 자동적으로 관리하지않으며, 큰 메모리 저장 공간을 동적으로 사용하기때문에, 자칫하면 메모리 사용 공간이 급격하게 고갈되어 프로그램 동작에 영향을 줄 수 있습니다. 또한 시간에 걸쳐서 memory fragmentation 으로 인하여 실제 사용할 수 있는 메모리 공간은 충분하지만 할당 불가능한 상태에 이르게 될 수 있습니다. 이 때, 가비지 컬렉션이 작동합니다.

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

V8 엔진의 가비지 컬렉션 절차는 객체의 세대(generation)을 관리합니다. 힙 메모리에 존재하는 객체들은 나이(age)별로 그룹되며, 각각 알맞는 단계에서 삭제되거나 이동됩니다. V8 엔진이 사용하는 가비지 컬렉션 절차에는 2개의 단계와 3개의 다른 알고리즘이 존재합니다.

Minor GC (스캐빈저)

Minor GC 는 새롭게 생성된 객체들을 관리하여 new space 공간을 깨끗하게 유지시켜줍니다. 이 공간이 할당되는 객체들은 비교적으로 사이즈가 작으며 (1~8MB) 단순한 포인터를 통해 할당이 이루어지기 때문에, 할당 절차의 비용이 작습니다. new space공간이 다 차서 할당 포인터가 더 이상 새로운 객체를 포인트할 공간이 없어지게되면, minor GC가 발동됩니다. 이 과정은 스캐빈저 가비지 컬렉션이라고도 불리며 Cheney's 알고리즘 의 원리로 작동합니다. 스캐빈저 GC 는 자주 발생하며, 병렬의 헬퍼 스레드들을 사용하기때문에 아주 빠릅니다.

Image 8. Minor GC process step-by-step 출처:https://dev.to/deepu105/visualizing-memory-management-in-v8-engine-javascript-nodejs-deno-webassembly-105p

링크 를 클릭하시면 단계별로 시각화된 자료를 참고하실 수 있습니다

  • new space는 2개의 같은 사이즈의 semi space로 나뉘어져있습니다: to-space & from-space
  • 대부분의 메모리 할당은 from space에서 이루어집니다. (예외도 있습니다. executable codes같은 경우는 항상 old space의 메모리가 할당됩니다)
  • from-space공간이 다 차게 되면, minor GC가 발동하며 그 절차는 아래와 같습니다.
  1. from space에 객체들이 존재하고 있습니다 (01~06 블록들)
  2. 새로운 객체 07이 생성됩니다.
  3. from space에 더 이상 할당할 수 있는 공간이 없는 상태에서 V8 엔진이 새로운 객체를 추가하려고 하면, minor GC가 발동합니다.
  4. minor GC는 재귀적으로 from space의 객체 그래프를 훑으면서 active(사용되고 있는 메모리)를 찾아내어 포인터를 수정하여 to space로 이동시킵니다.
  5. minor GCfrom space를 비워버립니다. 이 단계에서 from space에 남아있는 객체들은 가비지로 간주합니다.
  6. minor GCfrom spaceto space를 스왑하여, 모든 객체들은 from space에 존재하고, to space는 빈 공간이 됩니다.
  7. 새로 생성된 객체 07은 이제 공간이 여유로운from space에 추가될 수 있습니다.
  8. 시간이 지나고, from-space에 추가적으로 객체들이 저장되었다고 가정합니다. (07~09 블록들)
  9. 새로운 오브젝트 10이 생성됩니다.
  10. 3번 단계와 동일하게 V8 엔진이 from space에서 메모리를 확보하려고 하지만, 여유공간이 하나도 없기때문에 두번째 minor GC가 발동됩니다.
  11. 위 절차가 반복되어 두번쨰 GC에서 살아남은 active객체들은 old space로 이동합니다. minor GC를 한 사이클만 살아남은 객체들은 new spaceto space로 이동되고, active로 표기되지 못한 객체들은 가비지로 간주되어 삭제됩니다.
  12. minor GC는 다시 한번 to spacefrom space를 스왑하고, to space는 빈 공간이 되고, from space에 모든 객체들이 존재하게됩니다.
  13. 새로이 생성되는 객체는 from space의 메모리가 할당됩니다.

위 과정을 통해 V8 엔진의 가비지 컬렉터가 young generation의 메모리 공간을 회수하는 절차를 볼 수 있었습니다.

이 절차는 stop-the-world 방식이지만, 아주 빠르고 효과적이기문에, 일반적이 환경에서 부작용은 무시해도 될 정도입니다.

Major GC

major GCold space를 관리하며, V8 엔진이 old space 에 여유 저장 공간이 없다고 판단(여러번의 minor GC 사이클을 통해 동적으로 계산됩니다)때에 발동됩니다.

스캐빈저 알고리즘은 작은 데이터들을 관리하는데 유용하지만, 비교적 큰 사이즈의 힙 메모리 관리는 잘 수행해내지 못합니다. 따라서 old space에서는 Mark-Sweep-Compact알고리즘을 사용합니다. TRI-COLOR(white-grey-black) 혹은 삼색 마킹 시스템 이라고도 불리우는데, 이 GC는 아래의 3단계 절차를 따릅니다.

Image 9. TRI-Color marking
  1. Marking: 첫번째 스텝이며, active한 객체들과 그렇지 않은 객체들을 식별합니다. GC Roots(스택 포인터)로부터 접근 가능한 오브젝트들은 재귀적으로 active/alive 마킹되며, 깊이 우선 탐색 (depth first search) 알고리즘입니다.
  2. Sweeping: GC가 힙 메모리를 스캔하면서 active로 마킹되지 않은 객체들의 메모리 주소를 저장합니다. 이 공간들은 여유 공간으로 간주되어, 다른 객체 정보를 저장할 수 있게 됩니다.
  3. Compacting: Sweeping이후에 필요하다면, 살아남은 객체들이 되도록 같은 공간에 존재할 수 있도록 객체들의 주소를 이동시킵니다. 이 절차는 위에 멘션한 fragmentation을 줄이고, 새로운 객체에 메모리가 할당되는 절차의 퍼포먼스를 향상시킬 수 있습니다.

위 절차는 GC가 이루어지는 동안에 일시적인 pause time을 필요로합니다. 그렇기때문에 stop-the-world GC라고도 불리며, 프로그램이 일시적으로 멈추는 현상에 대한 부작용을 최소화하기위해 V8 엔진은 아래와 같은 테크닉을 사용하기도 합니다.

  • Incremental GC: 가비지 컬렉션이 한번에 이루어지지 않고, 여러번의 작은 단계들을 통해 이루어집니다.
  • Concurrent marking: 마킹이 여러개의 헬퍼 스레들을 사용하하여, 자바스크립트의 메인 스레드에 영향을 끼치지 않고, 동시에 병렬적으로 진행됩니다.
  • Concurrent sweeping/compacting: 스위핑, 컴팩팅 프로세스가 헬퍼 스레드에서 진행되며, 메인 자바스크립트 스레드에 영향을 끼치지 않습니다.
  • Lazy sweeping: 사용할 메모리가 없어서 당장 메모리를 삭제할 필요한 순간까지 가비지 컬렉션을 연기합니다.

이 글을 통해 실제 V8엔진의 메모리 구조와 관리 방식에 대해 전부 이해할 수 없지만, 메모리 관리 절차에 대한 전반적인 이해를 하는데에 도움이 될 수 있다고 생각합니다.

크롬 메모리 프로파일링

메모리 누수가 언제, 어디에서, 어느 정도로 발생하고 있는지 확인할 수 있는 방법에 대해 알아보도록 하겠습니다.

힙 스냅샷

크롬 개발자도구 > 메모리 탭 > Profiles > 힙 스냅샷을 선택하시면 현재의 힙 스냅샷을 찍을 수 있습니다.

위에 언급된 예제를 크롬 콘솔창에서 실행시켜보겠습니다

let theThing = null
let replaceThing = () => {
console.log('bye')
let originalThing = theThing
let unused = function () {
if (originalThing) console.log('hi')
}
theThing = {
longStr: new Array(100000000).join('*'),
someMethod: () => {
console.log('some message')
},
}
}
setInterval(replaceThing, 1000) // 10 <- 1000 조정하면 더 드라마틱합니다

총 3개의 스냅샷을 찍어보았는데, 보시는 바와 같이 concatenated strings에 할당된 메모리 용량이 지속적으로 증가하는 현상을 확인할 수 있습니다.

좌측에 스냅샷 리스트에는 해당 스냅샷의 전체 메모리가 명시되어있는데요. 2.9 MB 에서 3.0 MB 로 소폭 증가한 사실을 확인할 수 있습니다.

우측 창에서는 스냅샷에 저장되어있는 상세 정보를 확인해볼 수 있는데요. 검색 기능을 통해 원하는 객체에 대한 정보를 찾아볼 수 있습니다.

힙 스냅샷의 statistics 옵션을 선택하시면 힙 메모리를 구성하고 있는 객체들에 대한 통계를 파이 그래프로 확인할 수 있습니다.

메모리 누수가 의심된다면 3 Snapshot Technique, 혹은 snapshot comparison을 통해 하나의 스냅샷과 다른 스냅샷을 비교하는 방법을 사용할 수 있습니다.

3 Snapshot Technique의 절차는 이름이 설명하고 있는 그대로 입니다.

  1. 첫번째 힙 스냅샷을 찍습니다.
  2. 임의의 동작을 합니다.
  3. 두번째 힙 스냅샷을 찍습니다.
  4. 2번 스텝을 반복합니다.
  5. 3번째 스냅샷을 찍습니다.
  6. 3번째 스냅샷을 기준으로 첫번째 두번째 스냅샷과의 비교를 합니다.

본문의 예제와 같이 메모리 누수가 있다면, 같은 동작으로 인해 쌓여가는 데이터를 발견해보실 수 있을 것 입니다.

위 스크린샷에서와 같이, Snapshot 2 가 선택된 상태에서Comparison 옵션을 선택하시고 비교 대상을 Snapshot 1 으로 설정해주면 추가되거나, 삭제된 객체, 메모리 사용량에 대한 변화 등의 정보를 확인할 수 있습니다.

본문에서는 힙 스냅샷에 대해 아주 간략하게 소개만 해 드렸는데요. 힙 스냅샷의 자세한 분석에 대해 알아보시길 원하신다면 링크 를 클릭해주세요.

타임라인

메모리 누수가 발생하고 있는지 가장 직관적으로 알아볼 수 있는 툴이 바로 타임라인 그래프입니다.

메모리탭 > Profiles > Allocation instrumentation on timeline 을 선택하시면 일정 기간에 대한 프로세스를 기록할 수 있습니다.

보이는 바와 같이 1초에 한번 어레이를 위한 메모리 할당이 이루어지는 것을 확인할 수 있습니다.

Performance 탭 > Record 버튼을 클릭하시면 아래와 같은 메모리 타임라인 그래프를 보실 수 있습니다.

약 5초간 진행된 타임라인의 Event Log 에서는 1초마다 실행된 setInterval 함수가 의도한 바와 같이 진행된 사실을 확인할 수 있고, JS Heap 메모리 사용량이 1초에 한번씩 증가하는 모습을 쉽게 볼 수 있습니다.

타임라인에서 메모리 누수가 의심된다면, 위에 설명드린 힙 스냅샷을 찍어서 더 자세하게 확인해볼 수 있습니다.

크롬 작업관리자

크롬 옵션 > More Tools > Task Manger를 선택하시면 현재 실행되고 있는 크롬 브라우저 창들의 메모리 상태를 확인하실 수 있습니다.

React State Update Memory Leak?

리액트를 사용하여 개발하다가 한번씩은 보셨을만한 경고 메세지입니다. 컴포넌트가 언마운트된 시점에 스테이트값을 업데이트하려고 시도하였기때문에 발생하는데요.

텍스트에 설명되어있는 바와 같이, no-op (no operation)즉, 실제로 실행되지 않는 스테이트 업데이트이기때문에 기능적인 문제는 발생하지 않을 것 입니다.

하지만, 필요 없는 스테이트 업데이트에 대한 정보가 메모리 공간을 차지할 수 있기때문에, 컴포넌트 언마운트 시점 이후에 발생하는 스테이트 업데이트를 제거해 줄 필요가 있습니다.

리액트 훅을 사용하신다면 useEffect훅 내에서 컴포넌트 마운트 시점과 return문의 클린업을 통해 언마운트 시점을 구분하여 코드를 정리하실 수 있습니다.

--

--