메모리 관리

메모리 관리 (Memory management)

C 같은 저수준 언어에는 malloc()free() 같은 수동 메모리 관리 프리미티브가 있습니다. 반면 JavaScript는 객체가 생성될 때 자동으로 메모리를 할당하고 더 이상 사용되지 않을 때 해제합니다(가비지 컬렉션). 이러한 자동성은 혼란의 잠재적 원인입니다. 개발자에게 메모리 관리에 대해 걱정할 필요가 없다는 그릇된 인상을 줄 수 있기 때문입니다.

출처: Memory management

본문

메모리 수명 주기 (Memory life cycle)

프로그래밍 언어와 무관하게 메모리 수명 주기는 거의 항상 동일합니다.

  1. 필요한 메모리를 할당합니다.
  2. 할당된 메모리를 사용합니다(읽기, 쓰기).
  3. 더 이상 필요하지 않을 때 할당된 메모리를 해제합니다.

두 번째 부분은 모든 언어에서 명시적입니다. 첫 번째와 마지막 부분은 저수준 언어에서는 명시적이지만 JavaScript 같은 고수준 언어에서는 대부분 암시적입니다.

JavaScript에서의 할당

  • 값 초기화: 프로그래머에게 할당에 대한 부담을 주지 않기 위해, JavaScript는 값이 처음 선언될 때 메모리를 자동으로 할당합니다.
    const n = 123; // allocates memory for a number
    const s = "string"; // allocates memory for a string
    
    const o = {
      a: 1,
      b: null,
    }; // allocates memory for an object and contained values
    
    // (like object) allocates memory for the array and
    // contained values
    const a = [1, null, "str2"];
    
    function f(a) {
      return a + 2;
    } // allocates a function (which is a callable object)
    
    // function expressions also allocate an object
    someElement.addEventListener("click", () => {
      someElement.style.backgroundColor = "blue";
    });
    
  • 함수 호출을 통한 할당: 일부 함수 호출은 객체 할당을 초래합니다.
    const d = new Date(); // allocates a Date object
    
    const e = document.createElement("div"); // allocates a DOM element
    
    일부 메서드는 새 값이나 객체를 할당합니다.
    const s = "string";
    const s2 = s.substring(0, 3); // s2 is a new string
    // Since strings are immutable values,
    // JavaScript may decide to not allocate memory,
    // but just store the [0, 3] range.
    
    const a = ["yeah yeah", "no no"];
    const a2 = ["generation", "no no"];
    const a3 = a.concat(a2);
    // new array with 4 elements being
    // the concatenation of a and a2 elements.
    

값 사용하기 (Using values)

값을 사용한다는 것은 기본적으로 할당된 메모리를 읽고 쓰는 것을 뜻합니다. 변수나 객체 속성의 값을 읽거나 쓰는 것, 심지어 함수에 인자를 전달하는 것도 여기에 해당합니다.

메모리가 더 이상 필요하지 않을 때 해제 (Release)

대부분의 메모리 관리 문제는 이 단계에서 발생합니다. 이 단계의 가장 어려운 측면은 할당된 메모리가 더 이상 필요하지 않은 때를 결정하는 것입니다.

저수준 언어는 개발자가 프로그램에서 할당된 메모리가 더 이상 필요하지 않은 시점을 수동으로 결정하고 해제해야 합니다.

JavaScript 같은 일부 고수준 언어는 가비지 컬렉션(GC)이라는 자동 메모리 관리 형태를 활용합니다. 가비지 컬렉터의 목적은 메모리 할당을 모니터링하고, 할당된 메모리 블록이 더 이상 필요하지 않은 때를 결정해 회수하는 것입니다. 이 자동 프로세스는 근사치입니다. 특정 메모리가 여전히 필요한지 결정하는 일반적인 문제는 결정 불가능(undecidable)하기 때문입니다.

가비지 컬렉션 (Garbage collection)

