Javascript의 메모리 관리법

CianJS
14 min readApr 11, 2021

--

이 글은 fgerschau 님의 을 번역한 것이니 읽으시는데 참고 바랍니다.
번역 및 오역에 대한 문의는 언제든 환영입니다.

대부분 Javascript 사용자분들은 아마도 메모리 관리에 대해서 아무것도 알지 못해도 괜찮았을겁니다.
그건 전부 Javascript 엔진이 이 일을 해주고 있기 때문에 그렇습니다.

하지만 메모리 누수와 같은 문제들이 언제나 발생하는데 이 문제는 메모리 할당이 어떻게 동작하는지 알아야만 해결할 수 있습니다.

이 기사에서는 어떻게 메모리 할당이 이루어지는지, 가비지 컬렉션이 어떻게 작동하는지와 일반적으로 발생하는 메모리 누수들을 피할 수 있는 방법을 설명할 것입니다.

메모리 생명 주기(Life Cycle)

Javascript에서 변수, 함수, 객체 등을 만들때 JS 엔진은 메모리를 할당하고 더 이상 필요가 없어지면 메모리에서 해제합니다.

메모리 할당은 메모리의 빈 공간을 예약하는 과정이고 메모리를 해제하면 다른 목적으로 사용할 수 있는 공간이 확보됩니다.

변수를 할당하거나 함수를 생성할때마다 메모리에서는 항상 다음과같은 과정을 거칩니다.

  • 메모리 할당
    Javascript에서 우리 대신 해주는 것 : 우리가 생성한 객체(object)에 필요한 메모리를 할당합니다.
  • 메모리 사용
    메모리를 사용하는 것은 우리가 작성한 코드에서 하고 있습니다. -> 메모리가 읽고와 쓰는것은 다른게 아니라 변수에서 읽거나 쓰는 걸 말합니다
  • 메모리 해제
    이 단계에서는 Javascript 엔진에 의해 처리됩니다. 할당된 메모리가 해제되면 새로운 목적으로 그 공간을 사용할 수 있게됩니다.

메모리 관리 측면에서의 “객체(Objects)”는 JS 객체들만이 아니라 함수들(functions) 또는 함수
스코프들(function scopes)도 포함됩니다.

메모리 힙(heap)과 스택(stack)

우리는 이제 Javascript에서 정의하는 것들이 엔진에서 메모리를 할당하고 더이상 필요가 없으면 해제한다는 것을 알게 되었습니다.

다음 질문이 생각났습니다. 과연 어디에 저장이 되는 것일까요?

Javascript 엔진은 데이터를 저장할 수 있는 두 공간이 있습니다. -> 메모리 힙스택입니다.

힙(Heaps)과 스택(stacks)은 엔진이 서로 다른 목적으로 사용하는 데이터 구조입니다.

스택(Stack): Static memory allocation

호출 스택(Call Stack)과 이벤트 루프(Event Loop)에 대한 일부분 중 스택(Stack)을 알고 있을 것입니다. 여기서는 JS 인터프리터가 호출해야하는 함수를 찾는 방법을 위주로 다룰 것입니다.

모든 값들은 원시 값들이 포함되어 있어 전부다 스택에 저장됩니다.

스택은 Javascript가 사용하는 정적 데이터(Static data)를 저장하는 데이터 구조입니다. 정적 데이터(Static data)는 엔진이 컴파일시 데이터의 크기를 알고있는 데이터입니다.
Javascript에서 원시값들(string, number, boolean, undefined, null)과 객체와 함수를 참조하는 곳을 포함하고 있습니다.

엔진은 크기가 변경되지 않는다는 것을 알고 있기에 각 값들에게 고정된 양의 메모리를 할당합니다.

실행 직전. 메모리에 할당하는 과정을 정적 메모리 할당이라고 합니다.

엔진은 값들에게 고정된 양의 메모리를 할당하기 때문에 원시 값들의 크기에 제한이 있습니다.

값의 크기와 스택에 대한 제한은 브라우저마다 다릅니다.

