[ 살펴보기 ] Javascript - Memory Management

[ 살펴보기 ] Javascript - Memory Management

·

6 min read

디테일한 Memory Management 내부사항은 Javascript Engine마다 차이가 존재할 수 있으며 해당 포스트는 Javascirpt Engine의 Memory Management를 대략적으로 살펴봅니다.

우리가 Javascript를 통해 특정한 연산을 할 때 연산에 필요한 데이터는 메모리에 추가되고 CPU가 메모리에 추가된 데이터를 읽어 필요한 연산을 수행한다. 그리고 연산이 완료되어 나온 결과 값 역시 메모리에 저장된다.

다음과 같은 연산을 수행하고 그 연산의 결과를 변수에 담고 있다고 가정해보자.

let test = 10 * 2;
console.log(test);

연산을 위해 10과 2라는 데이터가 메모리에 저장되고 그 결과인 20도 메모리에 저장된다. ( binary 형태로 ) 그리고 코드가 실행되어 변수에 접근할 때 Javascript Engine은 해당 변수의 실제 값이 저장된 메모리 주소를 찾아 실제 값을 찾는다.

또한 변수에 할당된 데이터의 타입( 숫자, 문자, Boolean 등 )에 따라 데이터를 저장할 때 사용되는 메모리 공간의 크기도 달라지고 값을 메모리로부터 읽어 올 때 한 번에 읽어 오는 메모리 공간의 크기, 메모리로부터 읽어 온 binary 형태의 값을 Javasciprt Engine이 해석하는 방식 또한 달라진다.

그렇다면 Javascript Engine은 코드를 실행하며 변수의 값을 참조할 때 변수의 실제 값이 어디에 저장되어 있는지 어떻게 알 수 있을까?

Symbol Table

Javascript Engine은 symbol table이라는 내부 data structure를 통해 변수 이름과 같은 식별자와 식별자에 할당된 값이 저장된 memory address, 데이터 타입 등의 정보를 관리한다. 그렇기에 Javascript Engine이 코드를 실행하며 변수의 값을 참조할 때 symbol table의 정보를 통해 변수의 실제 값이 저장된 memory address를 알 수 있다.

아래 예제를 살펴보자.

const test = 25;

Javascirpt 변수는 선언과 초기화 두 단계를 통해 이루어 지며 각각 개별적인 단계로 이루어 진다. ( var로 선언된 변수를 제외하고 )

Javasceipt Engine이 해당 코드를 실행할 때 변수의 선언 단계를 거치며 symbol table에 식별자를 추가하고 해당 변수에 대한 memory 공간을 확보하여 memory address를 식별자와 mapping한다. 선언 단계가 끝나면 초기화 단계가 이루어 지고 선언 단계에서 확보된 memory 공간에 25라는 값을 저장한다. ( binary 형태로 )

추후 Javascript Engine이 코드를 실행하며 변수의 값에 접근할 때 symbol table정보를 통해 실제 값이 저장되어 있는 memory address를 확인하여 실제 값을 읽어올 수 있다.

Stack and Heap

Javascript Engine이 우리가 작성한 code를 실행할 때 stack과 heap 두 가지 타입의 메모리 영역을 통해 code 실행에 필요한 메모리를 관리한다.

  • Stack : Javascript Engine이 코드를 실행하며 현재 필요한 local context ( 실행하는 함수에 전달된 argument, 또는 함수 내부에 선언된 변수 등 )가 저장되고 관리되는 영역이다.

  • Heap : Object, array, function과 같은 reference type 데이터가 주로 저장되고 관리되는 영역이다.

하지만 Javasceipt Engine이 primitive 데이터를 어디에 저장하는지와 관련된 사항은 Engine에 따라 다소 차이가 존재하는 듯 하고 의견도 다소 갈리는 것 같아 Javascript 언어를 기준으로 primitive 데이터가 stack 또는 heap중 어디에 저장되는가와 관련해 딱 정해진 하나의 답을 찾기는 어려워 보인다.

다만 V8 기준으로는 대부분의 경우 primitive, reference type data 모두 heap 영역에 저장되어 관리되는 것으로 보인다.

Reference

Heap 메모리 영역은 다시 new space와 old space 영역으로 나뉘어 관리되며 각 space는 각각 다른 garbage collection이 적용된다. 먼저 new space는 새로운 data가 추가되는 공간이며 다시 두 개의 영역으로 나뉜다. 편의를 위해 이 공간을 S1, S2라고 지칭하자.

Javascript Engine이 코드를 실행해 나가며 data가 S1 공간에 저장된다, 코드 실행이 계속 실행되고 S1에 저장 공간이 부족하게 되면 new space의 garbage collection이( Minor GC ) 수행되며 S1 공간에서 여전히 참조되고 있는 ( 사용되고 있는 ) 데이터만 추려내 S2로 옮기고 S1 공간을 비운다.

그리고 S2으로 이동된 data의 memory address에 대한 reference 업데이트가 수행되고 계속해서 새로운 data가 S2로 추가된다. 그리고 다시 저장 공간이 부족해지면 위의 과정을 반복한다.

여기서 new space 공간에 있는 데이터 중에 두 번의 gabage collection 과정 이후에도 남아 있는 데이터가 있으면 해당 데이터는 old space로 이동한다. Old space는 mark-sweep-compack algorithm를 통해 old space 공간에 있는 데이터에 대한 garbage collection ( Major GC )을 수행한다.