앞서 말했듯이, 어떤 메모리가 "더 이상 필요하지 않은지" 자동으로 찾는 일반적인 문제는 결정 불가능합니다. 결과적으로 가비지 컬렉터는 일반 문제에 대한 해결책의 제한된 버전을 구현합니다. 이 섹션은 주요 가비지 컬렉션 알고리즘과 각각의 한계를 이해하는 데 필요한 개념을 설명합니다.

참조 (References)

가비지 컬렉션 알고리즘이 의존하는 주요 개념은 참조(reference)입니다. 메모리 관리 맥락에서, 한 객체가 다른 객체에 접근할 수 있으면(암시적으로든 명시적으로든) 전자가 후자를 참조한다고 말합니다. 예를 들어 JavaScript 객체는 프로토타입에 대한 참조(암시적 참조)와 자신의 속성 값에 대한 참조(명시적 참조)를 가집니다.

이 맥락에서 "객체"의 개념은 일반 JavaScript 객체보다 더 넓게 확장되며 함수 스코프(또는 전역 어휘 스코프)도 포함합니다.

참조 카운팅 가비지 컬렉션 (Reference-counting garbage collection)

참고: 더 이상 어떤 현대 JavaScript 엔진도 가비지 컬렉션에 참조 카운팅을 사용하지 않습니다.

이것은 가장 순진한 가비지 컬렉션 알고리즘입니다. 이 알고리즘은 객체가 여전히 필요한지 결정하는 문제를, 객체를 여전히 참조하는 다른 객체가 있는지 결정하는 문제로 축소합니다. 객체를 가리키는 참조가 0개이면 그 객체는 "가비지" 또는 수집 가능(collectible)하다고 말합니다.

예:

let x = {
  a: {
    b: 2,
  },
};
// 2 objects are created. One is referenced by the other as one of its properties.
// The other is referenced by virtue of being assigned to the 'x' variable.
// Obviously, none can be garbage-collected.

let y = x;
// The 'y' variable is the second thing that has a reference to the object.

x = 1;
// Now, the object that was originally in 'x' has a unique reference
// embodied by the 'y' variable.

let z = y.a;
// Reference to 'a' property of the object.
// This object now has 2 references: one as a property,
// the other as the 'z' variable.

y = "mozilla";
// The object that was originally in 'x' has now zero
// references to it. It can be garbage-collected.
// However its 'a' property is still referenced by
// the 'z' variable, so it cannot be freed.

z = null;
// The 'a' property of the object originally in x
// has zero references to it. It can be garbage collected.

순환 참조(circular references)의 경우 한계가 있습니다. 다음 예제에서 서로를 참조하는 속성을 가진 두 객체가 만들어져 순환(cycle)을 만듭니다. 함수 호출이 완료된 후 이들은 스코프를 벗어납니다. 그 시점에 더 이상 필요하지 않게 되므로 할당된 메모리가 회수되어야 합니다. 그러나 참조 카운팅 알고리즘은 두 객체 각각이 적어도 하나 이상의 참조를 가리키므로 회수 가능하다고 간주하지 않으며, 결과적으로 둘 다 가비지 컬렉션 대상으로 표시되지 않습니다. 순환 참조는 메모리 누수의 흔한 원인입니다.

function f() {
  const x = {};
  const y = {};
  x.a = y; // x references y
  y.a = x; // y references x

  return "azerty";
}

f();

표시-쓸기 알고리즘 (Mark-and-sweep algorithm)

이 알고리즘은 "객체가 더 이상 필요하지 않다"는 정의를 "객체가 도달 불가능(unreachable)하다"로 축소합니다.

이 알고리즘은 루트(roots)라는 객체 집합의 지식을 가정합니다. JavaScript에서 루트는 전역 객체입니다. 주기적으로 가비지 컬렉터는 이 루트들에서 시작해, 루트들에서 참조되는 모든 객체를 찾은 다음, 이들에서 참조되는 모든 객체를 찾는 식으로 진행합니다. 가비지 컬렉터는 루트에서 시작해 모든 도달 가능한(reachable) 객체를 찾고 모든 도달 불가능한 객체를 수집합니다.