힙(Heap): 동적 메모리 할당(Dynamic memory allocation)

힙(heap)은 Javascript의 객체(objects)와 함수(functions)를 저장하는 다른 공간입니다.

스택과는 다르게 엔진은 객체에 고정된 양의 메모리를 할당하지 않습니다. 대신에 필요한만큼 많은 공간을 할당해줍니다.

이런 식으로 메모리를 할당하는 것을 동적 메모리 할당이라고 부릅니다.

아래는 두 저장소의 기능을 비교한 것입니다.

더 이쁘게 보고 싶으시면 fgerschau 님의 힙:동적메모리할당 란을 살펴봐주세요!

예제

코드 예제들을 보러갑시다. ~~원문과 살짝 다를 수 있습니다!~~

const developer = {
name: ‘Martin’,
age: 24,
};

JS는 힙(heap)에서 이 객체에 메모리를 할당합니다. 실제 값은 원시값이기 때문에 스택에 저장됩니다.

const hobbies = [‘game’, ‘reading’, ‘exercise’];

배열은 객체이기때문에 힙(heap)에 저장됩니다.

let name = ‘Martin Berk’; // 문자열(string)을 메모리에 할당합니다.
const age = 24; // 숫자(number)을 메모리에 할당합니다.
name = ‘John Smith’; // 새 문자열을 메모리에 할당합니다.
const firstName = name.slice(0,4); // 새 문자열을 메모리에 할당합니다.

원시값들은 변경(immutable)되지 않기 때문에 원본값을 변경하는 대신에 Javascript는 새로운 값을 생성합니다.

Javscript에서의 참조

모든 변수들은 스택을 먼저 가리킵니다. 원시 값이 아닌 경우. 스택에서는 힙의 객체에 대한 참조를 가지고 있습니다.

힙의 메모리는 정렬되어있지 않기때문에 스택에 참조를 유지해야합니다. 여러분들은 참조(reference)를 주소라고 생각하고 힙의 객체는 주소를 가진 집이라고 생각할 수 있습니다.

Javascript는 객체(object)함수(function)들을 힙에 저장한다는 것을 기억해주세요. 원시값(primitive values)들과 참조(references)는 스택에 저장됩니다.

위 그림에서는 값들이 저장되는 방식을 보실 수 있습니다.
여기서는 person과 newPerson이 같은 객체를 가리키고 있다는 것에 집중해주세요.

예제

const developer = {
name: ‘Martin’,
age: 24,
};

힙에서 새 객체가 생성되고 스택에는 참조가 생성됩니다.
참조Javascript 동작의 핵심 개념입니다.

가비지 컬렉션(Garbage Collection)

우리는 이제 Javascript의 여러 객체들이 어떻게 메모리에 할당되는지 알게되었지만 메모리 생명주기(memory lifecycle)을 다시 떠올려보면 마지막 단계를 놓쳤습니다. 바로 메모리 해제입니다.

메모리 할당처럼 Javascript 엔진도 메모리 해제 단계를 잘 처리합니다. 더 자세히 얘기하자면 가비지 컬렉터가 이를 처리합니다.

Javascript 엔진이 주어진 변수나 함수가 더 이상 필요가 없다고 인식하고나면 메모리를 해제합니다.

문제는 일부 메모리가 여전히 필요한지 필요하지 않은지 결정할 수 없다는 것입니다. 다시 말하면 정확히 더 이상 필요하지 않게된 모든 메모리를 수집(collect)할 수 있는 알고리즘은 없다는 것 입니다.

일부 알고리즘은 이 문제의 해답에 가까운 좋은 답을 제공합니다. 여기서부터는 자주 사용되는 것에 대해 설명하겠습니다.
바로 레퍼런스 카운팅 가비지 컬렉션(reference-counting garbage collection)마크 앤 스윕 알고리즘(the mark and sweep algorithm)입니다.

레퍼런스 카운팅 가비지 컬렉션(Reference-counting garbage collection)

이것이 가장 쉽게 메모리가 필요한지를 검사하는 방법입니다. 가리키고 있는 reference가 없는 객체를 수집합니다.

