ReactNextCentral
Published on

JavaScript에서 메모리 관리: 가비지 컬렉션

JavaScript 메모리 관리와 가비지 컬렉션을 이해하는 것은 효율적인 웹 애플리케이션 개발의 핵심입니다; 이 블로그에서는 그 중심 원칙과 기법을 함께 탐험해보겠습니다.
JavaScript에서 메모리 관리: 가비지 컬렉션
Authors
Table of Contents

가비지 컬렉션 소개

프로그래밍에서 메모리 관리는 어떤 언어로 작업하더라도 핵심적인 부분입니다. 특히, JavaScript에서는 "가비지 컬렉션(Garbage Collection)"이라는 자동 메모리 관리 시스템이 메모리 할당과 해제를 대신 처리해 줍니다.

가비지 컬렉션은 애플리케이션에서 메모리를 효율적으로 사용하게 하며, 메모리 누수를 방지하고 전반적인 성능 최적화에 기여합니다. 이 포스트에서는 JavaScript 메모리 관리의 세계로 깊게 탐구하며, 특히 가비지 컬렉션의 세부사항에 중점을 둘 것입니다. 우리는 이를 관리하는 메커니즘, 알고리즘, 그리고 실용적인 예제를 통해 여러분의 JavaScript 애플리케이션에서 메모리가 어떻게 관리되는지 확실하게 이해할 수 있게 도와드리겠습니다.

JavaScript 메모리 관리 소개

JavaScript에서의 메모리 관리는 프로그램이 필요로 할 때 메모리를 할당하고 해제하는 과정을 포함합니다. C나 C++ 같은 저수준 언어들과는 달리, JavaScript는 메모리 관리를 추상화하여, 개발자들이 명시적인 메모리 할당이나 해제 없이 애플리케이션을 만드는데 집중할 수 있게 합니다.

간단한 예제를 살펴봅시다:

function createHeavyObject() {
  let data = new Array(1000000).fill('data');
  return {
    getData() {
      return data;
    },
  };
}

function main() {
  let obj = createHeavyObject();
  console.log(obj.getData());
  // obj는 더 이상 필요하지 않습니다.
}

main();

이위의 코드는 JavaScript의 메모리 관리와 가비지 컬렉션에 관한 예제입니다. 구체적으로는:

  1. createHeavyObject 함수는 큰 양의 메모리를 사용하는 객체를 생성합니다. 여기서는 1,000,000 개의 'data' 문자열을 가진 배열을 생성하고, 이 배열에 접근할 수 있는 메서드 getData를 가진 객체를 반환합니다.
  2. main 함수는 createHeavyObject 함수를 호출하여 메모리에 큰 객체를 할당하고, 이 객체의 getData 메서드를 사용하여 데이터를 콘솔에 출력합니다.
  3. main 함수의 실행이 끝나면, obj는 더 이상 참조되지 않습니다. 주석에서 언급한 것처럼 "obj는 더 이상 필요하지 않습니다." 이는 obj가 가비지 컬렉션의 대상이 될 수 있다는 것을 의미합니다. 따라서, 메모리 관리 시스템 (즉, 가비지 컬렉터)은 obj와 관련된 메모리를 회수할 수 있습니다.

이 예제는 개발자가 의도적으로 메모리를 해제하지 않아도, 사용되지 않는 객체는 자동으로 가비지 컬렉션의 대상이 될 수 있음을 보여줍니다.

가비지 컬렉션(Garbage Collection)이란?

가비지 컬렉션애플리케이션에서 더 이상 사용되지 않는 메모리를 식별하고 정리하는 과정입니다. JavaScript는 메모리 사용량을 모니터링하고, 사용되지 않는 객체를 식별하여 그들의 메모리를 재활용하기 위한 자동 가비지 컬렉터를 사용합니다. 이 과정은 참조되지 않는 객체에 의해 메모리가 소비되는 메모리 누수를 방지합니다.