이제 위에서 언급했던 V8 기준으로는 대부분의 경우 primitive, reference type data 모두 heap 영역에 저장되어 관리하는 것으로 보인다를 기준으로 data type에 따른 memory 할당 과정을 살펴보자. 다시 강조하지만 모든 JS Engine이 아래와 같은 방식으로 memory allocation을 수행하지 않을 수 있다.

Primitive data type

문자열, 숫자, Boolean과 같은 primitive type data를 변수에 할당하면 변수의 실제 data는 heap 공간에 저장된다. 코드가 실행될 때 함수 내에 선언된 변수나 argument와 같은 local context는 stack memory에 추가되어 관리되며 변수는 실제 값을 저장하고 있는 memory address 정보를 통해 실제 값을 조회한다.

Primitive type data의 중요한 특징은 immutable value라는 점이다. 변수에 새로운 값을 재할당 할 수는 있어도 변수에 ( 메모리 공간에 ) 저장된 primitive data는 수정되지 않는다.

다음 예제를 살펴보자. 만약 다음과 같이 변수의 값을 재할당하면 test 변수의 값을 저장하고 있는 기존의 메모리 영역에 새로운 값을 추가하는 것이 아니라 새로운 메모리 영역에 새로운 값을 추가하고 변수 역시 새로운 메모리 주소 정보를 가지게 된다.

let test = 10 * 2;
test = 200;

그리고 기존의 값을 저장하고 있는 메모리가 주소가 더 이상 다른 변수에 의해 참조되지 않아 불필요하게 되면 Garbage Collector에 의해 메모리에서 해제된다.

또 다른 예제를 살펴보자.

let test1 = 10;
let test2 = test1;

위의 예제에서 10이라는 숫자 타입의 값을 가진 test1 변수를 test2 변수에 할당하고 있다. 이 때 test2를 위한 새로운 메모리 공간이 확보될까? 새로운 메모리 공간이 확보 된다면 언제 확보될까? 이 역시 Engine에 따라 차이가 다소 있을 수 있다.

위의 예제와 같이 두 개의 변수가 같은 값을 가질지라도 test2에 test1를 할당하는 순간 새로운 메모리 공간이 확보되고 10이라는 값을 저장하여 test2가 새로운 메모리 주소를 참조할 수도 있고 혹은 위의 예제와 같이 test2에 test1을 할당하고 두 변수가 같은 값을 가지고 있을 때까지는 같은 메모리 주소를 참조하다가 둘 중 하나에 새로운 값이 할당되는 순간 새로운 메모리 공간을 확보하여 해당 메모리 주소를 새로운 값이 할당 된 변수가 참조할 수도 있다.

위에서 언급한 Reference ( Visualised guide to memory management in JavaScript - Kateryna Porshniev )에 따르면 V8은 위의 상황에서 둘 중에 새로운 값이 할당되는 순간 새로운 메모리 공간을 확보하는 방법으로 구현되어 있는 것으로 보인다.

그렇기에 primitive data type을 가진 변수는 아래의 예제와 같이 두 변수 중 하나에 다른 값을 할당해도 다른 변수는 수정의 영향을 받지 않는다. 새로운 메모리 공간 확보 시점이 어떻게 되었든 결국 새로운 값은 새로운 메모리 공간에 저장되고 기존 값을 가지고 있는 변수와는 다른 메모리 주소를 갖기 때문이다. 그러므로 primitive data는 immtable value다.

let test1 = 10;
let test2 = test1;
test1 = 20;

console.log(test1); // 20
console.log(test2) // 10

Reference data type

Object와 같은 reference type data를 변수에 할당하면 실제 object data는 heap 메모리 공간에 저장되고 변수는 실제 object data가 저장된 메모리 주소 값을 가진다. 그리고 object를 구성하는 property 역시 실제 데이터는 다른 메모리 주소에 저장되어 있고 property는 그 메모리 주소 정보를 가지고 있다.

const test = {
    country:"memory address1",
    city:"memory address2"
}

/* memory address1 */
korea

/* memory address2 */
seoul

Javascript의 reference type data는 mutable value다. 즉, object에 새로운 property를 추가하거나 특정 property를 삭제해도 새로운 메모리 주소를 가진 새로운 object가 생성되는 것이 아닌 원래 메모리 주소에 저장되어 있던 object가 수정되므로 메모리 주소가 변하지 않는다.

여기서 주의할 부분은 object 자체는 mutable value이지만 object의 property가 primitive data라면 해당 property는 immutable value다. 즉, 위의 예제에서 city property의 value를 변경하면 기존 city property의 value를 저장하고 있던 메모리 주소의 내용이 변경되는 것이 아닌 새로운 메모리 주소에 새로운 값이 할당되고 city property는 새로운 메모리 주소 정보를 참조한다.

test.city = "busan"

const test = {
    country:"memory address1",
    city:"memory address3"
}

/* memory address3 */
busan

그렇기에 다음과 같이 object와 같은 reference type data를 가진 변수를 다른 변수에 할당하고 둘 중 하나를 수정하면 나머지 변수도 그 수정에 영향을 받는다. 두 변수 모두 동일한 객체가 저장된 메모리 주소를 가리키는 참조 값을 가지고 있기 때문이다.

let book1 = {
    title:"book1"
}

let book2 = book1;

book2.title= "book2";

console.log(book1.title); // book2
console.log(book2.title); // book2