[번역] 자바스크립트의 메모리 관리 설명

sejin kim·2022년 10월 23일
8

번역

목록 보기
1/6
post-thumbnail

이 글은 개발자 Felix Gerschau님이 작성한 다음의 글을 한국어로 옮긴 것입니다 : JavaScript's Memory Management Explained


대부분의 경우, 자바스크립트 개발자로서 메모리 관리에 대해 전혀 모르더라도 괜찮을 수 있습니다. 결국에는 자바스크립트 엔진이 처리하기 때문입니다.

하지만 언젠가 메모리 누수와 같은 문제에 직면하게 될 수 있고, 이 때에는 메모리 할당이 어떻게 동작하는지 알고 있어야만 해결할 수 있을 것입니다.

이 글에서는 메모리 할당memory allocation가비지 콜렉션garbage collection이 동작하는 방식과, 일반적인 메모리 누수를 방지할 수 있는 방법을 소개합니다.






메모리의 생명 주기

자바스크립트에서, 변수나 함수 등 생각할 수 있는 모든 것들을 만들 때 JS 엔진은 이에 대한 메모리를 할당하고 더 이상 필요하지 않으면 해제합니다.

메모리 할당은 메모리에 공간을 예약하는 과정이며, 메모리를 해제하면 공간이 확보되어 다른 용도로 사용할 수 있게 됩니다.

변수를 할당하거나 함수를 생성할 때마다, 메모리는 항상 다음과 같은 단계를 거치게 됩니다 :

  • 메모리 할당
    자바스크립트는 생성한 객체에 필요한 메모리를 할당합니다.

  • 메모리 사용
    코드에서 명시적으로 수행되는 작업으로, 메모리를 읽고 쓰는 작업은 곧 변수에서 읽거나 변수에 쓰는 작업을 의미합니다.

  • 메모리 해제
    이 단계는 JS 엔진에서도 처리됩니다. 할당되었던 메모리가 해제되면 새로운 용도로 사용할 수 있습니다.

이때 '객체' 는, 메모리 관리의 맥락에서 자바스크립트의 객체(Object)뿐만 아니라 함수와 함수의 스코프까지 포함하는 개념입니다.






메모리의 힙과 스택

자바스크립트에서 정의하는 모든 것에 대해 엔진이 메모리를 할당하고, 더 이상 필요하지 않으면 해제한다는 것을 알았습니다.

그러면 이제 다음 질문을 생각해볼 수 있습니다 : '그럼 이것들은 어디에 저장될까?'

JS 엔진에는 데이터를 저장할 수 있는 힙Heap스택Stack이라는 영역이 존재합니다. 힙과 스택은 엔진이 각기 다른 목적으로 사용되는 두 가지의 자료 구조입니다.



스택 : 정적 메모리 할당

스택은 자바스크립트가 정적 데이터를 저장하는 데 사용하는 자료 구조입니다. 정적 데이터란 엔진이 컴파일 타임에 크기를 알고 있는 데이터를 말합니다. 자바스크립트에서는 원시 값primitive (string, number, bigint, boolean, undefined, symbol, null)과 객체 및 함수를 가리키는 참조reference가 해당합니다.

크기가 변경되지 않을 것임을 알고 있으므로, 엔진은 각 값에 대해 고정된 크기의 메모리를 사전에 할당합니다. 그리고 이렇게 실행 직전에 메모리를 할당한다는 점에서, 이를 정적 메모리 할당이라고 합니다.

이때 값과 전체 스택에 대한 제한은 브라우저에 따라 다르게 구현됩니다.



힙 : 동적 메모리 할당

힙은 자바스크립트가 객체와 함수를 저장하는 데이터를 저장하는 또 다른 공간입니다.

스택과 달리, 엔진은 고정된 크기의 메모리를 할당하지 않습니다. 대신 필요에 따라 더 많은 공간을 할당합니다.

이러한 방식으로 메모리를 할당하는 것을 동적 메모리 할당이라고 합니다.


스택
원시 값 및 참조객체 및 함수
컴파일 타임에 크기를 알 수 있음런타임에 크기를 알 수 있음
고정된 크기의 메모리 할당객체 당 제한 없음


예시

몇 가지 코드 예제를 살펴보겠습니다.

const person = {
    name: 'John',
    age: 24,
};

자바스크립트는 힙에서 이 객체에 대한 메모리를 할당합니다. 실제 값은 원시 값이므로, 스택에 저장됩니다.

const hobbies = ['hiking', 'reading'];

배열도 객체이므로, 역시 힙에 저장됩니다.

let name = 'John'; // 메모리 할당
const age = 24; // 메모리 할당

name = 'John Doe'; // 메모리 새로 할당
const firstName = name.slice(0,4); // 메모리 새로 할당

원시 값은 불변(immutable)입니다. 즉, 자바스크립트는 원래의 값을 변경하는 대신 새 값을 만듭니다.






자바스크립트의 참조

모든 변수는 먼저 스택을 가리킵니다. 원시 값이 아닌 경우 스택에는 힙의 객체에 대한 참조가 포함됩니다.