가비지 컬렉터(Garbage Collector)는 백그라운드에서 작동하며, 메모리 내의 객체간의 참조 관계를 분석합니다. 객체가 더 이상 참조를 통해 접근할 수 없으면, 그 객체는 가비지 컬렉션 대상으로 간주됩니다. 컬렉터는 이러한 객체를 식별하고 그들의 메모리를 해제하여 새로운 할당을 위해 사용 가능하게 합니다.

가비지 컬렉션 알고리즘에는 세 가지 유형이 있습니다:

1. 참조 카운팅(Reference Counting)

이 알고리즘은 각 객체에 대한 참조 횟수를 추적합니다. 참조 횟수가 0이 되면, 해당 객체는 더 이상 접근할 수 없게 되어 수집될 수 있습니다. 그러나, 이 방법은 순환 참조를 효과적으로 처리하지 못하며 메모리 누수를 일으킬 수 있습니다.

2. 마크 및 스위프(Mark and Sweep)

이 널리 사용되는 알고리즘에서, 가비지 컬렉터는 모든 접근 가능한 객체를 순회하며 이들을 "생존"으로 표시합니다. 그 다음, 메모리를 스캔하여 표시되지 않은 (사라진) 객체가 차지하고 있는 메모리를 회수합니다.

3. 세대별 컬렉션(Generational Collection)

이 방법은 객체를 그들의 연령에 따라 다른 세대로 나눕니다. 대부분의 객체는 젊은 나이에 사라지기 때문에 세대별 컬렉터는 젊은 객체(최근에 생성된 것들)에 초점을 맞춥니다. 여러 번의 컬렉션을 거친 후 생존하는 객체들은 더 오래된 세대로 승격됩니다. 이 메커니즘은 수집 노력을 가장 필요한 곳에 집중함으로써 효율성을 향상시킵니다.

세대별 컬렉션 알고리즘 상세

최근 JavaScript의 가비지 컬렉션 사용 알고리즘

최근 JavaScript의 주요 엔진인 V8 (Chrome과 Node.js에서 사용되는 엔진)은 주로 세대별 컬렉션(Generational Collection) 알고리즘을 사용합니다. 이 알고리즘은 객체들을 "young generation"와 "old generation"로 나눕니다.

  1. Young Generation: 이곳에는 최근에 생성된 객체들이 위치하게 됩니다. 여기서의 가비지 컬렉션은 상대적으로 자주 발생하지만 매우 빠르게 처리됩니다.

  2. Old Generation: Young Generation에서 오랜 시간 동안 살아남은 객체들이 여기로 이동합니다. Old Generation에서의 가비지 컬렉션은 덜 자주 발생하지만, 처리 시간이 Young Generation에 비해 길어질 수 있습니다.

또한, V8은 Mark-SweepIncremental Marking, Lazy Sweeping과 같은 다양한 최적화 기법들도 함께 사용하여 가비지 컬렉션의 효율성을 높이고, 애플리케이션의 중단 시간을 최소화합니다.

다른 JavaScript 엔진들도, 예를 들면 SpiderMonkey (Firefox에서 사용)나 JavaScriptCore (Safari에서 사용)도 비슷한 세대별 가비지 컬렉션 전략을 사용하며, 각자의 특성에 맞게 최적화된 방법을 적용하고 있습니다.

Generational Collection 알고리즘의 주요 장점은 대부분의 객체가 빠르게 메모리에서 사라지는 경향이 있어, Young Generation에서 빠르게 메모리를 정리할 수 있다는 점입니다. 그러나 이 알고리즘 또한 완벽하지 않으며 몇 가지 단점과 함께 개발자가 주의해야 할 코딩 패턴들이 있습니다.

세대별 컬렉션 알고리즘 단점

  1. Old Generation의 메모리 부족: Young Generation에서 오랜 시간 동안 살아남아 Old Generation으로 이동하는 객체가 많아진다면, Old Generation에서의 가비지 컬렉션은 더 큰 부하와 더 긴 시간을 필요로 할 것입니다. 이로 인해 애플리케이션의 반응성에 영향을 줄 수 있습니다.

  2. 최적화 오버헤드: 객체가 Young에서 Old로 이동하는 절차나, 이를 판단하는 알고리즘 등에는 추가적인 계산이 필요합니다.