이 알고리즘은 이전 알고리즘보다 개선된 것입니다. 참조가 0개인 객체는 사실상 도달 불가능하기 때문입니다. 하지만 그 반대는 성립하지 않습니다. 순환 참조에서 보았듯이 도달 가능하지 않아도 참조가 있을 수 있기 때문입니다.

현재 모든 현대 엔진은 표시-쓸기 가비지 컬렉터를 갖추고 있습니다. 지난 몇 년간 JavaScript 가비지 컬렉션 분야에서 이루어진 모든 개선(세대별/증분/동시/병렬 가비지 컬렉션)은 이 알고리즘의 구현 개선일 뿐, 가비지 컬렉션 알고리즘 자체나 "객체가 더 이상 필요하지 않을 때"의 정의 축소에 대한 개선은 아닙니다.

이 접근 방식의 즉각적인 이점은 순환이 더 이상 문제가 되지 않는다는 것입니다. 위 첫 번째 예제에서 함수 호출이 반환된 후, 두 객체는 전역 객체에서 도달 가능한 어떤 리소스에도 더 이상 참조되지 않습니다. 결과적으로 가비지 컬렉터에 의해 도달 불가능으로 발견되고 할당된 메모리가 회수됩니다.

그러나 가비지 컬렉션을 수동으로 제어할 수 없다는 점은 여전합니다. 언제 어떤 메모리를 해제할지 수동으로 결정하는 것이 편리할 때가 있습니다. 객체의 메모리를 해제하려면 그 객체를 명시적으로 도달 불가능하게 만들어야 합니다. 또한 JavaScript에서 가비지 컬렉션을 프로그래밍 방식으로 트리거하는 것은 불가능하며 — 핵심 언어에서는 아마 영원히 불가능할 것입니다. 다만 엔진이 선택(opt-in) 플래그 뒤에 API를 노출할 수는 있습니다.

