- #javascript
자바스크립트 메모리 관리
December 12, 2023 (1y ago)
......
Table of Contents:
배경
javascript를 사용하는 개발자들은 평소 메모리에 대해 큰 신경 쓸 일은 없을 것입니다.
저도 그랬지만, 회사 업무 중 자바스크립트 메모리 관리에 신경 써야 할 일이 생기게 되었습니다.
c++과 같은 low-level 언어들은 메모리 할당 해제를 수동으로 해주어야 하지만,
javascript와 같은 high-level 언어들은 `Garbage collector(GC)`
, 가비지 컬렉터가 알아서 할당된 메모리를 해제해 줍니다.
저도 이렇게 가비지 컬렉터가 참조가 없는 값에 대한 메모리 할당을 해제해준다는 수준으로 간단하게만 알고 있었습니다.
하지만 저처럼 메모리 누수에 신경을 쓸 일이 생긴다면, 어떻게 메모리에 저장되고 가비지 컬렉터는 어떻게 메모리를 해제하는지에 대해 알아볼 필요가 있습니다.
자바스크립트 메모리 사용
먼저, 자바스크립트에서는 객체나 원시값들은 어디에 저장 되고 있는 걸까요?
바로 `스택`
과 `힙 메모리`
입니다.
데이터를 저장하는 데에 힙과 메모리는 각각 다른 용도로 사용됩니다. 힙 메모리와 스택은 과연 어떻게 사용되고 있을까요?
스택
스택은 정적이고 고정된 크기의 데이터를 저장하는 데에 사용됩니다. 여기에는 자바스크립트의 대표적인 원시타입들이 포함됩니다. 대표적으로 string, number, boolean 등이 존재합니다. 이러한 원시값들은 컴파일 타임에 크기를 알 수 있기 때문에 스택에 저장합니다.
런타임에 크기가 가변적으로 변할 수 있는 객체 등은 스택에 저장되지 않습니다. 자바스크립트에서는 배열, 함수 등은 객체이므로 마찬가지로 스택에 저장되지 않습니다.
객체나 배열, 함수, 클래스에 대한 참조는 스택에 포함됩니다. 실제 참조가 가리키는 값은 스택에 저장되지 않습니다.
힙 메모리
그럼 정적으로 고정된 데이터가 아니라 동적으로 변하는 데이터는 어디에 저장될까요? 답은 힙 메모리입니다. 컴파일 타임에 크기를 알 수 없고 런타임에 동적으로 변하는 객체, 배열, 함수 등을 저장하기 위해 힙 메모리가 사용됩니다.
스택과 힙에 어떻게 저장되는지 코드로 예를 들어 보겠습니다.
// 컴파일 타임에 크기를 알 수 있는 name과 language는 스택에 저장됩니다. const name = "Junseo Park"; const language = "Javascript"; // Developer 클래스는 힙 메모리에 저장됩니다. // Developer 클래스에 대한 참조 또한 스택에 저장됩니다. class Developer { constructor(name, language) { this.name = name; this.language = language; } } // 마찬가지로 introduction 함수도 힙 메모리에 저장됩니다. // introduction 함수의 참조 또한 스택에 저장됩니다. function introduction(name, language) { console.log(`hi, my name is ${name}, my main language is ${language}!`); } // name = "Junseo Park", language = "Javascript"의 값을 가지는 Developer 클래스가 힙 메모리에 저장됩니다. // 이 객체에 대한 참조를 가지는 developer 또한 스택에 저장됩니다. const developer = new Developer(name, language); introduction(developer.name, developer.language);
요약하자면 다음과 같습니다.
- 스택은 정적 메모리 할당, 힙 메모리는 동적 메모리 할당
- 스택은 컴파일 타임, 힙 메모리는 런타임
- 스택은 고정된 크기를 갖는 원시값들을 저장, 힙 메모리는 동적으로 크기가 변하는 객체 등
가비지 컬렉터
자바스크립트에서 데이터들이 메모리에 어떻게 저장되는지 알아보았습니다. 이제는 자바스크립트에서 할당된 메모리를 필요 없을 때 어떻게 해제하는지 알아볼 차례입니다.
처음에 자바스크립트에서는 가비지 컬렉터가 알아서 메모리를 해제해 준다고 했습니다. 그렇다면 가비지 컬렉터는 어떠한 기준으로 메모리를 해제해 주는 걸까요? 현재 가장 많이 사용되고 있는 가비지 컬랙션 방법론을 알아보겠습니다.
가비지 콜렉터가 할당된 메모리를가 더이상 필요가 없는지 알고리즘에 따라 판단하고 자동으로 할당된 메모리를 해제하지만, 어떤 메모리가 여전히 필요한지 아닌지에 대한 것은 비결정적인 문제입니다. 즉, 할당된 메모리를 필요하지 않은 정확한 순간에 해제하는 알고리즘은 없습니다.
Reference-counting
가장 단순하고 쉬운 가비지 컬랙션의 알고리즘입니다.
`참조하는 대상이 없는 객체`
를 메모리 할당 해제의 대상으로 여깁니다.
코드로 예를 들어 보겠습니다.
// 힙 메모리에 me가 참조하는 객체와 me 객체의 favorities에 참조되는 배열이 저장됩니다. const me = { name: "Junseo Park", language: "Javascript", favorities: ["Three.js", "WebGL", "Opensource"], }; // me가 참조하고 있던 객체를 똑같이 참조합니다. const clone = me; // 위의 객체의 favorities 속성이 참조하고 있던 배열을 favorities 변수가 참조합니다. const favorities = me.favorities; // clone 변수가 참조하고 있던 대상이 참조 해제 되었습니다. // 이제 me 변수가 위 객체를 유일하게 참조하고 있습니다. clone = null; // 이제 위 객체를 참조하는 대상이 없으므로 메모리 할당이 해제되었습니다. me = null; // 배열을 참조하고 있던 마지막 변수 favorities 또한 메모리 할당이 해제 되었습니다. favorities = null;
Reference-counting은 가장 단순하고 쉬운 만큼 한계점이 존재합니다.
순환 참조에 대해 고려하지 않았다는 점 입니다.
function circle() { const a = { name: "junseo", }; const b = { name: "blarblar", }; a.b = b; b.a = a; a = null; b = null; } circle();
두 객체는 서로 참조하고 있지만, 더 이상 코드에서 접근할 수 있는 방법이 없습니다. 서로 참조하고 있기 때문에 Reference-counting 가비지 콜렉션은 메모리 할당 해제의 대상으로 여기지 않습니다.
최신 브라우저에서는 더이상 Reference-counting 가비지 콜렉션을 사용하지 않습니다.
Mark-and-sweep
이 알고리즘은 Reference-counting의 순환 참조 한계를 극복한 알고리즘입니다. 단순히 참조하는 대상이 있는지 확인하는 것이 아니라, root 객체에서 도달 할 수 있는지에 따라 판단합니다.
여기서 root는 전역 객체로서 javascript에서는 widow 객체를 뜻합니다. 가비지 콜렉터는 root에서 시작하여 root가 참조하는 객체들, 또 root가 참조하는 객체들이 참조하는 객체들을 타고 가면서 가비지 콜렉션의 대상을 찾습니다.
위 코드 예제의 circle의 경우에도 결국엔 root에서 도달할 수 있는 방법이 없기 때문에 가비지 콜렉션이 대상이 되므로 더이상 순환 참조의 한계점은 문제가 되지 않습니다.
현재 최신 브라우저들은 Mark-and-sweep을 바탕으로 하는 가비지 콜렉터를 사용하고 있습니다.
가비지 컬렉터의 한계
결국엔 메모리 할당을 해제해 주는 것은 가비지 콜렉터가 자동으로 해주는 것이기 때문에 메모리 최적화는 일정 수준으로만 가능합니다. 더 효율적으로 메모리를 최적화하기 위해서는 가비지 콜렉터를 사용하지 않고 수동으로 해제하는 low-level 언어를 고려해보는 것이 좋습니다.
또한 가비지 콜렉터를 동작하는데 드는 비용이 있기 때문에 가비지 콜렉터가 동작하는 동안에는 메인 스레드는 블로킹 됩니다. 메인 스레드가 오래동안 블로킹 된다면 우리의 javascript 어플리케이션의 UX 경험은 당연하게도 저해될 것입니다. 브라우저에서 가비지 컬렉터가 동작할 때 어떻게 메인 스레드를 블로킹하는지에 대한 자세한 설명은 이 블로그 에 자세히 설명되어 있습니다.
javascript의 가비지 콜렉터에서 자동으로 메모리 해제를 해주면서 우리는 메모리 관리로 시간을 낭비하는 대신에 서비스 구축에 좀 더 집중할 수 있습니다. 하지만 이런 부분이 오히려 javascript 개발자들에게 잘못된 인식을 심어줄 수 있습니다. 메모리 관리에 신경을 쓰지 않아도 된다는 인식입니다.
가비지 컬렉터는 예측할 수 없습니다. 일반적으로 가비지 수집이 언제 동작 될지 확신하는 것은 불가능합니다. 이는 어떤 상황에는 우리의 javascript 어플리케이션이 실제로 필요한 것보다 더 많은 메모리가 사용될 수 있습니다.
자바스크립트 메모리 누수
이제 우리는 javascript에서 놓치기 쉬운 메모리 누수 케이스를 살펴볼 것입니다. 아래의 몇 가지 케이스는 javascript에서 발생하는 메모리 누수의 대부분에 해당하는 원인들입니다. 이러한 원인이 왜 발생하는지 이해한다면 우리는 간단하게 메모리 누수를 방지할 수 있을 것입니다.
실수로 만들어진 전역 변수
실수로 전역 변수를 생성하는 흔한 케이스 중 하나는 `this`
를 사용하면서 발생합니다.
function setName(name) { this.name = name; } // 자바스크립트에서 this는 불리는 시점에 결정됩니다. // setName 함수에서의 this는 global 객체를 가리킵니다. setName("Junseo Park");
또한 코드를 작성하면서 `let`
또는 `const`
와 같은 키워드를 빼먹고 코드를 작성할 수도 있습니다.
javascript는 키워드를 생략하고 코드를 작성한다면 글로벌 객체에 할당하게 됩니다.
function foo() { user = "blan19"; } // 위 코드는 결국 아래와 같은 코드입니다. function foo() { window.user = "blan19"; }
하나하나 신경 쓰기 어렵기 때문에 이를 위한 간단한 해결법이 있습니다.
`strict mode`
를 사용한다면 위와 같은 우발적인 전역 변수 사용을 방지할 수 있습니다.
타이머
어플리케이션 개발을 하다 보면 가끔 setInterval을 사용할 수가 있습니다. 이러한 타이머는 주어진 delay 동안 반복적으로 실행되기 때문에 흔히 메모리 누수가 발생할 수 있습니다.
const person = { name: "Junseo Park", }; function sayHello() { const clone = person; console.log(`hello, ${clone.name}`); } const interval = setIntervale(sayHello, 1000);
위 코드는 1초마다 반복적으로 실행되고 있습니다. setInterval이 더이상 필요하지 않아도 취소하지 않는다면 이 코드는 계속해서 실행 될 것입니다. 또한 sayHello 내부에서 참조하고 있는 객체 또한 가비지 컬렉터에 수집 되지 않을 것입니다.
clearInterval(interval);
clearInterval을 통해 우리는 더이상 필요하지 않을 때 setInterval을 취소할 수 있습니다.
DOM 외부 참조
우리는 가끔 DOM element를 javascript의 객체나 배열에 저장하고 사용하는게 편할 때가 있습니다.
const form = { input: document.querySelector("input"), button: document.querySelector("button"), }; function removeButton() { document.body(document.querySelector("button")); } removeButton();
removeButton을 실행해서 button을 성공적으로 제거했다고 생각할 수 있습니다. 하지만 DOM에서는 제거 되었지만, 메모리에서는 form 객체의 button이라는 속성에 참조는 유지되고 있습니다.
function removeButton() { document.body(document.querySelector("button")); form.button = null; }
이와 같이 DOM 요소가 글로벌 객체에 연결되어 있을 때 유의해야 합니다.
마치면서
지금까지 자바스크립트 메모리 관리에 대해 알아보았습니다. 저는 이 블로그 포스팅을 작성하면서 아리송하고 간략하게만 알았던 부분들을 보완할 수 있었으며 자바스크립트 메모리 관리 개념들을 이해하는데 도움이 되었습니다. 이 글을 읽어주시는 여러분들도 마찬가지로 많은 것들을 얻고 가셨으면 합니다.
글 작성에 도움을 준 레퍼런스들
- https://developer.mozilla.org/ko/docs/Web/JavaScript/Memory_management#%EA%B0%92_%EC%82%AC%EC%9A%A9
- https://felixgerschau.com/javascript-memory-management/#reference-counting-garbage-collection
- https://deepu.tech/memory-management-in-v8/
- https://auth0.com/blog/four-types-of-leaks-in-your-javascript-code-and-how-to-get-rid-of-them/