메모리 관리 // 가비지 컬렉션 // 메모리 누수 (JS)

woolee의 기록보관소·2023년 2월 11일
0

FE개념정리

목록 보기
33/35

JS의 메모리 관리(Memory management)

js는 자동으로 메모리를 관리해준다(c언어와 같은 저급 언어는 메모리 관리를 위한 함수를 별도로 사용한다). objects가 생성되었을 때 자동으로 메모리에 할당하고, 더 이상 필요하지 않으면 자동으로 해제한다(garbage collection). 이러한 자동 메모리 관리 개념이 문제가 되는 이유는, 개발자가 메모리 관리에 대해 고민할 필요가 없다는 잘못된 인상을 주기 때문이다.

메모리의 생명주기(memory life cycle)는 프로그래밍 언어와 관계없이 유사하다.
1. 필요할 때 할당
2. 할당된 메모리 사용 (읽기, 쓰기)
3. 더 이상 필요하지 않으면 해제

2는 모든 언어에서 명시적으로 사용되며, 13은 저급언어에서는 명시적이지만 js와 같은 고급언어에서는 암묵적이다.

js는 값을 선언(+초기화)할 때 자동으로 메모리를 할당한다.
함수의 호출 결과를 변수에 담아도 메모리가 할당되며, 메서드가 새로운 값이나 objects를 할당하기도 한다.

가비지 컬렉션(GC, Garbage collection)

문제는 할당된 메모리가 더 이상 필요없어지는 때가 언제인지를 알아내기 쉽지 않다는 점이다.

js와 같은 고급언어에서는 GC라는 자동 메모리 관리 방법을 사용하지만, 여전히 메모리가 언제 필요하고 언제 불필요한지를 판단하는 건 어려운 문제이므로 GC가 완벽한 해결책이 될 수는 없다.

GC 알고리즘의 핵심 개념은 참조(reference)이다.
A라는 메모리를 통해(명시적이든 암시적이든) B 메모리에 접근할 수 있으면, "B는 A에 참조된다"고 표현한다. 예를 들어 js의 모든 object는 prototype을 암시적으로 참조하며, 그 object의 properties 값을 명시적으로 참조한다.

Reference-counting garbage collection

예를 들어, Reference-counting garbage collection라는 알고리즘은 GC의 가장 naive한 알고리즘이다. 이 알고리즘은 "더 이상 필요없는 object"를 "어떤 다른 object도 참조하지 않는 object"라고 정의하며, 이 object를 garbage라고 부른다. 이 garbage를 참조하는 다른 object가 하나도 없을 때, 이들을 collect한다.

예제 코드

var x = {
  a: {
    b: 2
  }
};
// 2개의 오브젝트가 생성되었습니다. 하나의 오브젝트는 다른 오브젝트의 속성으로 참조됩니다.
// 나머지 하나는 'x' 변수에 할당되었습니다.
// 명백하게 가비지 콜렉션 수행될 메모리는 하나도 없습니다.


var y = x;      // 'y' 변수는 위의 오브젝트를 참조하는 두 번째 변수입니다.

x = 1;          // 이제 'y' 변수가 위의 오브젝트를 참조하는 유일한 변수가 되었습니다.

var z = y.a;    // 위의 오브젝트의 'a' 속성을 참조했습니다.
                // 이제 'y.a'는 두 개의 참조를 가집니다.
                // 'y'가 속성으로 참조하고 'z'라는 변수가 참조합니다.

y = "mozilla";  // 이제 맨 처음 'y' 변수가 참조했던 오브젝트를 참조하는 오브젝트는 없습니다.
                // (역자: 참조하는 유일한 변수였던 y에 다른 값을 대입했습니다)
                // 이제 오브젝트에 가비지 콜렉션이 수행될 수 있을까요?
                // 아닙니다. 오브젝트의 'a' 속성이 여전히 'z' 변수에 의해 참조되므로
                // 메모리를 해제할 수 없습니다.