조심해야 할 코딩 패턴:

  1. 빠르게 생성 및 삭제되는 큰 객체: 큰 객체가 Young Generation에서 빠르게 생성되고 삭제된다면, 이러한 객체들은 빠르게 메모리를 사용하고 반환하는 패턴을 만들게 됩니다. 이로 인해 메모리 관리에 부담이 될 수 있습니다.

  2. 장기간 유지되는 임시 객체: 임시적으로 사용되는 객체가 오랜 시간 동안 메모리에서 해제되지 않는다면, 이러한 객체들은 Old Generation으로 이동하게 될 것입니다. 이로 인해 Old Generation에서의 가비지 컬렉션 부하가 증가할 수 있습니다.

  3. 순환 참조: 이는 Generational Collection 알고리즘에만 특정되는 문제는 아니지만, 객체들 사이에 순환 참조가 발생할 경우, 가비지 컬렉션에서 해당 객체들을 제대로 회수하지 못할 위험이 있습니다.

개발자는 위와 같은 코딩 패턴들을 피하고, 필요한 경우 메모리 프로파일링 도구를 사용하여 애플리케이션의 메모리 사용 패턴을 모니터링하여 적절한 조치를 취해야 합니다.

메모리 누수(Memory Leaks) 파악하기

메모리 누수객체들이 불필요하게 메모리에 계속 유지되어 리소스를 불필요하게 소비할 때 발생합니다. 주요 원인으로는 의도하지 않은 클로저(Unintentional Closures), 순환 참조(Circular References), 그리고 잊혀진 이벤트 리스너(Forgotten Event Listeners)가 있습니다.

예제: 의도하지 않은 클로저

의도하지 않은 클로저로 인해 메모리 누수를 발생시키는 일반적인 예제는 이벤트 리스너에 클로저를 사용하는 경우입니다.

다음은 이러한 상황의 간단한 예제입니다.

function attachEventListener(element) {
  let largeData = new Array(1000000).fill('data');

  element.addEventListener('click', function() {
    console.log(largeData);
  });

  // 어딘가에 이벤트 리스너를 제거하는 코드가 필요합니다.
  // 예: element.removeEventListener('click', ...);
}

const btn = document.createElement('button');
document.body.appendChild(btn);

attachEventListener(btn);

// 나중에 btn을 DOM에서 제거해도, 
// 클릭 이벤트 리스너의 클로저로 인해 largeData는 메모리에서 해제되지 않습니다.

위의 코드에서, attachEventListener 함수는 큰 데이터 배열 largeData와 이벤트 리스너를 가집니다. 이 리스너는 클로저로 largeData에 접근합니다. 버튼 요소에서 이벤트 리스너를 제거하지 않는 한, largeData는 메모리에서 해제되지 않습니다.

이 문제를 해결하려면 이벤트 리스너를 적절히 제거해야 합니다. 그러나 이 예제에서는 의도적으로 이벤트 리스너 제거를 생략하여 클로저에 의한 메모리 누수를 보여주고 있습니다.

예제: 순환 참조

순환 참조는 특히 객체가 서로를 참조하는 경우에 발생할 수 있습니다. JavaScript의 가비지 컬렉션은 일반적으로 순환 참조를 잘 처리하지만, 참조 카운팅 방식의 가비지 컬렉터를 사용하는 환경에서는 문제가 될 수 있습니다.

다음은 순환 참조를 만드는 간단한 예제입니다:

function createCircularReference() {
  let objA = {};
  let objB = {};

  objA.referenceToB = objB;
  objB.referenceToA = objA;

  return {
    objA: objA,
    objB: objB
  };
}

const circular = createCircularReference();

// circular.objA와 circular.objB는 서로를 참조합니다.
// 만약 참조 카운팅 방식의 가비지 컬렉터를 사용하는 환경에서는 이런 순환 참조로 인해 메모리 누수가 발생할 수 있습니다.

