자바스크립트의 메모리 누수(Memory leaks)의 원인, 일반적 4가지 형태

이동준·2023년 7월 28일
1

자바스크립트

목록 보기
20/28

메모리 누수(Memory leaks)는 모든 개발자들이 자주 직면하게 되는 문제이다. 메모리를 관리해주는 프로그래밍 언어를 사용하는 경우에도 메모리 누수 문제에서 벗어날 수 없는데, 메모리 누수는 프로그램의 속도 저하, 충돌, 지연 시간 증가뿐만 아니라 다른 프로그램에게도 악영향을 끼치는 등 전반적인 문제들의 원인이 된다.

메모리 누수(Memory leaks)란?

메모리 누수는 어떠한 이유로 프로그램에서 더이상 사용되지 않음에도 불구, 운영체제나 사용가능한 메모리 풀에 반환되지 않는 메모리라고 정의할 수 있다. 프로그래밍 언어들은 각각 다른 방법으로 메모리를 관리하는데, 이런 프로그래밍 언어 차원에서의 메모리 관리는 메모리 누수의 가능성을 많이 줄여준다. 하지만 특정 멜모리가 실제 사용중인지 미사용중인지 완벽히 구분하는 것을 사실상 불가능에 가깝고, 오직 그 코드를 작성한 개발자만이 해당 메모리 조각을 운영체제로 반환시킬 수 있는지 여부를 알 수 있다.

자바스크립트에서의 메모리 관리

자바스크립트는 가비지 컬렉션이 있는 언어 중 하나이다. 가비지 컬렉션은 이전에 할당한 메모리를 프로그램에서 여전히 사용중인지를 주기적으로 검사해 개발자들이 메모리 관리에 덜 신경 쓸 수 있도록 도움을 주는 것이다. 가비지 컬렉션은 메모리 관리 문제를 "여전히 필요한 메모리인가?"에서 "프로그램의 다른 코드에서 접근 할 수 있는 메모리인가?"로 관점을 축소할 수 있게 해준다. 둘의 차이점은 미묘하지만 매우 중요하다. 할당된 메모리가 미래에 사용되는지의 여부는 오직 개발자만 알고 있지만, 다른 코드에서 더 이상 접근되지 않은 메모리는 알고리즘적으로 결정 할 수 있어 OS에 반환될 수 있도록 표시할 수 있다.

가비지 컬렉션이 없는 언어들은 명시적 관리라는 다른 방법으로 메모리를 관리해준다. 개발자가 메모리를 더 이상 사용하지 않는 경우, 컴파일러가 메모리를 회수할 수 있도록 명시적으로 선언해야한다. 이러한 기법은 잠재적으로 메모리 누수의 위험을 가질 수 있다.

자바스크립트에서의 메모리 누수

가비지 컬렉션을 가진 언어들에선 메모리 누수의 주요 원인으로 예상치 못한 참조(unwanted references)가 있다. 예상치 못한 참조가 무엇인지 이해하기 위해서는 가비지 컬렉션이 어떤 방식으로 해당 메모리가 다른 코드에서 접근할 수 있는지 여부를 판단하는지를 알 필요가 있다.

Mark-and-sweep

대부분의 가비지 컬렉터는 mark-and-sweep이라는 알고리즘을 사용한다. 최신의 가비지 컬렉터는 알고리즘을 다른 형태로 더 진화시켰지만, 기본 베이스는 동일하다. 접근할 수 있는 메모리 조각들은 활성화 상태로 표시하고, 그 외는 폐기하도록 고려된다.
예상치 못한 참조는 개발자가 더이상 사용되지 않을 것이라 생각했지만, 어떠한 이유로 활성화 상태인 루트 트리 안에 존재하는 메모리 조각들이다. 자바스크립트에서 예상치 못한 참조는 더 이상 사용되지 않지만 코드 상에 어딘가에 유지되어 해제되지 못한 변수들이다. 어떤 이들은 이를 개발자의 실수라고 말하기도 한다.

메모리 누수의 일반적 4가지 형태

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

자바스크립트의 언어 목표 중 하나는 Java 와 유사하지만 초보자들에게도 쉽게 다가올 수 있는 언어를 만드는 것이였다. 그 방법중 하나가 자바스크립트가 선언되지 않은 변수들을 처리할 수 있도록 하는 것이다. 선언되지 않은 변수는 global 객체 내부에 새로운 변수로 생성된다. 브라우저 환경에서 global 객체는 window 다.

function foo() {
    bar = "Hi";
}

위 코드의 실제 동작은 다음과 같다.

function foo() {
  window.bar = "Hi";
}

만약 bar 변수가 foo 함수 범위 내에서만 참조가 유지되도록 하려고 했는데, 실수로 var로 선언하는 것을 깜빡했다면, 예상치 못한 전역 객체가 생성된 것이다. 위 예제에서 이 간단한 문장이 큰 악영향을 끼치지 않을 수도 있지만, 좋지 않은 것은 분명하다.

또 다른 우발적인 전역 객체는 this 를 통해 생성될 수 있다.