z = null;       // 'z' 변수에 다른 값을 할당했습니다.
                // 이제 맨 처음 'x' 변수가 참조했던 오브젝트를 참조하는
                // 다른 변수는 없으므로 가비지 콜렉션이 수행됩니다.

이 알고리즘은 순환 참조 문제를 해결하지 못한다는 점에서 한계가 명확하다.

아래 예제 코드에서 두 객체가 서로 참조한다. 함수 호출이 완료되면, 이 두 객체는 스코프를 벗어나게 되고 그 시점을 기준으로 두 객체가 불필요해져 할당된 메모리가 회수되어야 한다. 하지만 두 객체가 서로를 참조하고 있으므로 reference-counting 알고리즘은 두 객체 모두 GC 대상으로 표시하지 않는다. 이러한 순환 참조는 메모리 누수의 흔한 원인이 된다.

function f() {
  var x = {};
  var y = {};
  x.a = y;         // x는 y를 참조합니다.
  y.a = x;         // y는 x를 참조합니다.

  return "azerty";
}

f();

실제로 인터넷 익스플로러 6,7은 DOM 객체에 대해 reference-counting 알고리즘으로 GC를 수행한다. 그리고 다음과 같은 패턴의 순환 참조에 의한 메모리 누수가 발생한다.

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

Mark-and-sweep algorithm

이 알고리즘은 "더 이상 필요하지 않은 객체"를 "닿을 수 없는 객체"로 정의한다.

이 알고리즘은 roots라는 객체 집합을 가지고 있는데(js에서는 전역 변수들을 의미), 주기적으로 GC가 roots에서 시작해서 roots가 참조하는 객체들, 또 이 객체들을 참조하는 객체들, ...을 "전부 닿을 수 있는 객체(reachable objects)"라고 표시한다. 그리고 닿을 수 없는 객체들에 대해 가비지 컬렉션을 수행한다.

이 알고리즘은 Reference-counting 알고리즘보다 효율적이다. 참조되지 않는 객체는 모두 닿을 수 없는 객체이지만, 역은 성립하지 않는다.

2012년을 기준으로 모든 최신 브라우저들은 가비지 컬렉션에서 이 알고리즘(Mark-and-sweep algorithm)을 사용한다. 대부분의 GC 개선 알고리즘도 여전히 이 알고리즘을 근거로 두고 있다.

이 알고리즘을 사용하면 더 이상 순환 참조가 문제되지 않는다. 첫번째 예제에서 함수가 반환되고 두 객체는 닿을 수 없으므로 GC 대상이 된다. 두번째 예제에서도 div 변수와 이벤트 핸들러가 roots로부터 닿을 수 없기에, 순환 참조가 발생했음에도 불구하고 가비지 컬렉션이 발생한다.

한계는 여전히 존재한다. 때로는 수동으로 메모리 해제를 결정해야 할 때도 있다. 그리고 수동으로 객체의 메모리를 해제하려면, 객체 메모리에 도달할 수 없도록 명시하는 기능이 있어야 하지만, 아직까지는 존재하지 않는다.

Node.js

Node.js에서는 브라우저 환경에서 실행되는 js에 사용할 수 없는 메모리 문제를 구성하고 디버깅하기 위한 추가 옵션과 도구를 제공한다.

JavaScript engines typically offer flags that expose the memory model. For example, Node.js offers additional options and tools that expose the underlying V8 mechanisms for configuring and debugging memory issues.

메모리 관리를 도와주는 자료구조도 존재한다.

비록 js가 직접적으로 garbage collector API를 노출하지는 않지만, 간접적으로 garbage collection을 용이하게 하는 몇가지 자료구조를 제공한다.

WeakMaps and WeakSets

WeakMapWeakSet은 자료구조이며 이들의 API는 Map과 Set과 유사하지만 정반대된다.

