[CS]메모리 누수(Memory Leak)와 대처법

신세원·2021년 10월 6일
4

CS

목록 보기
2/8
post-thumbnail

가비지 컬렉션과 동작 방식
앞 전 포스팅에서 가비지와,가비지 컬렉터, 가비지 컬렉션에 대해 알아보았고, 마지막에 가비지 컬렉션 작동 방식에 대해 설명하던 중, 알고리즘 한계점의 발견, 즉 메모리 누수(Memory Leak)에 대해 잠깐 언급하였다.

오늘은 메모리 누수는 왜 발생이 되는 것인지 알아보기로 하자.

1. 메모리 누수(Memory Leak) 사례 - 4가지

1-1. 글로벌 변수(Global Vareables)

우리는 코딩하면서 한번 이상 전역 변수의 사용을 지양하라는 소리를 들어본적이 있을것이다. 왜일까? 단순한 버그의 문제 때문에?
버그의 문제도 맞지만 가장 중요한건, 무분별한 글로벌 변수 사용시 원치않는 메모리 누수로 빠지게 때문이다.

window -> global Object
node -> global Object

보통 브라우저에서는 window라는 global Object가 있고, node에서는 golbal이라는 global Object가 있다.

function foo(arg) {
    bar = "Hello World";
}

위 코드에서 보면 함수 foo안에 bar라는 변수에 "Hello World"라는 string을 할당 받는 것을 볼 수 있다.
여기서 특징은 bar라는 변수를 선언할때 어떠한 키워드도 사용하고 있지 않다는 점이다.
그럼 이렇게 되면 어떠한 현상이 발생될까?

이렇게 키워드를 사용하지않고 변수를 선언하게 되면 자동으로 window 라는 global Objectproperty로 설정이 된다.

이렇게 됐을때 문제점은, foo라는 함수만 bar라는 변수를 참조하는 것이 아니라, 글로벌에 있는 함수에서 bar라는 변수를 참조하게 된다.

원래 내부의 bar라는 변수가foo라는 함수의 지역 변수로 할당이되고 나서, foo가 실행이되고 나서 종료가 되면, bar는 변수로써 남아있지 않고 release되게 되는데, windowproperty로 등록이 되어 버리면 메모리가 release되지 않게 되어 버린다.
즉,사용되어지지 않음에도 불구하고, 가비지컬렉터가 메모리를 수거해야하는데 수거를 못해간다는 뜻이다.

또한, this를 이용해서도 뜻하지 않은 전역 변수를 생성 할 수 있다.

function foo() {
    this.var1 = "글로벌 변수 입니다.";
}
// 다른 함수 내에 있지 않은 foo를 호출하면 this는 글로벌 객체(window)를 가리킴
foo();

일반적인 함수내의 this 키워드는 바로 window를 가르킨다.

따라서, this.var1 === window.var1 과 동일하다고 볼 수 있다.

이렇게 되면 위에서 보았던 똑같은 문제를 야기될 수 있다.

그렇기에 함수안에서 변수를 선언할때는 꼭 앞에 키워드인 const,let을 작성해줘야 메모리 누수를 방지할 수 있다.(var는 이제 안쓰임으로 제외)

1-2. 잊혀진 타이머와 콜백

잊혀진 타이머와 콜백? 이게 무슨 뜻이지? 라고 생각하는 사람들이 있을것이다.

var serverData = loadData();
setInterval(function() {
    var renderer = document.getElementById('renderer');
    if(renderer) {
        renderer.innerHTML = JSON.stringify(serverData);
    }
}, 5000); // 매 5초 마다 실행

위 코드를 보고 한가지 가정을 해보자.
지금 DOM에서 가져온 renderer라는 객체가 바뀔수가 있고, 제거될 수 있다.
이럴 경우에는 더이상 renderer안에 그려지게 될 serverData는 더이상 바뀌거나 제거된 renderer안에는 더이상 참조될 일이 없어진다.
그렇지만 우리가 setInterval로 핸들러가 5초마다 실행이 되도록 설정을 해놓았기 때문에 serverData의 참조는 없어지지 않게 되버린다.

Observer의 경우, 더 이상 필요하지 않은 경우 (또는 관련 객체에 접근하지 못하게 하려는 경우) 해당객체를 제거하기 위해 명시적으로 호출하는 것이 중요하다.
과거 특정 브라우저 (IE6)가 순환 참조를 잘 관리하지 못했기 때문에 이 부분은 특히 중요하다. (자세한 내용은 아래 참조)
하지만 현대의 대부분의 브라우저는 observe 객체가 더 이상 참조되지 않는다면, 리스너가 명시적으로 제거되지 않는다 하더라도 메모리를 해제 한다.
객체를 없애기전에 이러한 observer를 명시적으로 제거하는 것은 좋은 관례다.
아래 예가 좋은 예시이다.