function foo() {
  this.bar = "Hi";
}

foo(); // foo() 함수를 호출하게 되면, this는 window 전역 객체를 가리키게 된다.

자바스크립트 파일의 시작 부분에 use strict 를 추가해 엄격모드로 이러한 실수를 방지할 수 있다. 이는 자바스크립트 엔진이 우발적인 전역 객체 생성을 방지하도록 더 엄격하게 자바스크립트를 파싱하게 해준다.

예상치 못한 전역 변수에 대해 얘기했지만, 코드 여기저기서 필요에 의해 명시적으로 전역 변수를 선언하여 사용하는 곳이 많다. 이들은 null 로 처리하거나 재할당하지 않는 한 가비지 컬렉터에 수집되지 않는다. 특히, 대용량 데이터를 일시적으로 저장하고 처리학 ㅣ위해 사용된 전역 변수는 더 신중하게 다뤄야한다. 전역 변수의 사용이 끝났다면, null 로 처리하거나 재할당을 반드시 해야한다. 전역 변수와 관련하여 메모리 사용 증가를 야기하는 일반적인 원인 중 하나는 캐시다. 캐시는 자주 사용하는 데이터들을 저장한다. 이를 효율적으로 처리하기 위해 데이터가 커진다면 캐시 사이즈도 커지게 된다. 캐시는 수집되지 않기 때문에 캐시 사이즈가 점점 커진다면 방대한 메모리 사용을 야기할 수 있다.

잊혀진 타이머와 콜백

자바스크립트에서 setInterval() 은 매우 흔하게 사용된다. 많은 라이브러리에서 observer 를 제공하거나, callback 을 가지는 기능들을 가지고 있다. 이러한 라이브러리의 대부분은 더이상 사용이 안되면 자체적으로 callback 에 대한 참조를 해제하도록 구현되어 있다. 하지만 setInterval() 의 경우 아래와 같은 코드 형태를 많이 사용한다.

const someResource = getData();
setInterval(function() {
    const node = document.getElementById('Node');
    if(node) {
        node.innerHTML = JSON.stringify(someResource));
    }
}, 1000);

node 는 미래에 제거되어질지도 모르는 객체이다. 만약 객체가 제거되어지면 setInterval() 내부의 핸들러는 더 이상 필요가 없게 되지만, 여전히 동작하여 가비지 콜렉터에 의해 수집되지 않게 된다. 만약 setInterval() 핸들러가 수집되지 않는다면, 이 핸들러에 의존되는 객체들도 수집되지 않게 된다. 이 말은 대량의 데이터를 저장하고 있을 수도 있는 someResource 도 수집되지 않음을 의미한다.

이러한 observer 형태의 경우, 더 이상 사용되지 않을때 명시적으로 제거하는 것이 중요하다. 이는 과거의 경우, 특정 브라우저들이 순환 참조를 잘 관리하지 못해 매우 중요한 요소 중 하나였다. 현재 대부분의 브라우저들은 observer 객체가 더이상 사용되지 않는다면 명시적으로 제거하지 않더라도 수집하지만, 이러한 observer 들을 명시적으로 제거하는 것이 좋은 관행으로 남아있다.

const element = document.getElementById('button');

function onClick(event) {
    element.innerHtml = 'text';
}

element.addEventListener('click', onClick);
element.removeEventListener('click', onClick);
element.parentNode.removeChild(element);
// 이렇게 코드를 처리하게되면 구형 브라우저에서도 메모리 누수가 일어나지 않고 가비지 컬렉터에 의해 처리됨

observer 와 관련된 참조들은 자바스크립트 개발자들에게 골칫거리였다. 인터넷 익스플로러의 가비지 컬렉터의 버그 때문이었는데, 구버전 인터넷 익스플로러는 DOM 노드와 자바스크립트 코드 사이의 순환 참조를 탐지하지 못했다. 이로 인해 메모리 누수가 발생하게 되었고 그때부터 개발자들은 명시적으로 참조를 제거하기 시작했다. 현재의 브라우저들은 이들을 정확하게 탐지하여 수집해간다.

JQuery 와 같은 프레임워크, 라이브러리들은 노드를 폐기하기 전에 listener 들을 명시적으로 제거한다. 이는 라이브러리 내부에서 수행되며, 구버전 인터넷 익스플로러와 같이 문제가 생길법한 브라우저에서도 메모리 누수가 발생하지 않도록 구현되어 있다.

DOM 외부에서 참조

종종 DOM 노드들을 자료구조 안에 저장하는 것이 유용할 때가 있다. 테이블에서 여러 행의 내용을 빠르게 업데이트하려는 경우를 가정해보자. 각 행의 DOM 노드들에 대한 참조를 맵이나 배열에 저장하는 것이 좋다. 이 경우 DOM 요소에 대한 참조는 DOM 트리와 맵, 2군데에서 유지된다. 만약 나중에 이 행들을 제거해야할 경우 두 참조 모두 제거를 해야한다.

const elements = {
    button: document.getElementById('button'),
    image: document.getElementById('image'),
    text: document.getElementById('text')
};

