[TIL] JavaScript의 가비지 컬렉션

August 02, 2021

자바스크립트 엔진의 메모리 관리

원시 값과 객체의 복사에서 원시 값이 담긴 변수에 새로운 값을 할당하는 경우, 새로운 메모리 공간이 확보되어 그 곳에 새로운 값이 담긴다고 했다. 그리고 기존 값이 담긴 메모리 주소를 참조하던 변수는 새로운 값이 담긴 메모리 주소를 참조하게 된다.

이처럼 값을 재할당 하면 기존 메모리 주소의 값을 지우고 그 곳에 새로운 값을 넣는 것이 아니라 추가적인 메모리 공간을 사용하기 때문에, 유한한 용량을 보유한 메모리를 효율적으로 관리하기 위해서는 더 이상 사용되지 않는 기존의 값을 메모리에서 정리해줘야 할 필요가 발생한다.

하지만 자바스크립트를 사용하면서 재할당된 변수의 기존 값을 직접 메모리에서 삭제한 경험은 없다. (메모리 공간에 값을 직접 할당해준 적도) 자바스크립트는 개발자의 직접적인 메모리 관리를 허용하지 않는 managed language이기 때문이다.

자바스크립트 엔진 내부의 가비지 컬렉터가비지 컬렉션을 자동으로 대신 수행해주는 것이다.

Garbage Collection 알고리즘

언어와 상관없이 메모리의 생존주기는 다음과 같다.

  1. 필요할 때 할당한다.
  2. 사용한다. (읽기와 쓰기)
  3. 사용이 끝나면 해제한다.

2번이야 모든 언어에서 공통적으로 개발자가 직접 수행하는 과정이지만, 위에서 말했듯 자바스크립트는 개발자의 직접적인 메모리 관리를 허용하지 않기 때문에 1번과 3번은 암묵적으로 작동한다.

그렇다면, 자바스크립트 엔진의 가비지 컬렉터는 특정 메모리 공간에 할당된 값이 더 이상 사용되지 않는 ‘쓰레기’라는 사실을 어떻게 판단할까? 내가 더 이상 사용하지 않을 거라고 말해준 적도 없는데..

결론부터 말하자면, 자바스크립트 엔진은 더 이상 사용되지 않는 값을 찾아내기 위해 mark-and-sweep이라는 알고리즘을 사용한다. 이 알고리즘을 설명하기에 앞서, 알고 있으면 이해에 더 도움이 될 것 같은 알고리즘이 하나 있다.

바로 reference-counting 알고리즘이다. 내가 가비지 컬렉션에 대해서 막연하게 유추했을 때 이런 방법을 쓰면 되지 않을까 라고 생각했었는데, 그 방식의 문제점을 여실히 드러내주는 알고리즘이기 때문이다.

Reference-counting (참조-세기) 알고리즘

reference-counting 알고리즘에서 더 이상 필요없는 오브젝트 즉, 가비지는 “어떤 다른 객체에 의해서도 참조되지 않는 객체”이다. 그 누구도 자신을 참조하지 않는다면, 해당 객체의 참조 카운트는 0이 된다. 이 알고리즘의 가비지 판단 기준은 참조 카운트가 0인 객체인 것이다.

언듯 보기엔 이 방식이 별 문제가 없어보인다. 누군가에 의해 참조되고 있다는 것은, 추후에 다시 사용될 가능성이 있는 값이기에 가비지로 분류하지 않고 남겨두면 되는 것 아닌가? 라고 생각되기 때문이다.

따라서 reference-counting 알고리즘이 문제가 되려면 ‘어딘가에서 참조되고 있지만 다시 사용될 수는 없는 경우’가 되어야 한다. 그런 경우가 가능할까? 가능하다. 바로 순환 참조(circular reference)의 상황이 그렇다.

아래는 MDN에 기술된 순환 참조의 예시다.