위의 코드에서 objAobjB를 참조하고, objBobjA를 참조합니다. 이러한 순환 참조는 참조 카운팅 방식을 사용하는 가비지 컬렉터에서 메모리를 올바르게 해제하지 못하게 만들 수 있습니다.

그러나 현대의 대부분의 JavaScript 엔진(예: V8)은 이런 순환 참조도 잘 처리합니다. 그래도 개발자는 이러한 패턴을 피하는 것이 좋습니다.

예제: 잊혀진 이벤트 리스너

이벤트 리스너는 종종 메모리 누수의 원인이 될 수 있습니다. 특히 DOM 요소에 이벤트 리스너를 추가한 후, 해당 요소를 제거하거나 사용하지 않게 되었을 때, 이벤트 리스너를 명시적으로 제거하지 않으면 메모리 누수가 발생할 수 있습니다.

아래는 "잊혀진 이벤트 리스너"로 인한 메모리 누수 예제입니다:

class MemoryLeakExample {
  constructor() {
    this.button = document.createElement('button');
    this.button.innerText = "Click me!";
    document.body.appendChild(this.button);

    this.button.addEventListener('click', this.handleClick);
  }

  handleClick() {
    alert("Button clicked!");
  }

  removeButton() {
    // 버튼을 DOM에서 제거
    document.body.removeChild(this.button);
    // 하지만, 이벤트 리스너는 여전히 남아 있어 메모리 누수가 발생합니다.
  }
}

const example = new MemoryLeakExample();

// 어느 시점에 버튼을 제거
setTimeout(() => {
  example.removeButton();
}, 5000);

이 예제에서 button 요소에는 handleClick 이벤트 리스너가 연결되어 있습니다. removeButton 메서드를 호출하여 버튼을 DOM에서 제거하면, 버튼 DOM 요소는 메모리에서 해제되지 않습니다. 왜냐하면 handleClick 리스너가 여전히 해당 요소에 연결되어 있기 때문입니다.

이러한 문제를 해결하려면, 요소를 제거하기 전에 removeEventListener를 사용하여 이벤트 리스너를 명시적으로 제거해야 합니다.

메모리 누수 관리 방법

Chrome DevTools의 메모리 패널과 같은 메모리 프로파일링 도구를 사용하면 메모리 누수를 쉽게 파악할 수 있습니다. Next.js와 React 웹 개발을 할 때도 이러한 메모리 누수는 성능 저하의 주요 원인이 될 수 있으므로, 주의 깊게 관찰하고 관리하는 것이 중요합니다.

메모리 관리를 위한 모범 사례

  • 글로벌 변수 최소화: 글로벌 변수의 사용을 줄이면 의도하지 않은 객체 유지의 가능성을 제한할 수 있습니다.
  • 이벤트 리스너 적절히 관리: 더 이상 필요하지 않은 이벤트 리스너는 제거하여 객체가 메모리에 계속 유지되는 것을 방지하세요.
  • 순환 참조 피하기: 객체 간의 순환 참조를 생성할 때 주의하세요. 이는 메모리 누수로 이어질 수 있습니다.
  • let과 const 사용하기: 적절한 스코프를 확보하고 의도하지 않은 글로벌 변수 선언을 피하기 위해 var 대신 let과 const를 사용하는 것을 선호하세요.

1. 글로벌 변수 최소화

글로벌 변수는 전체 어플리케이션에서 접근 가능하기 때문에 의도하지 않게 데이터를 유지하는 경향이 있습니다. 따라서 가능하면 글로벌 변수를 최소화하고, 필요한 경우 모듈 패턴 등을 활용하여 변수를 캡슐화하는 것이 좋습니다.

나쁜 예시:

var globalData = "This is a global variable";

function displayData() {
    console.log(globalData);
}

좋은 예시:

(function() {
    var privateData = "This is encapsulated data";

    function displayData() {
        console.log(privateData);
    }
})();

2. 이벤트 리스너 적절히 관리

