얼마 전 좋은 기회로 큰 회사 면접을 보게 되었다. 그 때 '가비지 컬렉터란 무엇일까요?' 라는 질문을 받았는데, 제대로 답하지 못했다. JAVA에서 어떻게 동작하는지는 알아도 Javascript에서 동작원리는 잘몰라서 공부해야겠구나!를 느꼈다...ㅠㅠ
종류는 number, string, boolean, null, undefined, symbol이 있다. primitive type은 메모리를 할당 받으면 값이 변경되지 않는 불변 타입이다. 그래서 값이 변경될 때마다 새로운 메모리 주소에 값을 할당한다. 그리고 쓰이지 않는 메모리 주소는 가비지의 대상이 된다.
let count;
count = 3;
count++;
위와 같은 코드가 있을 때, 밑의 그림과 같이 메모리주소가 할당된다.

❓(여기서 잠깐) symbol 타입이 무엇일까?
ES6 버전에서 추가된 타입으로 객체의 property 키를 고유하게 설정함으로서 property 키의 충돌을 방지하기 위해 사용된다. Symbol 함수를 호출하면 매번 새로운 symbol이 생성되고, new 연산자를 통해 객체 생성이 불가능하다.
그리고 주로 사용되는 예시로는 객체의 property 키로 사용된다. 아래 코드는 symbol 활용 예시이다.
const obj = {};
// sym1, sym2, sym3 모두 다르다!!!
const sym1 = Symbol();
const sym2 = Symbol('foo');
const sym3 = Symbol('foo');
obj[sym1] = 'propertyValue1';
obj[sym2] = 'propertyValue2';
obj[sym3] = 'propertyValue3'; // no conflict with sym2
console.log(obj); // {Symbol(): 'propertyValue1', Symbol(foo): 'propertyValue2', Symbol(foo): 'propertyValue3'}
console.log(obj[sym1]); // propertyValue1
console.log(obj[sym2]); // propertyValue2
console.log(obj[sym3]); // propertyValue3
primitive type을 제외한 모든 타입으로, 객체, 배열, 함수 등이 속한다. object type에 변수를 할당하면 실제 객체가 저장된 힙 메모리 주소가 저장된다.

위의 그림처럼, object type 의 변수 선언 시 stack 에는 주소값이 저장되고, heap 영역에는 주소가 가리킨 실제 데이터가 저장된다.
❓(여기서 잠깐) 깊은 복사 vs 얕은 복사
object type 의 메모리 저장 방식을 검색하다보면 깊은 복사와 얕은 복사가 자주 보인다. 그렇다면 두 복사는 어떤 차이점이 있는지 알아보자!


두 복사 방식의 차이점은 heap 영역을 공유하는가 이다. 얕은 복사를 할 경우 기존 주소값을 복사하여 stack 영역을 할당받아 heap 영역을 공유하고, 깊은 복사를 할 경우 새로운 heap 영역을 생성한다는 점이 다르다.
고딩 때 배운 C언어에서는 malloc()과 free() 로 JAVA에서는 garbage collection 메모리 관리를 한다. 그렇다면 Javascript에서는 어떻게 할까??
- 필요할 때 할당한다.
- 할당된 메모리를 읽고, 쓴다.
- 더 이상 필요하지 않으면 해제한다.
위와 같은 방식으로 대부분 메모리가 생성되고, 해제된다. 언어마다 다른 점이 있다면 저수준 언어에서는 직접 할당 및 해제를 해주는 식으로 명시적이며, 고수준 언어에서는 암묵적으로 작동한다.
우선 javascript 에서 메모리 할당하는 방법은 값 초기화를 하거나, 함수 호출을 통해 할 수 있다. 그렇다면 메모리를 해제하는 방법은 무엇이 있을까?
컴퓨터는 명령한대로만 동작하기 때문에 인간이 해당 메모리는 더이상 필요없으니까 해제해도 돼!라고 알려줘야한다. 그래서 등장한게 가비지 컬렉션(garbage collection) 이다.
우선 가비지 컬렉션의 목적은 메모리 할당을 추적하고 할당된 메모리 블록이 더 이상 필요하지 않게 되었는지 판단하여 회수하는 것이다.
1. Reference-Counting
말그대로 참조 개수를 세서 참조 갯수가 0개이면 메모리가 해제되는 방식이다. 즉, 어떤 다른 객체도 참조하지 않는 객체 = 필요 없는 객체 = 가비지로 여기는 알고리즘이다.
그러나!! 순환 참조가 발생하면 참조 횟수가 0개이면 메모리가 해제되지 않는다는 문제점이 있다.
2. Mark-and-sweep
roots 라는 전역변수의 집합부터 시작해서 roots가 참조하는 객체 -> roots의 자식들이 참조하는 객체 식으로 접근 가능한 객체를 선별해서 접근이 불가능한 객체를 가비지로 판단하는 방식이다.
실행 컨텍스트가 소멸하는 순간 접근하기 불가능한 객체가 되기 때문에 순환 참조가 발생하지 않는다. 2012년부터 최신 브라우저들의 가비지 컬렉션은 Mark-and-sweep 알고리즘으로 사용한다.
let obj2 = {};
const map2 = new Map();
map2.set(obj2, {key: "some_value"});
console.log('3 => ',map2.get(obj2));
obj2 = undefined;
console.log('4 => ',map2.get(obj2));
console.log(map2);
위와 같은 코드를 실행하면 나오는 결과값은?? 바로 아래 이미지와 같다.