힙의 메모리는 특정한 방식으로 정렬되지 않으므로, 스택에 대한 참조를 유지해야 합니다. 참조는 '주소'로, 힙의 객체는 이러한 주소가 가리키는 '집'으로 생각할 수 있습니다.


자바스크립트는 객체와 함수를 힙에 저장한다는 것을 기억하세요. 원시 값과 참조는 스택에 저장됩니다.


위 그림에서, 다른 값들이 어떻게 저장되는지를 살펴볼 수 있습니다. personnewPerson 둘 모두 같은 객체를 가리키는지를 주목하세요.



예시

const person = {
    name: 'John',
    age: 24,
};

힙에 새로운 객체가, 스택에는 객체에 대한 참조가 생성됩니다.






가비지 콜렉션

이제 자바스크립트가 모든 종류의 객체에 메모리를 할당하는 방법을 알게 되었습니다만, 메모리의 생명 주기를 기억한다면 마지막 단계가 누락되었다는 것을 알 수 있습니다. 바로 메모리 해제입니다.

메모리 할당과 마찬가지로, JS 엔진에서 이 단계를 처리합니다. 구체적으로 말하면, 가비지 콜렉터가 이를 처리합니다.

JS 엔진이 어떤 변수나 함수가 더 이상 필요하지 않다는 것을 인식하게 되면, 이것들이 차지하던 메모리를 해제합니다.

그런데 이때 중요한 문제는, 메모리가 여전히 필요한지에 대한 여부를 결정할 수가 없다는 것입니다. 다시 말해, 더 이상 메모리가 사용되지 않는 정확한 순간에 쓰레기를 수집해낼 수 있는 알고리즘이 존재할 수 없음을 의미합니다.

일부 알고리즘은 이러한 문제에 대해 훌륭한 근사해를 제시합니다. 아래에서 가장 많이 사용되는 알고리즘인 Reference-counting garbage collectionMark-and-Sweep 알고리즘에 대해 이야기해 보겠습니다.



Reference-counting Garbage Collection

이는 가장 쉬운 근사 알고리즘입니다. 가리키는 참조가 없는 객체를 수집합니다.

아래 예를 살펴보겠습니다. 선은 참조를 나타냅니다.

hobbies는 참조가 존재하는 객체이기 때문에, 마지막에 힙에서만 유지되고 있다는 점에 유의하세요.

이 알고리즘의 문제는 '순환 참조'를 고려하지 않는다는 것입니다. 하나 이상의 객체가 서로를 참조하지만, 더 이상 코드를 통해 접근할 수 없을 때 문제가 발생하게 됩니다.

let son = {
    name: 'John',
};

let dad = {
    name: 'Johnson',
};

son.dad = dad;
dad.son = son;

son = null;
dad = null;

sondad 객체가 서로를 참조하기 때문에, 알고리즘은 할당된 메모리를 해제하지 않습니다. 더 이상 두 객체에 접근할 수 있는 방법도 없습니다.

null로 할당하면 서로 참조가 존재하기 때문에, 알고리즘이 더 이상 사용할 수 없다고 인식하지 않게 됩니다.



Mark-and-Sweep Algorithm

Mark-and-Sweep 알고리즘에는 순환 참조에 대한 솔루션이 있습니다. 단순히 주어진 객체에 대한 참조를 계산하는 대신, 루트 객체에서 도달할 수 있는지를 감지합니다. 이때 '루트'란 브라우저에서는 window 객체를, Node.js에서는 global 객체를 가리킵니다.

알고리즘은 도달할 수 없는 객체를 쓰레기로 표시(mark)하고, 나중에 청소(sweep)합니다. 루트 객체는 수집되지 않습니다.

이렇게 하면 순환 참조가 더 이상 문제가 되지 않습니다. 앞의 예시에서는 루트에서 dad 또는 son 객체에 도달할 수 없었습니다. 따라서 둘 다 쓰레기로 표시되고 수집됩니다.

2012년부터 이 알고리즘은 모든 최신 브라우저에서 구현됩니다. 이후로 성능과 구체적인 구현은 개선되었지만, 알고리즘의 핵심 아이디어 자체는 변하지 않았습니다.



트레이드오프

자동적인 가비지 콜렉션을 통해, 메모리 관리에 시간을 소모하는 대신 애플리케이션 구현에 집중할 수 있습니다. 하지만 알아야 할 몇 가지 트레이프오프가 존재합니다.

메모리 사용량

알고리즘 상 정확히 언제 메모리가 더 이상 필요하지 않은 것인지를 알 수 없다는 점을 생각해보면, 자바스크립트 애플리케이션은 실제로 필요한 것보다 더 많은 메모리를 사용하게 될 것입니다.

객체가 쓰레기로 표시되더라도, 할당된 메모리를 수집할 시기와 여부를 결정하는 것은 결국 가비지 콜렉터에게 달려 있습니다.

만약 애플리케이션이 가능한 메모리 효율적이어야 하는 경우, 보다 로우 레벨의 언어를 사용하는 것이 좋습니다. 그러나 여기에는 트레이프오프가 수반된다는 점을 명심하세요.

성능