window.onload = function() {
  const div = document.getElementById('myDivElement');
  div.circularReference = div;
  div.lotsOfData = new Array(10000).join('*');
};

DOM 요소인 divcircularReference 속성으로 자기 자신을 참조한다. 한편 lotsOfData 속성으로는 많은 양의 데이터를 가지고 있다.

만약 해당 DOM 요소가 더 이상 페이지에서 필요하지 않아 DOM 트리에서 제거된다면, div의 속성들도 더 이상 필요하지 않을 것이다. 이에 따라 메모리의 많은 공간을 차지하지만 더 이상의 쓸모는 없는 lotsOfData 같은 속성의 값을 메모리에서 해제하는 것이 효율적인 메모리 관리법일 것이다.

그러나 reference-counting 알고리즘은 ‘참조 여부’를 판단의 기준으로 삼는다고 했다. 그 누구도 div DOM 요소를 필요로 하지 않는 상황이지만 div 요소는 속성으로 자기 자신인 div를 ‘참조’하고 있다. 참조 카운트가 1인 것이다. 따라서 가비지 컬렉터에 의해 정리되어야 할 가비지로 판단되지 않는다.

따라서 div는 다시는 쓰이지 않지만, 메모리의 큰 부분을 떡하니 차지하며 보이지 않는 곳에서 구천을 떠돌며 메모리 누수의 원인으로 작용하는 것이다. 이는 브라우저의 속도 저하를 유발한다. (익스플로러 6와 7은 실제로 reference-counting 알고리즘을 사용하여 가비지 컬렉션을 수행한다고 한다. 새로운 뭔가를 공부할 때마다 익스플로러를 쓰면 안되는 이유도 하나씩 배우는 중이다..😂)

이처럼 참조 여부만으로 메모리에 할당된 값의 쓰임을 판단하는 reference-counting 알고리즘의 한계를 알아보았다.

mark-and-sweep (표시하고-쓸기) 알고리즘

쓰지 않는 알고리즘에 대해 알아봤으니 이제는 실제로 자바스크립트의 가비지 컬렉션에 쓰이는 mark-and-sweep 알고리즘이 뭔지 알아보자.

reference-counting 알고리즘은 참조 여부로 가비지 컬렉션 대상을 판단한다고 했다. 이에 반해 mark-and-sweep 알고리즘은 도달 가능성(reachability)라는 개념을 사용하여 가비지 컬렉션의 대상을 찾아낸다. ‘도달 가능한 값’은 그대로 놔두고 ‘도달할 수 없는 값’을 메모리에서 삭제하는 방식이다.

그렇다면 도달 가능성이란 무엇일까?

특정 값에 대하여 접근할 수 있는지, 그 값을 사용할 수 있는지의 여부를 도달 가능성이라고 한다. 전역 변수처럼 생성되는 순간부터 언제 어디서나 참조할 수 있는 값을 root라고 부르는데, mark-and-sweep 알고리즘은 이 root 값들이 참조하고 있는 값 그리고 체이닝을 통해 root에서 참조할 수 있는 값을 ‘도달 가능한 값’이라고 판단한다.

mark-and-sweep 알고리즘을 사용한 가비지 컬렉션 진행 순서

  • 가비지 컬렉터는 root 정보를 수집하고 이를 mark(기억)한다.
  • root가 참조하고 있는 모든 객체를 방문하고, 이들을 다시 mark 한다.
  • 새롭게 mark한 객체들이 참조하고 있는 객체를 방문하고 mark 한다. 이미 mark한 객체는 다시 방문하지 않는다.
  • 이 방식으로 도달 가능한 모든 객체를 방문할 때까지 반복한다.
  • mark 되지 않은 객체들을 메모리에서 삭제한다.

자바스크립트의 가비지 컬렉터는 자바스크립트 엔진 내에서 끊임없이 동작하며, 모든 객체를 탐색하며 도달 가능하다고 판단된 값들은 가비지 컬렉션의 대상에서 제외하고, 도달할 수 없는 값들에 대해서 가비지 컬렉션을 수행하는 것이다.

