본문 바로가기
Javascript

[Javascript] 자바스크립트에서 메모리 누수의 4가지 형태

by kellis 2020. 10. 21.

이전 글에서 Closure를 다루면서 메모리 누수에 대해 언급한 바가 있습니다. 이 글에서는 자바스크립트에서 발생하는 4가지 형태의 메모리 누수를 살펴보고, 이러한 코드를 어떻게 제거할 수 있는지 알아보도록 하겠습니다.

자바스크립트에서 메모리 누수가 발생하는 경우는 크게 4가지로 아래와 같습니다. 

  • 우발적으로 생성된 전역 변수
  • 잊혀진 타이머 또는 콜백 함수
  • DOM 외부에서 참조
  • 특정한 경우의 Closure

 

각각 경우에 대해 살펴보기에 앞서, 자바스크립트에서 메모리를 어떻게 관리하는지 알아두는 것이 좋습니다. 

 

자바스크립트는 자바와 마찬가지로 Garbage Collected 언어입니다. 즉, 개발자가 메모리 관리에 있어 신경을 덜 쓰더라도, GC(Garbage Collector)가 주기적으로 할당된 메모리를 검사하여 사용하지 않는 경우 메모리를 해제합니다. 따라서 특정한 메모리가 여전히 필요한지, 애플리케이션의 코드에서 접근이 가능한지를 중점적으로 고려합니다. 특히 자바스크립트에서 발생하는 메모리 누수의 주요 원인이 예상치 못한 참조인 만큼, GC는 해당 메모리가 다른 코드에서 접근 가능한지 여부를 판단하는 것이 중요한 임무입니다. 

대부분의 GC는 mark-and-sweep이라는 알고리즘을 사용하는데(기본 베이스이며 최신 GC들은 여기서 더욱 진화된 형태라고 할 수 있습니다), 이 경우 크게 세 가지 절차를 따릅니다. 먼저 roots 목록을 생성합니다. 루트들은 일반적으로 전역 변수를 의미합니다. 자바스크립트의 경우 window가 대표적인 루트라고 할 수 있습니다. GC는 이 객체를 항상 유지하고, window의 자식 객체들도 항상 유지될 것으로 판단하여 폐기하지 않습니다(전역 변수를 남발하여  만들면 안 되는 이유 중 하나이기도 합니다) 그러고 나면 모든 루트를 검사하여 폐기되지 않도록 활성화 상태로 만듭니다. 활성화 표시가 되어 있지 않은 메모리가 바로 GC가 메모리 해제할 대상으로 인식되며, OS에 반환되게 됩니다. 

 

이렇듯 예상치 못한 참조는 여러 가지 이유로 더 사용되지 않으나 활성화 상태로 표시되어 해제되지 못하는 변수들이며, 인지하고 있다면 피할 수 있는 실수입니다. 그렇다면 네 가지 경우에 대해 자세히 살펴보겠습니다. 

 

1) 우발적으로 생성된 전역 변수

첫 번째 경우는 의도치 않게 생성되는 전역 변수입니다. 이 경우의 대부분은 함수 내부에서 var나 let, const 키워드로 변수를 생성해야 할 것을, 키워드를 깜빡하고 생략하면서 발생합니다. 함수 내부에서만 사용할 변수가 전역으로 생성되면서, 함수가 종료되어도 메모리가 해제되지 않는 것입니다.

function foo() {
    bar = "accidental global variables";
}
 
// 코드 실행시 아래와 같이 동작
 
function foo() {
    window.bar = "accidental global variables";
}

더불어 변수가 this 키워드를 통해 생성될 경우에도 위와 같이 전역 객체로 생성됩니다. 

function foo() {
    this.bar = "accidental global variables";
}
foo();
//이 경우 this는 window를 가리키게 됨

전역 변수는 재할당하거나 null을 주지 않는 한 회수되지 않습니다. 무엇보다도 이러한 전역 변수가 메모리에 영향을 주는 원인 중 하나는  캐시입니다. 캐시는 GC에 의해 수집되지 않고, 개발자가 코드상에서 해제할 수도 없습니다. 그 때문에 전역 변수의 데이터가 커지게 되고, 이 때문에 캐시 사이즈가 커지게 되면 방대한 메모리의 사용을 야기할 수 있습니다.

 