엔진의 메모리 모델 구성 (Configuring an engine's memory model)

JavaScript 엔진은 일반적으로 메모리 모델을 노출하는 플래그를 제공합니다. 예를 들어 Node.js는 메모리 문제를 구성하고 디버깅하기 위한 기저 V8 메커니즘을 노출하는 추가 옵션과 도구를 제공합니다. 이 구성은 브라우저에서는 제공되지 않을 수 있으며, 웹 페이지(HTTP 헤더 등을 통해)에서는 훨씬 더 제공될 가능성이 낮습니다.

사용 가능한 힙 메모리의 최대량은 플래그로 늘릴 수 있습니다.

node --max-old-space-size=6000 index.js

플래그와 Chrome Debugger를 사용해 메모리 문제 디버깅을 위해 가비지 컬렉터를 노출할 수도 있습니다.

node --expose-gc --inspect index.js

메모리 관리에 도움이 되는 데이터 구조 (Data structures aiding memory management)

JavaScript는 가비지 컬렉터 API를 직접 노출하지 않지만, 언어는 가비지 컬렉션을 간접적으로 관찰하고 메모리 사용량을 관리하는 데 사용할 수 있는 여러 데이터 구조를 제공합니다.

WeakMaps와 WeakSets

WeakMapWeakSet은 API가 약하지 않은 대응물인 MapSet을 밀접하게 반영하는 데이터 구조입니다. WeakMap은 키-값 쌍의 컬렉션을 유지할 수 있게 하고, WeakSet은 고유 값의 컬렉션을 유지할 수 있게 하며, 둘 다 성능 좋은 추가, 삭제, 질의가 가능합니다.

WeakMapWeakSet은 약하게 유지되는 값(weakly held values)이라는 개념에서 이름을 얻었습니다. xy에 의해 약하게 유지된다면, y를 통해 x의 값에 접근할 수는 있지만 다른 무엇도 x를 강하게 유지하지 않는다면 표시-쓸기 알고리즘이 x를 도달 가능한 것으로 간주하지 않는다는 뜻입니다. 여기서 논의하는 구조를 제외한 대부분의 데이터 구조는 언제든 검색할 수 있도록 전달된 객체를 강하게 유지합니다. WeakMapWeakSet의 키는, 프로그램의 다른 무엇도 그 키를 참조하지 않는 한 가비지 컬렉션될 수 있습니다(WeakMap 객체의 경우 값도 가비지 컬렉션 대상이 됩니다). 이는 두 가지 특성으로 보장됩니다.

  1. WeakMapWeakSet은 객체 또는 symbol만 저장할 수 있습니다. 객체만 가비지 컬렉션되기 때문입니다. 원시 값은 항상 다시 만들어질 수 있으므로(즉 1 === 1이지만 {} !== {}) 영원히 컬렉션에 남게 됩니다. 등록된 symbol(Symbol.for("key") 같은)도 다시 만들어질 수 있어 가비지 컬렉션되지 않지만, Symbol("key")로 만든 symbol은 가비지 컬렉션됩니다. Symbol.iterator 같은 잘 알려진 symbol은 고정된 집합으로 제공되며 Array.prototype 같은 내장 객체와 유사하게 프로그램 수명 동안 고유하므로 키로 허용됩니다.
  2. WeakMapWeakSet은 반복 가능하지 않습니다(iterable). 이는 Array.from(map.keys()).length를 사용해 객체의 생존 여부를 관찰하거나, 가비지 컬렉션 대상이 될 수 있는 임의의 키를 얻는 것을 방지합니다. (가비지 컬렉션은 가능한 한 보이지 않아야 합니다.)

WeakMapWeakSet에 대한 일반적인 설명(위와 같은)에서는 키가 먼저 가비지 컬렉션되어 값도 가비지 컬렉션되게 한다고 암시하는 경우가 많습니다. 그러나 값이 키를 참조하는 경우를 고려해 보세요.

const wm = new WeakMap();
const key = {};
wm.set(key, { key });
// Now `key` cannot be garbage collected,
// because the value holds a reference to the key,
// and the value is strongly held in the map!

key가 실제 참조로 저장되면 순환 참조를 만들어 다른 무엇도 key를 참조하지 않을 때조차 키와 값 모두를 가비지 컬렉션 대상에서 제외시킵니다. 왜냐하면 key가 가비지 컬렉션되면 어떤 특정 순간에 value.key가 존재하지 않는 주소를 가리키게 되어 비합법적이기 때문입니다. 이를 고치기 위해 WeakMapWeakSet의 엔트리는 실제 참조가 아니라 ephemeron으로, 표시-쓸기 메커니즘의 개선입니다. Barros 외 연구자들은 이 알고리즘에 대한 좋은 요약을 제공합니다(4쪽). 한 단락을 인용하자면:

Ephemeron은 약한 쌍(weak pair)의 정제로, 키와 값 중 어느 것도 약하거나 강하다고 분류할 수 없습니다. 키의 연결성(connectivity)이 값의 연결성을 결정하지만, 값의 연결성은 키의 연결성을 영향 주지 않습니다. […] 가비지 컬렉션이 ephemeron을 지원할 때, 그것은 두 단계(표시와 쓸기) 대신 세 단계로 발생합니다.

대략적인 머릿속 모델로서 WeakMap을 다음과 같은 구현으로 생각해 보세요.

경고: 이것은 폴리필도 아니고 엔진에서의 구현(가비지 컬렉션 메커니즘에 걸리는)과는 거리가 멉니다.

class MyWeakMap {
  #marker = Symbol("MyWeakMapData");
  get(key) {
    return key[this.#marker];
  }
  set(key, value) {
    key[this.#marker] = value;
  }
  has(key) {
    return this.#marker in key;
  }
  delete(key) {
    delete key[this.#marker];
  }
}

보시다시피 MyWeakMap은 실제로 키 컬렉션을 유지하지 않습니다. 전달되는 각 객체에 메타데이터를 추가할 뿐입니다. 그러면 그 객체는 표시-쓸기를 통해 가비지 컬렉션될 수 있습니다. 따라서 WeakMap의 키를 반복하거나 WeakMap을 비우는 것은(전체 키 컬렉션에 대한 지식을 필요로 하므로) 불가능합니다.

API에 대한 자세한 내용은 키 기반 컬렉션 가이드를 참고하세요.

WeakRefs와 FinalizationRegistry

참고: WeakRefFinalizationRegistry는 가비지 컬렉션 메커니즘에 대한 직접적인 내부 관찰을 제공합니다. 런타임 의미론이 거의 완전히 보장되지 않으므로 가능하면 사용을 피하세요.

값으로 객체를 가지는 모든 변수는 그 객체에 대한 참조입니다. 그러나 그러한 참조는 강합니다 — 그 존재가 가비지 컬렉터가 객체를 수집 대상으로 표시하는 것을 막습니다. WeakRef는 객체에 대한 약한 참조(weak reference)로, 객체가 가비지 컬렉션되도록 허용하면서도 수명 동안 객체의 내용을 읽는 능력은 유지합니다.

WeakRef의 한 가지 사용 사례는 문자열 URL을 큰 객체에 매핑하는 캐시 시스템입니다. 이 목적에는 WeakMap을 사용할 수 없습니다. WeakMap 객체는 키를 약하게 유지하지 값은 약하게 유지하지 않기 때문입니다 — 키에 접근하면 항상 결정적으로 값을 얻습니다(키에 접근할 수 있다는 것은 키가 여전히 살아 있다는 뜻). 여기서는 키에 대해 undefined를 얻어도 괜찮습니다(대응하는 값이 더 이상 살아 있지 않다면 값만 다시 계산하면 되므로). 그러나 도달 불가능한 객체가 캐시에 남는 것은 원하지 않습니다. 이 경우 일반 Map을 사용하되, 각 값을 실제 객체 값 대신 객체의 WeakRef로 만들 수 있습니다.

function cached(getter) {
  // A Map from string URLs to WeakRefs of results
  const cache = new Map();
  return async (key) => {
    if (cache.has(key)) {
      const dereferencedValue = cache.get(key).deref();
      if (dereferencedValue !== undefined) {
        return dereferencedValue;
      }
    }
    const value = await getter(key);
    cache.set(key, new WeakRef(value));
    return value;
  };
}

const getImage = cached((url) => fetch(url).then((res) => res.blob()));

FinalizationRegistry는 가비지 컬렉션을 관찰하는 더 강력한 메커니즘을 제공합니다. 객체를 등록하고 가비지 컬렉션될 때 알림을 받을 수 있게 해줍니다. 예를 들어 위에 예시한 캐시 시스템에서 blob 자체가 수집 자유로울 때조차 그것을 유지하는 WeakRef 객체는 그렇지 않으며 — 시간이 지나면 Map에 쓸모없는 엔트리가 많이 쌓일 수 있습니다. FinalizationRegistry를 사용하면 이 경우 정리(cleanup)를 수행할 수 있습니다.

function cached(getter) {
  // A Map from string URLs to WeakRefs of results
  const cache = new Map();
  // Every time after a value is garbage collected, the callback is
  // called with the key in the cache as argument, allowing us to remove
  // the cache entry
  const registry = new FinalizationRegistry((key) => {
    // Note: it's important to test that the WeakRef is indeed empty.
    // Otherwise, the callback may be called after a new object has been
    // added with this key, and that new, alive object gets deleted
    if (!cache.get(key)?.deref()) {
      cache.delete(key);
    }
  });
  return async (key) => {
    if (cache.has(key)) {
      return cache.get(key).deref();
    }
    const value = await getter(key);
    cache.set(key, new WeakRef(value));
    registry.register(value, key);
    return value;
  };
}

const getImage = cached((url) => fetch(url).then((res) => res.blob()));

성능과 보안 우려로 인해 콜백이 언제 호출될지, 심지어 호출될지조차 보장되지 않습니다. 이는 정리 — 그리고 중요하지 않은 정리 — 에만 사용해야 합니다. 더 결정적인 자원 관리를 위한 다른 방법이 있습니다. 예를 들어 항상 finally 블록을 실행하는 try...finally가 있습니다. WeakRefFinalizationRegistry는 오로지 장기 실행 프로그램에서 메모리 사용량을 최적화하기 위해 존재합니다.

WeakRefFinalizationRegistry API에 대한 자세한 내용은 해당 참조 페이지를 참고하세요.

더 알아보기