이렇게 결과 값이 나오는 이유는 map, array, object, set을 구성하는 요소들은 메모리에 남아 있는 동안 도달 가능한 값으로 취급받기 때문에 메모리에서 삭제되지 않는다.
위와 같은 문제를 해결할 수 있는 WeakMap 과 WeakSet 은 메모리 관리를 도와줄 수 있는 자료구조이며, ES2015에서 등장했다. 그리고 WeakMap과 WeakSet만의 특징이 몇가지 있는데, key값으로 객체만 가질 수 있으며, WeakMap은 iterable 하지 않으므로 반복작업을 할 수 없다.
그럼 바로 코드로 알아보자!
let obj = {};
const map = new WeakMap();
map.set(obj, {key: "some_value"});
console.log('1 => ', map.get(obj));
obj = undefined;
console.log('2 => ',map.get(obj));
console.log(map);
위와 같은 코드를 실행하면 나오는 결과값은?? 바로 아래 이미지와 같다.

이처럼 WeakMap은 요소가 제거되어 가비지컬렉션이 동작하면 해당 데이터를 지니고 있던 WeakMap에서도 제거된다. WeakSet은 WeakMap의 Set 버전이다. 마찬가지로 WeakSet 내 유일 참조가 남을 경우 해당 객체를 가비지 컬렉트 할 수 있다.
WeakMap과 WeakSet은 메모이제이션에 유용하고, 예를 들어 사용자의 방문 횟수를 세어주는 코드가 있을 경우 사용하면 좋다.
구글이 주도하여 작성된 고성능의 자바스크립트 엔진이다. 동작 방식은 아래 그림과 같다.


메모리 구조 상 가장 중요한 부분은 New space 와 Old space 이다. GC의 핵심 기능을 하기 때문이다.
New space 는 새로 만들어진 객체를 저장하는 곳이고, Old space 는 New space에서 마이너 GC가 2번 발생할 동안 살아남은 객체들을 저장하는 곳이다.
이외 다른 부분은 큰 객체를 저장하기 위한 Large object space, JIT (Just In Time) 컴파일러가 컴파일된 코드 블록을 저장하는 곳이자, 실행 가능한 메모리가 있는 유일한 공간인 Code space 등으로 이루어져 있다.
그렇다면 GC는 어떻게 동작하는지 알아보자.
GC는 마이너 GC와 메이저 GC로 나누어지며, 메모리 구조에 따라 발생하는 종류가 다르다.
마이너 GC가 발생한다.마이너 GC는 할당 포인터가 끝에 도달하면 발생하므로 자주 발생하고, 이에 따라 빠르게 진행되어야한다. 그래서 New space를 to-space와 from-space 로 나누어진다. 동작 방식은 다음과 같다.

Marking
1.1) root로부터 DFS방식으로 연결된 객체를 순회하면서 처음 닿은 객체를 회색으로 마킹한다.
1.2) 회색으로 마킹된 부모 객체와 연결된 객체를 회색으로 마킹하고, 부모 객체는 검은색으로 마킹한다.
Sweep
마킹되지 않은 흰색 객체를 지우고, 사용해도 되는 Free list에 등록한다.
Compacting
파편화된 메모리를 정렬하는 과정으로 Heap을 압축한다.
GC가 진행되는 동안 Main Thread의 기존 작업이 멈추는 현상 때문에 좋지 않는 사용자 경험을 준다. 특히 메이저 GC는 시간이 꽤 걸린다.
그래서 등장한 Orinoco 프로젝트!! 그럼 어떤 방식으로 최적화 방식이 변화했는지 알아보자.
1. Incremental

GC 작업을 잘게잘게 쪼개서 Main Thread 작업 중간중간에 끼워넣는 방식이다. 이렇게 진행하면 멈추는 일까지는 생기지 않지만, 현재는 사용되지 않는 것으로 보인다.
2.Parallel

여러 스레드에서 병렬적으로 처리하는 방식이다. Main Thread를 잠깐 멈추지만, Helper Thread를 이용해 빠르게 처리한다. (JS는 싱글 스레드지만, 추가적인 Work Thread는 이용가능하다. JS 코드가 돌아가는 곳이 싱글 스레드일 뿐...)
3.Concurrent

Main Thread에서 코드가 돌아감과 동시에 Helper Thread에서 GC를 동작시키는 방식이다. 코드를 실행시키는 동안 Heap 구성이 또 바뀔 수도 있기 때문에, 굉장히 어렵기 때문에 각 스레드 사이의 중간 작업을 Synchronize 해주는 과정이 들어가는 것으로 보인다.