그러나 이 경우는 방지하기가 쉽습니다. 개발자가 조심하는 것도 방법이 될 수 있겠지만, 자바스크립트 파일 시작 부분에 'use strict'를 추가하여 전역 객체 생성을 방지하도록 엄격한 모드로 자바스크립트를 파싱하게 할 수 있습니다. 

 

2) 잊혀진 타이머 또는 콜백 함수

자바스크립트 코드를 작성하다 보면, setInterval 함수를 자주 사용하게 됩니다. 

var data = getData();
setInterval(function(){
    var tag = document.getElementById('result');
 
    if(tag){
        tag.innerHTML = data;
    }
}, 1000);

위 코드를 보면, setInterval 내부에서 외부 변수인 data를 참조하여 tag의 innerHTML 값을 변경하는 것을 볼 수 있습니다. 이때, tag 객체가 어떤 미래 시점에 제거되어야 한다고 가정하면, setInterval 내부의 함수는 더는 필요 없는 함수가 됩니다. 그러나 타이머 시간에 따라 계속 동작하게 되고 GC에 의해서 수집되지 않습니다. 따라서 매우 큰 데이터를 가지고 있을 수도 있는 data 변수도 수집되지 않으며 많은 메모리를 물고 있게 됩니다. 

 

이러한 형태를 observer 형태라고 하는데, 과거에는 observer들을 명시적으로 제거해주는 것이 매우 중요했습니다. IE6와 같은 특정 브라우저에서 제대로 관리되지 않아 문제를 야기할 수 있기 때문이었습니다.

 

[콜백 함수 명시적 해제]

//콜백함수의 경우로 명시적 해제를 살펴보면
 
val elem = document.getElementById('button');
 
function onClick(event){
    element.innerHTML = 'clicked';
}
 
elem.addEventListener('click', onClick);
 
elem.removeEventListener('click', onClick); //개발자가 아래와같이 elem DOM 노드를 삭제하고 싶다면 먼저 observer 관련 참조들을 해제해야 했습니다.
 
elem.parentNode.removeChild(elem);

그러나 현재 최신 브라우저들은 대부분 observer 객체가 더 사용되지 않으면 이를 탐지하여 수집합니다. jQuery와 같은 프레임워크, 라이브러리들 역시 노드를 제거하기 이전에 observer들, 위의 경우 listener들을 내부적으로 제거합니다. 따라서 더는 개발자가 이에 대해 신경 쓰지 않아도 되지만, 사용할 때 조금 더 유의할 필요는 있습니다. 

 

3) DOM 외부 참조

간혹 DOM 노드 자체를 자료구조 내에 저장하는 경우가 있습니다. 맵이나 배열에 저장하며, 보통 테이블에서 여러 행의 내용을 빠르게 업데이트하는 경우 이런 코드를 작성합니다. 이렇게 되면 DOM 요소를 DOM 트리와 맵(혹은 배열), 두 군데에서 참조하게 되고, 이 요소를 제거하고자 할 경우 두 군데 모두에서 제거해야 합니다. 

 

[Out of DOM references]

var elemMap = {
    button: document.getElementById('button'),
    image: document.getElementById('image'),
    text: document.getElementById('text')
};
 
function removeButton() {
    document.body.removeChild(document.getElementById('button'));
}

removeButton이라는 함수가 실행되었을 때, DOM에서는 button이 사라지지만, elemMap에서 여전히 참조하고 있기 때문에 GC에 의해 수집될 수 없고 메모리를 잡고 있게 됩니다. 위 코드는 간단하기 때문에 쉽게 해결할 수 있는 문제라고 생각할 수도 있습니다. 하지만 특정 테이블의 특정 셀을 맵에서 참조하고 있고, 애플리케이션의 다른 코드에서 이 테이블을 DOM에서 제거한다고 가정해 봅시다. 이 경우 특정 셀은 테이블의 자식 노드이기 때문에, 맵에서 셀을 참조하고 있는 이상 부모 노드 역시 메모리에 유지되게 되고, 따라서 테이블은 제거되었으나 GC에 의해 수집될 수 없습니다. 이러한 이유로 DOM의 요소를 참조하는 경우 주의해서 사용해야 합니다. 

 

