
자바스크립트는 자동으로 메모리를 관리하는 언어입니다. 개발자가 직접 free() 같은 함수를 호출해 메모리를 해제할 필요가 없다는 뜻이죠. 대신 엔진 내부에 내장된 가비지 컬렉터(Garbage Collector)가 사용하지 않는 객체를 찾아 메모리에서 해제해 줍니다. 겉보기에는 매우 편리해 보이지만, GC가 항상 모든 문제를 해결해 주는 것은 아닙니다.
개발자가 의도치 않게 참조를 남겨두거나 불필요한 데이터를 해결해 주는 것은 아닙니다. 개발자가 의도치 않게 참조를 남겨두거나, 불필요한 데이터를 붙잡고 있다면, GC는 그 객체를 여전히 “필요하다”고 판단하여 제거하지 못합니다. 이런 상황이 쌓이면 결국 메모리 누수로 이어지고, 애플리케이션이 점점 무거워지거나 브라우저 탭이 강제 종료되는 최악의 상황을 맞을 수도 있습니다.
이번 글에서는 자바스크립트 GC의 기본 원리, 대표적인 알고리즘, 메모리 누수가 발생하는 구체적인 사례, 그리고 실무에서 이를 디버깅하고 예방하는 방법까지 차근차근 살펴보겠습니다.
자바스크립트 엔진은 메모리를 크게 세 단계로 관리합니다. 객체가 생성되면 메모리를 할당하고, 코드에서 이를 사용합니다. 그리고 더 이상 필요하지 않다고 판단되면 메모리를 해제합니다. 이 마지막 과정을 자동으로 해주는 것이 바로 가비지 컬렉션입니다.
초기의 GC 알고리즘은 참조 카운트 방식을 사용했습니다. 객체가 몇 번 참조되고 있는지를 기록하고, 참조 횟수가 0이 되면 메모리에서 제거하는 방식입니다. 예를 들어 다음과 같은 코드가 있다고 가정해 봅시다.
let a = { name: "효빈" };
let b = a;
a = null;
b = null;
처음 객체가 생성되었을 때 참조 카운트는 1입니다. b = a가 실행되면서 참조 횟수가 2가 되고, 이후 a = null, b = null이 실행되면 참조 횟수가 0이 되어 메모리에서 해제됩니다.
이 방식은 단순하지만 치명적인 문제가 있는데요, 바로 순환 참조(circular reference)입니다.
function createCycle() {
const obj1 = {};
const obj2 = {};
obj1.other = obj2;
obj2.other = obj1;
return obj1;
}
이 경우 obj1과 obj2는 서로를 참조하고 있기 때문에 참조 카운트가 0이 되지 않습니다. 실제로는 외부에서 더 이상 접근할 수 없는 객체임에도, 참조 카운트 방식에서는 메모리 해제가 이루어지지 않아 누수가 발생합니다.
현재 자바스크립트 엔진(V8, SpiderMonkey 등)이 사용하는 방식은 마크 앤 스윕 알고리즘입니다. 이 방식은 참조 카운트의 한계를 극복했습니다.
GC는 먼저 루트 객체(root objects)를 기준으로 도달 가능한 객체들을 “마킹(mark)”합니다. 루트 객체란 브라우저 환경에서는 window, Node.js 환경에서는 global과 같은 전역 객체를 의미합니다. 루트에서 출발해 참조할 수 있는 모든 객체를 따라가며 표시해 두고, 그 외에 표시되지 않은 객체는 “도달 불가능하다(unreachable)”고 판단해 메모리에서 제거합니다.
<코드>
let user = { name: "Alice" };
let admin = { role: "admin" };
user.admin = admin;
admin.user = user;
// 두 객체가 서로를 참조하지만…
user = null;
admin = null;
// 더 이상 루트 객체에서 도달할 수 없으므로 GC가 정리함.
이처럼 순환 참조가 있더라도 루트에서 출발해 도달할 수 없으면 해제 대상이 되기 때문에 훨씬 안전합니다.
자동 메모리 관리가 있다고 해서 항상 안전한 것은 아닙니다. 코드 작성 습관이나 실수로 인해 GC가 객체를 해제하지 못하는 경우가 많습니다. 특히 규모가 큰 프론트엔드 프로젝트에서는 메모리 누수가 곧 성능 문제로 이어집니다.
클로저는 함수가 외부 스코프의 변수를 기억하는 기능입니다. 이 자체는 유용하지만, 필요 없는 데이터를 계속 붙잡고 있을 때 문제가 됩니다.
function createBigClosure() {
const bigArray = new Array(1000000).fill("데이터");
return function() {
console.log(bigArray[0]);
};
}
const fn = createBigClosure();
// bigArray는 더 이상 필요 없지만 fn이 참조하고 있어 GC가 해제하지 못함
위 예제에서 bigArray는 사실상 필요 없지만, 클로저 fn이 이를 참조하고 있기 때문에 메모리에 남아 있게 됩니다.
DOM 요소를 제거할 때 이벤트 리스너를 함께 제거하지 않으면 참조가 남습니다.
const button = document.getElementById("btn");
function handleClick() {
console.log("클릭");
}
button.addEventListener("click", handleClick);
// 이후 DOM 제거
document.body.removeChild(button);
// 하지만 이벤트 리스너는 여전히 메모리에 남아 있음
이 경우 버튼 자체는 DOM에서 제거되었지만, 이벤트 리스너가 남아 있어서 메모리에서 해제되지 않습니다. 수많은 버튼이 동적으로 생성되고 사라지는 애플리케이션이라면 점점 메모리가 불어날 것입니다.
DOM 요소를 전역 변수나 캐시에 저장해두고 해제하지 않는 경우에도 문제가 생깁니다.
let cachedDiv;
function createDiv() {
cachedDiv = document.createElement("div");
document.body.appendChild(cachedDiv);
}
function removeDiv() {
document.body.removeChild(cachedDiv);
// cachedDiv를 null로 해제하지 않으면 GC가 수거하지 못함
}
DOM에서 제거되었어도 자바스크립트 변수 cachedDiv가 참조를 유지하고 있기 때문에 메모리가 해제되지 않습니다.
실제로 메모리 누수를 어떻게 탐지하고 해결할 수 있을까요? 브라우저는 메모리 분석 도구를 제공하므로 적극적으로 활용하는 것이 좋습니다.
1) Chrome DevTools의 Memory 탭 활용
DevTools를 열고 Memory 탭으로 들어가면 세 가지 주요 기능을 사용할 수 있습니다. Heap snapshot 기능은 현재 메모리 상태를 스냅샷으로 찍어, 어떤 객체가 얼마나 차지하고 있는지 확인할 수 있습니다. 특정 이벤트 전후로 스냅샷을 두 번 찍어 비교하면, 해제되지 않고 남아 있는 객체를 쉽게 찾을 수 있습니다.
Allocation instrumentation은 시간 흐름에 따른 메모리 할당과 해제를 추적할 수 있습니다. 특정 동작, 예를 들어, 페이지 전환이나 탭 닫기 후에도 메모리가 줄어들지 않는다면 누수를 의심해야 합니다. Record Allocation Timeline 기능은 객체가 언제 생성되고 해제되는지를 추적해, 특정 함수나 이벤트 핸들러가 원인인지 분석할 수 있도록 도와줍니다.
실무에서는 몇 가지 습관을 들여야 메모리 누수를 예방할 수 있습니다. 이벤트 리스너를 등록했다면 반드시 해제하는 습관을 가져야 합니다. React에서는 useEffect의 cleanup 함수, Vue에서는 beforeUnmount 훅을 활용하면 이런 과정을 자동으로 관리할 수 있습니다.
전역 변수는 최소화해야 하며, 필요한 경우 지역 범위 내에서 데이터를 관리하는 구조를 권장합니다. 대규모 데이터를 담는 배열이나 Map은 사용이 끝나면 반드시 null 할당이나 clear 메서드를 통해 참조를 끊어야 합니다. 또한 WeakMap이나 WeakSet 같은 자료구조를 활용하면 키가 참조되지 않을 때 자동으로 GC 대상이 되기 때문에 캐시 용도로 적합합니다.
자바스크립트의 가비지 컬렉션은 개발자가 직접 메모리를 관리하지 않아도 된다는 점에서 큰 장점입니다. 그러나 자동이라고 해서 무조건 안전한 것은 아닙니다. 참조 카운트와 마크 앤 스윕 같은 알고리즘의 원리를 이해하면, 어떤 상황에서 메모리가 해제되지 않는지 더 명확히 알 수 있습니다.
실무에서 자주 발생하는 메모리 누수 원인은 클로저, 이벤트 리스너, 그리고 DOM 참조입니다. 이 세 가지는 모두 GC가 “여전히 참조되고 있다”고 착각하게 만드는 코드 패턴인데요. 이벤트 리스너를 반드시 해제하고, DOM 참조는 필요할 때만 유지하며, 클로저는 꼭 필요한 변수만 남기는 습관을 들여야 합니다. 또한 Chrome DevTools의 Memory 탭을 활용하면, 현재 애플리케이션에서 어떤 객체가 불필요하게 메모리를 차지하고 있는지 쉽게 확인할 수 있습니다. 이런 도구를 정기적으로 사용하는 것만으로도, 예상치 못한 성능 저하나 브라우저 다운 문제를 크게 줄일 수 있습니다.
결국 GC는 자동으로 동작하는 믿음직한 관리인 같지만, 개발자가 기본 원리와 주의점을 모른다면 오히려 성능을 해치는 복병이 될 수도 있습니다. 가비지 컬렉션의 동작 원리와 누수 방지 전략을 잘 이해한다면, 자바스크립트 애플리케이션을 더욱 안정적이고 효율적으로 운영할 수 있을 겁니다.
©️요즘IT의 모든 콘텐츠는 저작권법의 보호를 받는 바, 무단 전재와 복사, 배포 등을 금합니다.