다음 예제를 살펴보겠습니다. 스택에서 힙에 있는 것과 연결된 선이 참조를 나타냅니다. 아래의 링크를 타고 들어가서 10초짜리 영상을 보고와주세요!

빨간 상자가 마지막 코드를 가리킬때를 보면 hobbies 객체만 힙에 reference가 유지되어있다는 것에 유의해주세요.

사이클(Cycles)

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

let son = {
name: 'John',
};
let dad = {
name: 'Johnson',
}
son.dad = dad;
dad.son = son;
son = null;
dad = null;
son과 dad 객체는 서로 참조하기 때문에 알고리즘은 할당된 메모리를 해제하지 않습니다.
두 객체에 더 이상 접근할 수 있는 방법이 없습니다.

둘 다 null로 설정해주면 레퍼런스 카운팅 알고리즘은 힙에 있는 두 객체가 서로를 참조하고 있어서 더 이상 사용할 수 없다는 것을 인식할 수 없습니다.

마크 앤 스윕 알고리즘

마크 앤 스윕 알고리즘은 순환 종속성의 해결책을 가지고 있습니다. 단순히 주어진 객체의 참조의 수를 세는 대신에 루트 객체에서 도달할 수 있는지 감지합니다.

브라우저에서 루트는 window객체이고 NodeJSglobal 입니다.

알고리즘은 도달할 수 없는 객체를 가비지로 표기하고 나중에 스윕(수집)합니다. 루트 객체는 수집되지 않습니다.

이 방법은 순환 종속성이 더 이상 문제가 되지않습니다. 이전 예제를 다시 보면 dadson객체는 루트에서 도달 할 수 없습니다. 즉, 둘 다 가비지로 표기(mark)되어 수집됩니다.

2012년 이후 이 알고리즘은 모든 최신 브라우저들에 구현되어 있습니다. 성능만 개선이 되었지만 알고리즘의 핵심적인 아이디어는 개선되지 않았습니다.

장단점(Trade-offs)

자동 가비지 컬렉션은 메모리 관리로 시간을 낭비하는 대신 애플리케이션 구현에 집중할 수 있게 해줍니다. 어쨌든 우리는 장단점에 대해서 더 잘 알아야합니다.

메모리 사용량(Memory Usage)

알고리즘이 메모리가 더 이상 필요하지 않을 정확한 시점을 알 수 없다고 했을때 Javascript 애플리케이션은 실제로 필요한 것보다 더 많은 메모리를 사용할 수도 있습니다.

객체가 가비지가 표기(mark)되어도 할당된 메모리를 수집할때를 정하는 것은 가비지 컬렉터가 결졍합니다.

애플리케이션이 가능한 한 메모리를 효율적으로 사용하려면 저수준 레벨(lower-level)의 언어를 사용하는게 좋습니다. 하지만 이 방법에도 장단점이 있음을 유의해주세요.

성능(Performance)

가비지 컬렉트 알고리즘은 미사용된 객체를 정리하기 위해 주기적으로 실행되고 있습니다.

문제는 개발자인 우리가 언제 일이 일어난지 정확히 모른다는 것입니다. 많은 가비지를 수집하거나 자주 가비지를 수집하면 일정량의 계산 능력이 필요하므로 성능에 영향을 줄 수 있습니다.

하지만 이는 일반적으로 유저나 개발자에게 발견되는 것이 어렵습니다.

메모리 누수(Memory Leaks)

우리는 이제 메모리 관리에 대한 지식으로 무장하였습니다. 이제 가장 흔하게 볼 수 있는 메모리 누수(memory leaks)을 살펴보러 가보겠습니다.

여러분들은 이제 뒤에서 무슨 일이 일어나고 있는지 이해한다면 문제를 쉽게 피할 수 있다는 것을 알게 될 것 입니다.

전역 변수들(Global variables)

전역 변수에 데이터를 저장하는 것이 아마 메모리 누수의 가장 흔한 유형일 것 입니다.