이벤트 리스너는 메모리 누수의 주요 원인 중 하나입니다. 객체나 요소에 이벤트 리스너를 추가하면 해당 리스너가 메모리에 유지됩니다. 따라서 필요하지 않을 때 이벤트 리스너를 제거하는 것이 중요합니다.

나쁜 예시:

const button = document.getElementById('myButton');
button.addEventListener('click', () => {
    console.log('Button clicked');
});

// button 요소가 필요없게 되었을 때 리스너를 제거하지 않음.

좋은 예시:

const button = document.getElementById('myButton');
const clickHandler = () => {
    console.log('Button clicked');
};
button.addEventListener('click', clickHandler);

// button 요소가 필요없게 되었을 때 리스너를 제거.
button.removeEventListener('click', clickHandler);

3. 순환 참조 피하기

순환 참조는 두 객체가 서로를 참조하는 것을 의미합니다. 이는 가비지 컬렉터가 제대로 메모리를 해제하지 못하게 만들 수 있습니다.

나쁜 예시:

let objA = {};
let objB = {};

objA.reference = objB;
objB.reference = objA;
순환 참조를 해결 방법

순환 참조를 해결하기 위해서는 객체 간의 서로 참조하는 구조를 끊어줘야 합니다. 간단한 해결 방법 중 하나는 참조를 명시적으로 끊는 것입니다. 그러나 실제 애플리케이션에서는 이 문제가 좀 더 복잡할 수 있으므로, 주기적으로 코드를 검토하고 순환 참조를 피하도록 주의를 기울여야 합니다.

해결 방법:

  1. 객체 간의 참조 구조를 재평가합니다.
  2. WeakMap, WeakSet 같은 약한 참조를 활용합니다. 이러한 구조는 가비지 컬렉션 대상이 될 수 있도록 객체에 대한 참조를 약하게 유지합니다.
  3. 객체의 생명주기와 관련된 리스너나 콜백을 적절히 관리합니다.

좋은 예시:

순환 참조를 피하기 위해 WeakMap을 사용하는 예시입니다:

let objA = {};
let weakmap = new WeakMap();

let objB = {
  destructor() {
    console.log("objB is being garbage collected");
  }
};

weakmap.set(objA, objB);
objB = null; // objB 참조를 제거

// objA가 가비지 컬렉션 될 때, objB도 함께 가비지 컬렉션 대상이 됩니다.

여기서 WeakMapobjA를 키로 사용하여 objB를 참조합니다. 그러나 이 WeakMap에 대한 참조가 없어지면, objB도 가비지 컬렉션 대상이 됩니다. 이 방식으로 순환 참조 문제를 피할 수 있습니다.

4. let과 const 사용하기

letconst는 블록 스코프 변수를 선언하는데 사용되며, var에 비해 예측 가능한 스코프와 동작을 제공합니다.

나쁜 예시:

for (var i = 0; i < 10; i++) {
    // ...
}
console.log(i);  // 10

좋은 예시:

for (let i = 0; i < 10; i++) {
    // ...
}
console.log(i);  // Error: i is not defined

이렇게 각 모범 사례에 대한 확장 설명과 예제를 통해 메모리 관리의 중요성을 이해하고 최적화된 코드를 작성하는 데 도움이 되기를 바랍니다.

Next.js나 React에서도 이러한 메모리 관리 원칙은 웹 애플리케이션의 성능과 안정성에 큰 영향을 미치므로 주의 깊게 적용해야 합니다.

결론

JavaScript의 메모리 관리와 가비지 컬렉션을 이해하는 것은 효율적이고 견고한 애플리케이션을 작성하기 위해 필수적입니다. 가비지 컬렉션 알고리즘의 원칙을 파악하고, 메모리 누수를 식별하며, 모범 사례를 따르면 개발자는 메모리 사용을 최적화하고 과도한 메모리 소비로 인한 성능 병목을 방지할 수 있습니다.

적절한 메모리 관리는 웹 애플리케이션의 전반적인 안정성과 반응성에 기여합니다. 특히, Next.js와 React를 사용하여 웹 애플리케이션을 구축할 때 이러한 원칙은 더욱 중요해집니다.