function doStuff() {
    image.src = 'http://some.url/image';
    button.click();
    console.log(text.innerHTML);
    // Much more logic
}

function removeButton() {
    // The button is a direct child of body.
    document.body.removeChild(document.getElementById('button'));

    // 여기서 elements 에서 여전히 button 참조를 가지고 있습니다.
    // 이 경우 button element는 여전히 메모리에 상주하게 되며 GC에 의해 수집될 수 없습니다.
}

여기에 추가적으로 고려해야하는 사항은 DOM 트리 안에서 내부 혹은 말단 노드의 참조이다. 자바스크립트 코드에서 테이블의 특정 셀에 대한 참조를 가지고 있다고 가정해보자. 나중에 DOM 으로부터 테이블을 제거하기로 결정했지만, 여전히 셀에 대한 참조를 가지고 있게 된다. 직관적으로 가비지 컬렉터가 해당 셀을 제외한 나머지는 수집할 것이라 생각된다. 하지만 현실은 그렇지 않다. 셀은 테이블의 자식 노드이고 자식 노드는 부모 노드에 대한 참조를 유지한다. 그래서 테이블 셀에 대한 참조로 인해 테이블 전체의 메모리가 유지된다. DOM 요소에 대한 참조를 유지할 때는 이 점을 주의해야한다.

클로저(Closures)

자바스크립트 개발에서 주요 요소 중 하나는 상위 스코프의 변수에 접근이 가능한 클로저다. Meteor 개발자들은 자바스크립트 런타임의 구현 방법으로 인해 메모리 누수가 가능한 특정 사례를 발견했다.

let theThing = null;

const replaceThing = function () {
  const originalThing = theThing;
  const unused = function () {
    
    if (originalThing)
      console.log("hi");
  };
  theThing = {
    longStr: new Array(1000000).join('*'),
    someMethod: function () {
      console.log('someMessage');
    }
  };
  // 만약 여기에 `originalThing = null` 를 추가한다면, 메모리 누수는 사라질 것 입니다.
};
setInterval(replaceThing, 1000);

위 코드에서 replaceThing 이 호출될 때마다 큰 사이즈의 배열과 someMethod 클로저를 생성한다. 동시에 unused 변수는 originalThing 을 참조하는 클로저를 가지게 된다. 중요한 것은 unused 와 같은 내부 함수에서는 자신을 둘러싼 부모 함수의 스코프를 공유한다는 것이다. unused 내부 함수가 없었다면, replaceThing 함수는 매번 실행 시 길이가 큰 문자열을 매번 생성하긴 하지만, 최신 자바스크립트 엔진에서는 이전에 호출된 originalThing 이 사용되지 않음을 파악하고 이전 값을 메모리 해제하여 일정 메모리 사용량을 유지시켜준다. 하지만 위 코드에서는 unused 내부 함수 때문에 originalThing 을 참조하게 되고 비록 unused 가 사용하지 않더라도 이 코드가 반복적으로 실행될 때 마다 메모리 사용량이 꾸준히 증가하는 것을 관찰 할 수 있다. 가비지 컬렉터가 실행되더라도 메모리 사용량이 줄어들지 않게 된다. 본질적으로 클로저의 참조고리가 생성되고, 이 클로저의 범위에는 큰 사이즈의 배열에 대한 간접적인 참조를 동반하기 때문에 상당량의 메모리 누수가 발생하게 된다.

가비지 컬렉터의 비직관적인 동작

가비지 컬렉터는 편리하지만, 그만의 특정 메커니즘에 의해 동작한다. 그 특징 중 하나는 비결정성이다. 이 말은 가비지 컬렉터는 예측이 불가능하단 뜻이다. 언제 수집이 수행되는지 정확하게 예측하기 힘들다. 경우에 따라 프로그램에 요구되는 메모리보다 더 많은 메모리가 사용되고 있을 수 있다. 또 다른 경우, 민감한 어플리케이션에선 짧은 일시정지 현상이 보이기도 한다. 비록 비결정성은 수집이 언제 수행될지 모른다는 것을 의미하지만, 대부분의 가비지 컬렉터는 일반적으로 메모리 할당이 이뤄지는 경우에만 수집을 수행한다. 만약 메모리 할당이 이뤄지지 않았다면 대부분의 가비지 컬렉터는 유휴상태에 있게 된다.

  1. 사이즈가 큰 데이터 할당을 여러번 수행한다.
  2. 가비지 컬렉터에 의해 대부분(혹은 전부)은 더 이상 접근되지 않는다라고 표시가 된다.(더 이상 사용하지 않은 경우 null 로 초기화 했다고 가정 시)
  3. 더 이상 할당을 수행하지 않는다.

이 시나리오에서 대부분의 가비지 컬렉터들은 더 이상 수집을 수행하지 않는다. 더 이상 접근되지 않는 데이터 셋들이 남아있음에도 불구하고 수집이 일어나지 않는다. 이는 엄격히 메모리 누수는 아니지만, 일반적인 메모리 사용량보다 더 많은 메모리를 사용하게 된다.

0개의 댓글