WeakMap과 WeakSet은 weakly held values라는 개념을 갖는다. 만약 x가 y에 의해 약하게 참조된다면(x is weakly held), 비록 y를 통해 x에 접근할 수 있더라도 mark-and-sweep algorithm은 x를 reachable하다고 인식하지 않는다. 다른 무언가에 의해 strongly 참조되지 않는 한.

WeakMap과 WeakSet에 있어, key와 value는 garbage collection 대상이 된다. 이들에게 원시값이 위조될 수는 있어도 영원히 이 collection에 남아 있을 수는 없다.

WeakMap과 WeakSet은 iterable이 아니다. 그러므로 Array.from(map.keys()).length도 사용할 수 없다.

If key is stored as an actual reference, it would create a cyclic reference and make both the key and value ineligible for garbage collection, even when nothing else references key — because if key is garbage collected, it means that at some particular instant, value.key would point to a non-existent address, which is not legal. To fix this, the entries of WeakMap and WeakSet aren't actual references, but ephemerons, an enhancement to the mark-and-sweep mechanism. Barros et al. offers a good summary of the algorithm (page 4). To quote a paragraph:

Ephemerons are a refinement of weak pairs where neither the key nor the value can be classified as weak or strong. The connectivity of the key determines the connectivity of the value, but the connectivity of the value does not affect the connectivity of the key. […] when the garbage collection offers support to ephemerons, it occurs in three phases instead of two (mark and sweep).

Reachability과 mark-and-sweep 알고리즘을 더 깊게

현재 js는 reachability라는 개념을 사용해서 메모리 관리를 수행한다.

예를 들어 도달 가능한(reachable) 값은 다음과 같다.

  1. 태생부터 도달 가능한 값들
  • 현재 함수의 지역 변수와 매개 변수
  • 중첩 함수의 체인에 있는 함수에서 사용되는 지역 변수와 매개변수
  • 전역 변수
  • 기타 등등

이 값들을 루트(roots)라고 부른다.

  1. 루트가 참조하는 값이나 체이닝으로 루트에서 참조할 수 있는 값

Interlinked objects 예제

function marry(man, woman) {
  woman.husband = man;
  man.wife = woman;

  return {
    father: man,
    mother: woman
  }
}

let family = marry({
  name: "John"
}, {
  name: "Ann"
});

함수 marry는 매개변수로 받은 두 객체를 서로 참조하도록 만들고, 두 객체를 포함하는 새로운 객체를 반환한다. 그럼 메모리 구조는 아래와 같다.

이 상태에서 참조 2개를 지우면

delete family.father;
delete family.mother.husband;

왼쪽의 name이 "John"인 객체는 도달 가능한 상태를 벗어나게 되므로 가비지 컬렉션을 통해 메모리에서 제거된다.

이때 객체와 루트의 연결을 끊으면 (family=null;)

John과 Ann이 여전히 서로를 참조하고 있고, 두 객체 모두 외부에서 들어오는 참조를 갖고 있음에도 루트 연결이 사라지면서 도달할 수 없는 상태가 되어 버려 메모리에서 제거된다.

js가 reachability라는 개념을 사용해서 메모리 관리를 수행하는데,
이 가비지 컬렉션의 기본적인 내부 알고리즘은 'mark-and-sweep'이라고 불린다.

가비지 컬렉션은 다음 단계를 거쳐 수행된다.

  • 가비지 컬렉터는 루트(roots) 정보를 수집하고 이를 remembers(mark)한다.
  • 루트가 참조하고 있는 모든 객체를 방문하고 이것들을 mark한다.
  • mark된 모든 객체에 방문하고 그 객체들이 참조하는 객체도 mark한다. 한번 방문한 객체는 전부 mark하기 때문에 같은 객체를 다시 방문하는 일은 없다.
  • 루트에서 도달 가능한 모든 객체를 방문할 때까지 위 과정을 반복한다.
  • mark되지 않은 모든 객체를 메모리에서 삭제한다.

js 엔진이 가비지 컬렉션을 더 빠르게 하는 다양한 최적화 기법들

  • generational collection(세대별 수집)