따라서 전역 변수처럼 언제 어디서나 참조 가능한 값은, 의도적으로 메모리에서 삭제하지 않는 이상 (자바스크립트에서는 불가능하다!) 언제나 ‘도달 가능한 값’이기에 가비지 컬렉션에 의해 메모리에서 해제되지 않을 것이다.

이러한 방법을 사용하여 mark-and-sweep 알고리즘은 reference-counting 알고리즘에서 발생하는 순환 참조에 의한 메모리 누수를 방지할 수 있게 된다. 실제로는 쓰일 수 없지만 단지 서로를 참조하고 있는 ‘외딴 섬’ 같은 값들을 삭제 대상으로 판단하기 때문이다.

위에서 살펴 본 예시에 mark-and-sweep 알고리즘을 적용해본다면,

div 요소가 DOM 트리에서 제외되어 다른 어느 요소로부터도 도달할 수 없는 상황이 되면, 자기 자신의 속성으로 순환 참조가 일어나고 있더라도 외부의 어느 요소로부터도 접근할 수 없기 때문에 도달 할 수 없는 값으로 판단되어 div 요소 그리고 속성으로 참조하는 lotsofData는 가비지 컬렉터에 의해 메모리에서 삭제될 것이다.

이 때 주의할 것은 root에서부터 참조 가능한 방향에 있는 값들만이 도달 가능한 값이 된다는 점이다. 만약 div 요소가 도달 가능한 범위에 속한 특정 요소를 참조하고 있더라도, 외부(반대 방향)에서 div 요소에 접근할 수 있는 방법이 없기 때문에 당연히 도달할 수 없는 값이 되어 div는 결국 가비지 컬렉터에 의해 삭제된다는 뜻이다.

(여기에서 추가적으로 언급하는 mark-and-sweep 알고리즘을 최적화 하는 기법 generational collection, incremental collection, idle-time collection 등에 대해서는 추후 자세히 알아보자.)

결론 및 느낀점

정리하자면 reference-counting 알고리즘은 단순히 ‘객체의 참조가 이루어지고 있는가?‘만을 고려하여 가비지 컬렉션의 대상을 판단한다면,
mark-and-sweep 알고리즘은 ‘객체의 유의미한(= 도달 가능한) 참조가 이루어지고 있는가?‘를 판단하여 불필요한 데이터들에 대해 보다 정밀한 가비지 컬렉팅을 수행함으로써 효율적인 메모리 관리를 돕는다고 할 수 있을 것 같다.

자바스크립트는 개발자를 위해 많은 부분을 암묵적으로 수행하는데 언제나 장점과 단점이 공존한다.

가비지 컬렉터에 의한 메모리 관리도 역시나 마찬가지다. C 처럼 직접 메모리 관리를 해줘야하는 언어의 불편함은 줄여주지만, 이는 결국 개발자의 의도와는 상관없이 메모리의 제어가 이루어진다는 의미라고도 할 수 있을 것 같다.

가비지 컬렉션에 대해 더욱 깊게 이해하기 위해서는 결국 직접 메모리 관리를 수행해야 하는 언어들에 대해서도 알 필요가 있어보인다. C 공부를 더 해보자.

모던 자바스크립트 엔진은 위에서 설명한 것보다 좀 더 발전된 가비지 컬렉션 알고리즘을 사용한다고 한다. V8 엔진에 대해서도 좀 더 살펴보자.

자바스크립트의 클로저(closer)에 대해서 더 자세히 공부한 뒤에 이 부분을 다시 들여다보면 지금 놓치고 있는 부분을 더 명확하게 이해할 수 있을 것 같다.

참고

MDN Web Docs | 자바스크립트의 메모리 관리
가비지 컬렉션


Profile picture

42KIM A person trying to create something.
👉Github