쓰레기를 수집하는 알고리즘은 사용되지 않는 객체들을 청소하기 위해 주기적으로 실행되고 있습니다.

이것의 문제는 개발자가 정확히 언제 이러한 일이 일어날지를 모른다는 것입니다. 많은 양의 쓰레기를 수집하거나, 빈번하게 쓰레기를 수집하면 일정 수준의 컴퓨팅 파워가 필요할 것이므로 성능에 영향을 미칠 수 있습니다.

그러나 그 영향은 대개 사용자나 개발자에게 눈에 띄지는 않습니다.



메모리 누수

메모리 관리에 대한 이러한 지식들로 무장하고, 가장 일반적인 메모리 누수 사례를 살펴보겠습니다.

배후에서 무슨 일이 일어나고 있는지를 이해한다면, 문제를 쉽게 피할 수도 있다는 것을 알게 될 것입니다.


전역 변수

전역 변수에 데이터를 저장하는 것은 가장 일반적인 유형의 메모리 누수일 것입니다.

예를 들어 브라우저에서는 constlet 키워드 대신 var를 사용한다거나 아예 생략해버리는 경우, 엔진이 변수를 window 객체에 할당하게 됩니다. function 키워드로 정의한 함수도 마찬가지입니다.

user = getUser();
var secondUser = getUser();
function getUser() {
    return 'user';
}

user, secondUser, getUser() 모두 window에 할당됩니다.

이는 전역 스코프에 정의된 변수 및 함수에만 적용됩니다. 이에 대해 더 알고 싶다면 자바스크립트의 스코프를 설명하는 글을 확인하세요.

실수로 루트 객체에 변수를 추가하는 것 외에도, 의도적으로 이 작업을 수행할 수 있는 많은 경우가 존재합니다.

전역 변수를 사용할 수는 있지만, 더 이상 필요하지 않으면 공간을 확보해 주어야 합니다. 메모리를 해제하려면 변수에 null을 할당하세요.

window.users = null;

잊어버린 타이머 및 콜백

타이머와 콜백을 잊어버리면 애플리케이션의 메모리 사용량이 증가할 수 있습니다. 특히 단일 페이지 애플리케이션(SPA)에서 이벤트 리스너와 콜백을 동적으로 추가할 때 주의해야 합니다.

// 잊어버린 타이머
const object = {};
const intervalId = setInterval(function() {
    // 여기에서 사용된 모든 것들은 interval이 클리어될 때까지 수집되지 않습니다
    doSomething(object);
}, 2000);

위 코드는 2초마다 함수를 실행합니다. interval이 취소되지 않는 한, 여기서 참조된 객체들은 수집되지 않습니다. 더 이상 필요하지 않게 된 interval은 아래와 같이 취소해야 합니다.

clearInterval(intervalId);

이는 특히 SPA에서 중요합니다. 다른 페이지로 이동할 때에도 여전히 백그라운드에서 실행될 것이기 때문입니다.


잊어버린 콜백

나중에 제거되는 버튼에 onclick 이벤트 리스너를 추가한다고 가정해 보겠습니다.

구식 브라우저는 리스너를 수집할 수 없었지만, 요즘에는 더 이상 문제가 되지는 않습니다.

그래도 더 이상 필요하지 않으면 이벤트 리스너를 제거하는 것이 좋습니다.

const element = document.getElementById('button');
const onClick = () => alert('hi');

element.addEventListener('click', onClick);
element.removeEventListener('click', onClick);
element.parentNode.removeChild(element);

DOM 참조

이 메모리 누수는 이전의 사례들과 유사한 경우입니다. 자바스크립트에 DOM 엘리먼트를 저장할 때 발생합니다.

const elements = [];
const element = document.getElementById('button');

elements.push(element);

function removeAllElements() {
    elements.forEach((item) => {
        document.body.removeChild(document.getElementById(item.id))
    });
}

엘리먼트를 제거할 때, 배열에서도 제거해 주어야 합니다. 그렇지 않으면 DOM 엘리먼트가 수집될 수 없습니다.

const elements = [];
const element = document.getElementById('button');

elements.push(element);

function removeAllElements() {
    elements.forEach((item, index) => {
        document.body.removeChild(document.getElementById(item.id));

        // 배열에서 엘리먼트를 제거하면, DOM과 동기화된 상태로 유지됩니다.
        elements.splice(index, 1);
    });
}

모든 DOM 엘리먼트는 부모 노드에 대한 참조도 유지하기 때문에, 가비지 콜렉터가 엘리먼트의 부모와 자식을 수집하는 것을 방지할 수 있습니다.






결론

이 글에서는 자바스크립트의 메모리 관리의 핵심 개념을 요약했습니다.

이 글을 작성함으로써 스스로 완전히 이해하지 못한 몇 가지 개념을 정리하는 데 도움이 되었으며, 이러한 내용들이 자바스크립트에서 메모리 관리가 동작하는 방식에 대해 좋은 개요를 제공할 수 있었기를 바랍니다.

그 외 관심을 가질 수 있을 만한 다른 글들을 소개합니다 :


profile
퇴고를 좋아하는 주니어 웹 개발자입니다.

0개의 댓글