var element = document.getElementById('launch-button');
var counter = 0;
function onClick(event) {
   counter++;
   element.innerHtml = 'text';
}
element.addEventListener('click', onClick);
// 필요한 작업 수행
element.removeEventListener('click', onClick);
element.parentNode.removeChild(element);
// 이제 `element` 참조를 없앰으로서, 가비지 컬렉터가 가비지로 인식하여 수거해간다. 
// `element`와 `onClick`모두 스코프에서 사라진다.
// 오래된 브라우저에서는 이러한 순환참조를 잘 해결하지 못했다.

1-3. 클로저(Closures)

클로저에 대해서는 이야기할 부분이 너무 많아 따로 정리를 해놓았고, 자세한 부분은 그 내용을 참고해보자.
Javascript Scope(유효범위),Closure(클로저)

그래도 간단하게 짚고 넘어가자면

클로저는 InnerFunction이다.
클로져는 자신을 감싸고 있는 바깥 함수의 변수에 접근할 수 있는 내부의 함수를 말한다.

var theThing = null;
var replaceThing = function () {
  var originalThing = theThing;
  var unused = function () {
    if (originalThing) // 'originalThing'에 대한 참조
      console.log("hi");
  };
  theThing = {
    longStr: new Array(1000000).join('*'),
    someMethod: function () {
      console.log("message");
    }
  };
};
setInterval(replaceThing, 1000);

위 코드의 동작 순서를 보도록 하자.

  • replaceThing이 호출 되면 theThing은 커다란 배열과 새로운 클로져(someMethod)를 포함하는 새로운 객체를 얻게된다.
  • 아직 originalThingunused 변수가 갖고 있는 클로져에 의해 참조되고 있다.(which is theThing variable from the previous call to replaceThing).
    기억해야할 점은 한 번 동일한 부모 스코프에 있는 클로져들에 대한 스코프가 생성되고, 이것은 공유된다.
  • 위의 경우 someMethod 클로져를 위해 생성된 스코프는 unused와 공유되었다.
  • unusedoriginalThing에 대한 참조를 갖고 있다.
  • unused가 다시 사용되지 않는다 해도 someMethodtheThing을 통해 replaceThing의 스코프 바깥에서 글로벌하게 사용될 수 있다.
  • 또한 someMethodunused와 클로져 스코프를 공유하기 때문에 unusedoriginalThing에 대해 갖고 있는 참조 때문에 강제로 활성 상태가 유지된다.
    (두 클로져 사이에 공유된 전체 스코프).
  • 이 때문에 가비지컬렉션이 작동하지 않습니다.

위 코드에서 someMethod 클로저에 의해 생성된 스코프는 unused와 공유되고, unusedoriginalThing을 참조하게 된다.
unused가 사용된적이 없어도 someMethodreplaceThing 스코프의 바깥에서 theThing을 통해 사용될 수 있다.
unusedoriginalThing을 참조한다는 사실 때문에 unused는 활성 상태가 유지되는데 왜냐하면 someMethodunused와 클로져 스코프를 공유하기 때문이다.

즉, 이와 같은 현상처럼 unused은 사용되어지지도 않으면서, release가 되지않는 메모리이고, 이것은 메모리 누수로 이어진다.

1-4. DOM에서 벗어난 요소를 참조

이 경우는 바로 코드를 보면서 설명하기로 한다.

var elements = {
    button: document.getElementById('button'),
    image: document.getElementById('image')
};
function doStuff() {
    elements.image.src = 'http://example.com/image_name.png';
}
function removeImage() {
    // image는 body 요소의 바로 아래 자식임
    document.body.removeChild(document.getElementById('image'));
    // image는 지웠지만, element의 button 요소 객체에 대한 참조가 아직 존재하여, image 요소는 아직도 메모리 상에 있고 가비지컬렉터가 수거할 수 없게된다.
}

여기서 중요한 점은 DOM Tree에서 가져온 image 요소를 element의 프로퍼티로 할당한 다음, 이후 image 요소를 제거 했다면, 반드시 button에서도 null값을 할당해줘야 한다.

그렇지 않으면 이 image 요소는 지워졌음에도 불구하고, 여전히 이 element를 참조하게 된다.

추가적으로 고려해야할 사항은 DOM 트리에서 내부 혹은 말단 노드에 대한 참조다.

  • 자바스크립트 코드에서 테이블의 특정 셀(<td/>)에 대한 참조를 가지고 있다고 한다.
  • 추후 DOM에서 이 table을 제거하기로 했지만, 여전히 셀에 대한 참조를 가지고 있게 된다.
  • 직관적으로 가비지 컬렉터가 해당 셀을 제외한 나머지를 해제할 것으로 보이지만,그렇지 않다.
  • 셀은 table의 자식노드고, 자식 노드는 부모 노드의 참조를 유지한다.
  • 그래서 table의 셀에 대한 참조로 인해 테이블 전체가 메모리에 유지 된다. DOM의 요소에 대해 참조할 때는 이점을 유의해야 한다.

<참고자료>
자바스크립트는 어떻게 작동하는가: 메모리 관리 + 4가지 흔한 메모리 누수 대처법

profile
생각하는대로 살지 않으면, 사는대로 생각하게 된다.

0개의 댓글