객체를 '새로운 객체'와 '오래된 객체'로 구분한다. 객체의 상당수는 생성하자마자 제 역할을 빠르게 수행해서 금방 쓸모가 없어지는데, 이러한 객체를 '새로운 객체'로 분류한 뒤에 이 객체들을 가비지 컬렉터가 공격적으로 제거한다. 일정 시간 이상 동안 살아 남은 객체를 '오래된 객체'로 구분한다.

  • incremental collection(점진적 수집)

방문해야 할 객체가 너무 많으면 너무 오래 걸린다. 그래서 가비지 컬렉션을 여러 부분으로 분리한 다음, 각 부분을 개별적으로 수행한다.

  • idle-time collection(유휴 시간 수집)

가비지 컬렉터가 실행에 주는 영향을 최소화하기 위해 CPU가 유휴 상태일 때(while the CPU is idle)만 가비지 컬렉션을 실행한다.

메모리 누수

더 이상 사용하지 않는 메모리를 적절히 해제하지 못할 때, 우리는 메모리가 누수된다고 표현한다.

자바스크립트 메모리는 단순 변수(원시 타입)를 저장하는 스택 메모리와 복잡한 객체(참조 데이터 타입)를 담는 힙 메모리로 구분된다.

일반적으로 전역 변수는 자동으로 정리되지 않는다.

크롬 개발자 도구로 성능을 측정하는 방법 - performance 패널

  1. 개발자 도구의 Performance 패널을 연다.
  2. 좌측 상단의 회색 동그라미 버튼은 프로그램의 메모리 사용량을 기록한다. 동그라미 버튼을 누르는 동안 메모리 사용량을 기록하므로, 동그라미 버튼을 누르고 측정하길 원하는 함수를 실행한다. 그리고 종료한다.
  3. 원활한 측정을 위해 쓰레기통 모양의 collect garbage 버튼을 누르고 나서 측정하는 게 좋다.

JS Heap 부분에는 참조 데이터 타입이 들어가는데, 메모리 누수가 발생할 경우(즉, 가비지 컬렉터가 메모리를 수거하지 못할 경우) 라인 차트가 콜백을 보이지 않고 계속해서 상승하는 추세를 보인다.

크롬 개발자 도구로 성능을 측정하는 방법 - memory 패널

메모리 패널에서는 메모리 사용량을 실시간으로 볼 수 있다.

마찬가지로 회색 동그라미 버튼을 눌러 측정한다.
측정하는 동안 프로그램을 실행하면 회색 막대기와 파란색 막대기가 등장하고, 파란색 막대기가 높아졌다가 낮아지는 걸 볼 수 있다.
해당 높이 만큼 메모리를 차지했다가 다시 메모리가 해제되는 걸 의미한다.

파란색 막대기가 회색 막대기로 변경되지 않으면 메모리가 해제되지 않았다는 걸 의미한다.

메모리 누수 사례

  • 클로저의 잘못된 사용
  • 의도치 않게 생성된 전역 변수
  • 분리된 DOM 노드
  • 콘솔 출력
  • 해제되지 않는 타이머 (혹은 콜백함수)

1. 클로저의 잘못된 사용

클로져는 자신을 감싸고 있는 외부 함수에 접근할 수 있는 내부의 함수를 말한다. 이 클로져는 한번 동일한 부모 스코프 내에서 생성되면, 이 스코프를 공유한다.

<!DOCTYPE html>
<html>
  <body>
    <button onclick="closure1()">execute fn1</button>
    <script>
      var memory = null

      var closure1 = function () {
        var origin = memory

        var unused = function () {
          if (origin) // 'origin'에 대한 참조
            console.log("unused")
        };

        memory = { // 'memory'에 대한 참조 
          str: new Array(1000000).join('*'),
          closure2: function () {
            console.log("method!")
          }
        };
      };
    </script>
  </body>
</html>

예를 들어 위 코드에서,
closure1 함수를 실행하면 전연 변수인 memory는 배열 데이터인 str과 새로운 클로져인 clusure2 함수를 할당 받는다.