4) 특정한 경우의 Closure

지난 글에서 SPA의 경우 메모리 누수가 발생할 수 있다고 언급한 바가 있습니다. 이는 특정한 경우에 Closure에서 메모리 누수가 발생하는 사례가 발견되면서 알려졌습니다.

var data = null;
 
var closureVar = function() {
    var innerData = data;
    var notUsedFunc = function() {
        if(innerData) {
            console.log('notUsedFunc');
        }
    };
 
    obj = {
        bigArr: new Array(1000000).join('*'),
        func: function() {
            console.log('func in obj');
        }
    };
    //innerData = null;
};
setInterval(closureVar, 1000);

setInterval 함수가 실행될 때마다, closureVar가 호출되어 bigArr이라는 큰 사이즈의 배열과 func라는 클로저를 생성합니다. 또한 notUsedFunc도 innerData를 참조하는 클로저를 만듭니다. 여기에 한 depth 더 들어가 innerData는 외부의 data 객체를 참조합니다.

이러한 코드가 문제가 되는 이유는 notUsedFunc 때문입니다. 이 내부 함수로 인해 closureVar 함수는 메모리를 해제할 수 없습니다. 최신 자바스크립트 엔진은 1 depth의 사용되지 않는 클로저에 대해서는 스코프를 해제해주지만, 2 depth 이상으로 넘어가게 되면 해제하지 못합니다. 만약 notUsedFunc가 존재하지 않았다면 bigArr이 호출 시마다 생성되기는 하겠지만, 주기적으로 이전 호출 객체의 메모리를 해제하였을 것입니다. 그런데 notUsedFunc가 innerData를 참조하고 있고 innerData가 data를 참조하고 있기 때문에 클로저의 참조 고리가 생성되게 됩니다. 따라서 interval에 따라 메모리 사용량은 계속해서 증가하게 되며 메모리 누수가 발생하게 됩니다. 

 

이러한 경우 closureVar가 종료되기 전에 innerData에 null을 할당함으로써 메모리 누수를 막을 수 있습니다. 

 

* 참고. 크롬 개발자도구를 이용한 메모리 누수 탐지

메모리 누수는 크게 두 가지 형태로 나타나게 되는데, 주기적으로 메모리 사용량이 증가하는 모양과 한 번의 사용만으로 메모리가 크게 증가하는 모양이 그것입니다. 전자의 경우 탐지는 어렵지 않지만, 브라우저가 느려지고 스크립트 실행이 중단될 수 있습니다. 후자의 경우에는 확 늘어나기 때문에 전자보다 상대적으로 쉽게 발견할 수 있지만, 흔히 발생하는 경우가 아니기 때문에 오히려 인지하지 못하는 경우가 많습니다. 따라서 어느 경우이든 반드시 해결해야 하는 오류입니다. 

크롬의 개발자도구(F12 클릭 시 나타나는 화면)를 이용하면 메모리의 사용  패턴을 확인할 수 있고, 메모리 누수가 발생하는지, 어떤 코드가 메모리를 얼마나 사용 중인지 등을 확인할 수 있습니다. 이는 Performance 메뉴(구버전의 경우 Timeline)와 Memory 메뉴(구버전의 경우 Profiles)를 이용하여 직관적으로 확인할 수 있습니다. Performance 메뉴가 한 번의 사용만으로 메모리가 크게 증가하는 모양을 탐지하는 데 주로 사용되며, Memory 메뉴는 주기적으로 사용량이 증가하는 추세를 보는 데 사용됩니다. 

 

더 자세한 탐지 내용은 4 Types of Memory Leaks in JavaScript and How to Get Rid Of Them - Chrome Memory Profiling Tools Overview에서 확인하실 수 있습니다.

 

 

 

'Javascript' 카테고리의 다른 글

Prototype  (0) 2020.11.15
[Javascript] Debounce & Throttle  (0) 2020.10.21
[Javascript] Closure  (0) 2020.10.21
[Javascript] 실행 컨텍스트  (0) 2020.10.21
[Javascript] Scope  (0) 2020.10.21

댓글