브라우저에서 constlet대신에 var를 사용한다거나 지정하는 것을 전부 생략하면 엔진이 변수를 window객체에 연결할 것 입니다.

function 키워드로 정의된 함수도 같은 일이 일어납니다.

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

세 변수들 user, secondUser, getUser window 객체에 연결됩니다.

이건 오직 전역 스코프에서 정의된 변수들과 함수들에만 적용됩니다. 만약 이에 대해 더 자세히 알고 싶으시다면 Javascript scope를 한번 살펴봐주세요.

엄격 모드(strict mode)에서 실행하여 이를 피하세요.

실수로 루트에 변수를 추가하는 것과는 별개로 일부러 root에 변수를 만드는 경우도 많이 있습니다.

확실히 전역 변수를 사용할 수 있지만 데이터가 더 이상 필요하지 않으면 여유 공간을 확보해주세요.

메모리를 해제하는 것은 전역 변수에 null을 할당하는 것입니다.

window.users = null;

이 기사를 이해하기 쉽도록 만들고 싶습니다. 여기에 댓글을 다시거나 이메일로 질문사항들을 보내주세요.
이 기사를 개선하기 위해 여러분들의 도움이 필요합니다.

잊혀진 타이머와 콜백(Forgotten timers and callbacks)

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

잊혀진 타이머들(Forgotten timers)

const object = {};
const intervalId = setInterval(function() {
// everything used in here can’t be collected
// until the interval is cleared
doSomething(object);
}, 2000)

위의 코드는 2초마다 함수를 실행합니다. 만약 이런 코드가 여러분들의 프로젝트에 있다면 항상 실행할 필요가 없을 수도 있습니다.

interval이 취소되지 않고 길어지면 질수록 참조된 객체는 가비지가 수집되지 않습니다.

interval이 더 이상 필요하지 않으면 정리해주세요.

clearInterval(intervalId);

이것이 SPAs에서 특히 중요합니다. interval이 페이지에서 벗어나더라도 여전히 백그라운드에서 실행됩니다.

잊혀진 콜백들(Forgotten callbacks)

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

옛날 브라우저들은 리스너 수집을 할 수 없었지만 이제는 더 이상 문제가 되지 않습니다.

그래도 이벤트 리스너가 더 이상 필요없으면 제거해주는게 좋습니다.

const element = document.getElementById(‘button’);
const onClick = () => alert(‘hi’);
element.addEventListener(‘click’, onClick);element.removeEventListener(‘click’, onClick);
element.parentNode.removeChild(element);

DOM 참조에서 벗어남(Out of DOM reference)

메모리 누수는 타이머와 콜백들과 유사합니다.
Javascript에 DOM 요소를 저장할때 발생합니다.

const elements = [];
const element = document.getElementById(‘button’);
elements.push(element);
function removeAllElements() {
elements.forEach((item) => {
document.body.removeChild(document.getElementById(item.id))
});
}

요소(element)들 중 하나를 제거할때 여러분들은 배열에서 요소를 제거해주어야 합니다.

그래야 DOM 요소 수집이 가능합니다.

const elements = [];
const element = document.getElementById(‘button’);
elements.push(element);
function removeAllElements() {
elements.forEach((item, index) => {
document.body.removeChild(document.getElementById(item.id));
elements.splice(index, 1);
});
}

배열에서 element를 제거하면 DOM에서 동기화가 됩니다.

모든 DOM 요소는 부모 노드에 대한 참조를 유지하기에 가비지 컬렉터가 요소의 부모와 자식을 수집하지 못하도록 막습니다.

마무리

이 기사에서 Javascript에서 메모리 관리의 핵심 개념을 분석해보았습니다.

여러분들이 읽으시면서 Javascript에서 메모리 관리가 어떻게 이루어지는지 이해하는데에 도움이 되셨으면 좋겠습니다.

혹시 관련해서 더 알아보고 싶으신 분들은 이곳을 살펴봐주세요!

--

--

CianJS
CianJS

Written by CianJS

영어랑 영문서 자유롭게 듣고 읽고 싶다..

No responses yet