closure1 함수 내부에 있는 unused 함수는 origin을 참조하고 있고, 이 origin은 memory를 참조하고 있다. 그리고 이 두 참조 관계는 closure1 함수라는 부모 스코프 아래에서 맺어진다.

그러므로 unused 함수는 실행되지 않음에도 강한 참조 상태가 활성화되게 되고, 덕분에 unused 함수에 대해 가비지 컬렉터가 동작하지 않게 된다.

그리고 performance 패널로 확인해보면
JS Heap이 줄어들지 않고 계속 증가 상태로 지속되는 걸 볼 수 있다. 메모리 누수가 발생하고 있는 것이다.

memory 패널로도 확인을 해봐도, 파란색 막대기가 줄어들지 않고 계속 남아 있게 된다. 메모리 누수가 발생하는 것이다.

이렇게 클로져를 잘못 사용해서 참조가 계속되면 메모리 누수가 발생하는 것이다.

2. 의도치 않게 생성된 전역 변수

전역 변수는 일반적으로 가비지 컬렉터에 의해 수집되지 않는다.
그러므로 필요하지 않다면 전역 변수를 생성하는 건 자제해야 한다.

변수에 선언 없이 값을 할당하면 그 변수는 전역에 생성된다.

name = 'woolee';

위에서 처럼,
의도치 않게 전역 변수를 생성하거나
클로저를 잘못 사용해 스코프가 공유되는 경우 등
원인이 되는 변수에 null 값을 할당하거나 혹은 다른 변수로 할당함으로써 메모리 누수를 막을 수 있다.
null을 할당하면, 참조가 끊기며 reachable하지 않게 되므로 해당 데이터가 메모리에서 해제된다.

3. 분리된 DOM 노드 (DOM 외부에서 요소를 참조하는 경우)

일반적으로 DOM 노드를 매번 불러오지 않으려고, 한번 찾아 놓은 뒤 이를 변수에 저장하곤 한다.

이때 특정 DOM 노드를 제거하려는 경우,
아직 변수에 해당 DOM 노드가 담겨 있으므로 메모리 누수가 발생한다.

let btn = document.querySelector('button')
let child001 = document.querySelector('.child001')
let root = document.querySelector('#root')

btn.addEventListener('click', function() {
  root.removeChild(child001)
})


memory 패널에서 Heap snapshot을 선택한 다음, 동그라미 버튼을 누르면 스냅샷이 찍히게 된다. JS Heap에 어떤 데이터들이 담기는지 확인할 수 있다.


Summary 옆에서 detached 를 검색해 클릭해보면
하단에 Object 안에 우리가 지우려는 child0001 노드가 여전히 남아 있게 된다.

이 경우 특정 노드를 참조하는 값을 콜백 함수 내부로 옮기면 해결된다.

let btn = document.querySelector("button");
btn.addEventListener("click", function () {
    let child001 = document.querySelector(".child001");
    let root = document.querySelector("#root");
    root.removeChild(child001);
});

4. 콘솔

콘솔의 경우 디버깅용으로 사용하지만, 실제 서비스에서 콘솔을 출력할 경우 메모리 누수가 발생한다. 디버깅 할 때를 제외하고는 콘솔은 사용하지 않아야 한다.

5. 해제되지 않은 타이머

타이머 또한 사용했다면 해제하는 것도 잊지 말아야 한다.
수동으로 해제하지 않으면 메모리 누수가 발생한다.

참고

자바스크립트의 메모리 관리 - JavaScript - MDN Web Docs
Garbage collection
JavaScript Internals: Garbage Collection

자바스크립트는 어떻게 작동하는가: 메모리 관리 + 4가지 흔한 메모리 누수 대처법
당신이 모르는 자바스크립트의 메모리 누수의 비밀
The Secrets of Memory Leaks in JavaScript You Don’t Know

Record heap snapshots

profile
https://medium.com/@wooleejaan

0개의 댓글