메모리 관리와 가비지 컬렉션
우리는 지금까지 자바스크립트의 문법, 동작 원리, 비동기 처리, 모듈 시스템 등 다양한 측면을 학습했습니다. 이러한 지식은 코드를 "어떻게 작성할 것인가"에 초점을 맞추었습니다. 하지만 코드를 실행한다는 것은 결국 컴퓨터의 자원, 특히 메모리(Memory) 를 사용한다는 의미입니다. 작성된 코드가 메모리를 어떻게 효율적으로 사용하고, 불필요해진 메모리를 어떻게 해제하는지 이해하는 것은 성능 최적화와 애플리케이션의 안정성에 매우 중요합니다.
자바스크립트는 C/C++와 같은 저수준 언어와 달리, 개발자가 직접 메모리를 할당하거나 해제할 필요가 없습니다. 자바스크립트 엔진은 가비지 컬렉션(Garbage Collection) 이라는 메커니즘을 통해 자동으로 메모리를 관리합니다. 하지만 이 '자동'이라는 말은 우리가 메모리 관리에 대해 전혀 신경 쓰지 않아도 된다는 의미는 아닙니다. 가비지 컬렉션의 동작 원리를 이해하고 있어야 의도치 않은 메모리 누수(Memory Leak)를 방지하고, 더 나아가 애플리케이션의 성능을 최적화할 수 있습니다.
이번 장에서는 자바스크립트에서 메모리가 어떻게 할당되고, 더 이상 사용되지 않는 메모리가 어떻게 감지되어 회수되는지, 그리고 흔히 발생하는 메모리 누수 시나리오와 이를 방지하는 방법에 대해 깊이 있게 알아보겠습니다.
자바스크립트 메모리 생명주기
프로그래밍 언어에서 메모리 관리는 일반적으로 다음과 같은 생명주기를 가집니다.
- 메모리 할당 (Allocation): 프로그램이 변수나 함수 등을 필요로 할 때 메모리를 할당합니다.
- 메모리 사용 (Usage): 할당된 메모리에 값을 읽고 쓰는 등의 작업을 수행합니다.
- 메모리 해제 (Release): 더 이상 필요 없는 메모리를 해제하여 다른 용도로 사용할 수 있도록 합니다.
자바스크립트에서는 이 모든 과정이 엔진에 의해 자동으로 처리됩니다.
값의 종류와 메모리 할당 방식
자바스크립트에서 데이터는 크게 두 가지 방식으로 메모리에 저장됩니다.
-
원시 값 (Primitive Values):
Number
,String
,Boolean
,null
,undefined
,Symbol
,BigInt
와 같은 원시 값은 스택(Stack) 메모리 영역에 고정된 크기로 할당됩니다. 스택은 데이터가 순서대로 쌓이고 가장 마지막에 쌓인 데이터가 먼저 제거되는 (LIFO: Last-In, First-Out) 구조로, 접근 속도가 빠르고 관리가 용이합니다.let a = 10; // 숫자 10은 스택에 할당 let b = "hello"; // 문자열 "hello"는 스택에 할당 (일반적으로) let c = true; // 불리언 true는 스택에 할당
-
객체 (Objects)와 함수 (Functions):
Object
,Array
,Function
와 같은 참조 타입의 값은 힙(Heap) 메모리 영역에 동적으로 할당됩니다. 힙은 스택과 달리 크기가 가변적이며, 데이터가 할당되는 순서에 관계없이 자유롭게 할당되고 해제됩니다. 변수는 힙에 할당된 객체의 메모리 주소(참조) 를 스택에 저장합니다.let obj = { name: "Alice", age: 30 }; // 객체는 힙에 할당되고, obj 변수는 그 객체의 주소를 스택에 저장 let arr = [1, 2, 3]; // 배열은 힙에 할당되고, arr 변수는 그 배열의 주소를 스택에 저장 let func = function() { console.log("hi"); }; // 함수도 힙에 할당되고, func 변수는 함수의 주소를 스택에 저장 let obj2 = obj; // obj2는 obj가 가리키는 동일한 힙 메모리 주소를 가리킴 obj2.age = 31; console.log(obj.age); // 결과: 31 (obj와 obj2는 같은 객체를 참조하므로)
참조 타입의 경우, 변수가 실제 값을 직접 저장하는 것이 아니라 값이 저장된 메모리 위치를 가리킨다는 점이 중요합니다.
가비지 컬렉션 (Garbage Collection)
자바스크립트는 더 이상 필요 없는(도달 불가능한) 객체를 자동으로 찾아내 메모리에서 해제하는 가비지 컬렉션(Garbage Collection, GC) 메커니즘을 사용합니다. GC의 목표는 개발자가 수동으로 메모리를 관리할 필요 없이 메모리 누수를 방지하고 효율적인 자원 사용을 가능하게 하는 것입니다.
도달 가능성 (Reachability) 개념
가비지 컬렉션은 기본적으로 '도달 가능성(Reachability)' 이라는 개념을 사용하여 메모리를 해제할지 말지를 결정합니다.
- 루트(Roots): 항상 도달 가능한(가비지 컬렉션 대상이 아닌) 값들을 의미합니다.
- 현재 실행 중인 함수의 지역 변수와 매개변수
- 다른 함수에서 호출되었지만 아직 끝나지 않은 함수의 변수들
- 전역 변수 (
window
객체 또는 Node.js의global
객체에 저장된 변수) - DOM 요소 (document 객체)
- 도달 가능한 객체: 루트에서부터 참조 체인을 따라갈 때 접근할 수 있는 모든 객체를 '도달 가능한 객체'라고 합니다.
- 가비지 (Garbage): 도달 가능한 객체가 아닌 모든 객체는 '가비지'로 간주되어 가비지 컬렉션의 대상이 됩니다. 즉, 어떤 변수도 참조하지 않거나, 참조하더라도 루트로부터 도달할 수 없는 객체는 가비지가 됩니다.
let user = {
name: "John"
};
// user 객체는 'user' 변수에 의해 참조되므로 도달 가능합니다.
user = null; // 이제 user 변수는 null을 가리킵니다.
// { name: "John" } 객체는 더 이상 어떤 변수도 참조하지 않으므로 가비지가 되어 GC 대상이 됩니다.
let user1 = { name: "Alice" };
let user2 = user1; // user2도 { name: "Alice" }를 참조합니다.
user1 = null; // { name: "Alice" }는 여전히 user2에 의해 참조되므로 가비지가 아닙니다.
user2 = null; // 이제 { name: "Alice" }는 더 이상 참조되지 않으므로 가비지가 됩니다.
마크-스윕 (Mark-and-Sweep) 알고리즘
가장 널리 사용되는 가비지 컬렉션 알고리즘 중 하나입니다.
- 마크 (Mark) 단계: 가비지 컬렉터는 '루트'에서부터 시작하여 모든 도달 가능한 객체들을 '표시(mark)'합니다. 루트에서 직접 참조하는 객체들, 그리고 그 객체들이 참조하는 객체들... 이런 식으로 모든 참조를 따라가며 표시합니다.
- 스윕 (Sweep) 단계: 마크되지 않은(즉, 도달 불가능한) 모든 객체들을 메모리에서 '제거(sweep)'합니다.
현대 자바스크립트 엔진(V8 등)은 이 알고리즘을 기반으로 다양한 최적화 기법(Generational Collection, Incremental GC 등)을 적용하여 가비지 컬렉션으로 인한 성능 저하를 최소화합니다.
- Generational Collection: 객체를 '새로운 세대'와 '오래된 세대'로 나누어 관리합니다. 새로 생성된 객체는 빠르게 가비지가 될 확률이 높고, 오래된 객체는 오랫동안 살아남을 확률이 높다고 가정합니다. 따라서 새로운 세대에 대한 GC를 더 자주, 더 빠르게 수행하여 효율성을 높입니다.
- Incremental GC: GC 작업을 한 번에 모두 수행하지 않고, 작은 조각으로 나누어 점진적으로 수행합니다. 이는 GC로 인해 애플리케이션이 잠시 멈추는 현상(Stop-The-World)을 줄여 사용자 경험을 향상시킵니다.
메모리 누수 (Memory Leak)
가비지 컬렉션이 자동으로 메모리를 해제해주지만, 개발자의 실수로 인해 더 이상 필요 없는 객체가 '도달 가능한 상태'로 남아있게 되는 경우가 발생합니다. 이렇게 되면 가비지 컬렉터가 해당 객체를 가비지로 인식하지 못해 메모리에서 해제하지 못하게 되고, 결국 메모리 사용량이 계속 증가하는 현상이 발생하는데, 이를 메모리 누수(Memory Leak) 라고 합니다. 메모리 누수가 심해지면 애플리케이션의 성능이 저하되고, 결국 크래시(Crash)로 이어질 수 있습니다.
흔히 발생하는 메모리 누수 시나리오와 방지법:
-
의도치 않은 전역 변수:
var
키워드 없이 선언하거나,this
를 잘못 사용하여 전역 객체(window)의 프로퍼티로 할당되는 변수들. 전역 변수는 페이지가 닫히기 전까지는 가비지 컬렉션 대상이 되지 않습니다.function createLeakyGlobal() { // 'use strict'가 없으면 window.leakyVar = '...' 가 됨 leakyVar = "나는 의도치 않은 전역 변수!"; } createLeakyGlobal(); // leakyVar는 계속 메모리에 남아있습니다.
방지법: 항상
const
,let
을 사용하여 변수를 선언하고,'use strict'
모드를 사용합니다. -
타이머 (setTimeout, setInterval)의 잘못된 사용: 타이머의 콜백 함수 내부에서 외부 스코프의 변수를 참조하고, 타이머가 명시적으로 해제되지 않으면, 해당 변수들이 메모리에서 해제되지 않을 수 있습니다.
let data = { value: "important data" }; setInterval(() => { // data 객체를 참조하는 콜백이 계속 실행되므로, data는 GC 대상이 되지 않음 console.log(data.value); }, 1000); // data = null; // 이렇게 해도 콜백이 data를 계속 참조하므로 GC 안 됨 // 방지법: 필요 없으면 반드시 타이머를 해제합니다. let timerId; function startTimerLeak() { let count = 0; timerId = setInterval(() => { console.log(count++); if (count > 5) { clearInterval(timerId); // 타이머 명시적 해제 } }, 500); } startTimerLeak();
-
이벤트 리스너의 미해제: DOM 요소에 이벤트 리스너를 추가했지만, 해당 DOM 요소가 DOM 트리에서 제거될 때 리스너를 함께 제거하지 않으면 메모리 누수가 발생할 수 있습니다. (특히 오래된 브라우저나 IE에서 더 흔함, 모던 브라우저는 개선됨)
const button = document.getElementById('myButton'); button.addEventListener('click', function onClick() { // 이 함수가 외부 스코프의 큰 객체를 참조한다면 메모리 누수 발생 가능성 console.log("버튼 클릭!"); }); // button이 DOM에서 제거되어도 onClick 함수가 제거되지 않으면 누수 // button.removeEventListener('click', onClick); // 명시적 해제 필요
방지법:
removeEventListener
를 사용하여 더 이상 필요 없는 리스너는 명시적으로 제거합니다. 또는 React와 같은 라이브러리는 컴포넌트 언마운트 시 자동으로 리스너를 정리해줍니다. -
클로저의 과도한 사용: 클로저는 외부 스코프의 변수를 기억하는 강력한 기능이지만, 클로저가 불필요하게 오래 유지되면 클로저가 기억하는 외부 변수들도 GC 대상에서 제외되어 메모리 누수로 이어질 수 있습니다.
let longLivedRef = null; function createClosure() { let largeData = new Array(1000000).fill('some string'); // 큰 배열 longLivedRef = function() { // 이 클로저가 largeData를 참조 console.log(largeData.length); }; } createClosure(); // longLivedRef 함수가 null로 설정되거나, longLivedRef를 참조하는 다른 변수가 없어지기 전까지 largeData는 해제되지 않습니다. // longLivedRef = null; // 이렇게 해야 largeData가 GC 대상이 됩니다.
방지법: 클로저를 사용할 때, 클로저가 참조하는 외부 변수가 언제까지 필요한지 신중하게 고려하고, 필요 없어진 시점에는 참조를 해제(예: 변수에
null
할당)하거나 클로저 자체를null
로 만들어 GC 대상이 되도록 합니다.
메모리 누수 진단 도구: 크롬 개발자 도구의 'Performance' 탭이나 'Memory' 탭을 활용하면 실시간으로 메모리 사용량을 모니터링하고, 특정 시점의 힙 스냅샷(Heap Snapshot)을 찍어 어떤 객체들이 메모리에 남아있는지 분석하여 메모리 누수를 진단할 수 있습니다.
마무리하며
이번 장에서는 자바스크립트의 메모리 관리 방식과 가비지 컬렉션(Garbage Collection) 의 원리에 대해 심도 있게 학습했습니다. 여러분은 원시 값과 객체가 스택과 힙 메모리에 각각 어떻게 할당되는지 이해했고, 가비지 컬렉터가 '도달 가능성' 개념을 바탕으로 메모리를 자동으로 해제하는 Mark-and-Sweep
알고리즘에 대해 알아보았습니다.
또한, 자동 메모리 관리에도 불구하고 개발자의 실수로 인해 발생할 수 있는 메모리 누수(Memory Leak) 의 주요 시나리오(의도치 않은 전역 변수, 해제되지 않은 타이머/이벤트 리스너, 클로저의 잘못된 사용 등)를 파악하고, 이를 방지하기 위한 실제적인 방법들을 살펴보았습니다.
메모리 관리에 대한 이해는 고성능의 안정적인 웹 애플리케이션을 구축하는 데 필수적입니다. 이제 여러분은 코드가 메모리에서 어떻게 살아 움직이는지에 대한 깊은 통찰력을 얻게 되었을 것입니다. 개발자 도구를 활용하여 자신의 애플리케이션의 메모리 사용량을 주기적으로 모니터링하고, 메모리 누수를 사전에 방지하는 습관을 들이는 것이 중요합니다.