가비지 컬렉션(Garbage Collection)을 사용하는 언어에서 메모리 누수의 가장 큰 원인은 불필요한 참조(unwanted references)입니다. 메모리 누수를 제대로 이해하려면 먼저 메모리가 해제되는 과정, 즉 가비지 컬렉션이 어떻게 동작하는지 살펴볼 필요가 있습니다.
마크 앤 스윕(Mark-and-Sweep) 알고리즘
이 알고리즘은 "더 이상 필요하지 않은 객체"라는 개념을 "도달할 수 없는(unreachable) 객체"로 재정의합니다. 이 방식은 루트(roots)라고 불리는 객체 집합이 존재한다는 전제를 기반으로 합니다. 자바스크립트에서 루트는 전역 객체(global object)입니다.
가비지 컬렉터(GC)는 주기적으로 이러한 루트에서 출발하여, 루트가 참조하는 모든 객체를 재귀적으로 탐색합니다. 결과적으로 GC는 루트로부터 도달 가능한 모든 객체를 식별하고, 도달할 수 없는 객체들을 수집하여 메모리를 해제합니다.
메모리 누수의 대표적인 유형
1. 전역 변수 (선언되지 않았거나 실수로 생성된 변수)
자바스크립트에서는 선언 키워드(let, var, const)를 지정하지 않으면 변수가 의도치 않게 전역으로 선언될 수 있습니다. JS는 스코프를 바깥쪽으로 거슬러 올라가며 변수를 탐색하고, 어느 스코프에서도 해당 변수를 찾지 못하면 새로운 전역 변수를 생성해 버립니다.
function test() {
a = [1, 2, 3]
}
test()
// a는 선언 키워드 없이 초기화되었으므로 전역 스코프에 존재하게 됩니다.
console.log(a)[1, 2, 3]
이처럼 변수가 개발자의 의도와 무관하게 전역 스코프에 존재하게 되면, 프로그램이 종료될 때까지 메모리에서 해제되지 않아 메모리 누수의 원인이 됩니다. 이 문제는 항상 선언 키워드를 명시하는 습관으로 간단히 예방할 수 있습니다.
2. 클로저(Closures)
외부 함수에서 선언된 변수는 중첩된 내부 함수에 자동으로 접근 가능합니다. 문제는 내부 함수에서 해당 변수를 실제로 사용하거나 참조하지 않더라도, 변수가 계속 메모리에 남아 있다는 점입니다. 이렇게 불필요하게 유지되는 참조가 클로저로 인한 메모리 누수를 발생시킵니다.
3. 분리된 DOM / DOM 외부 참조(Detached DOM)
DOM은 이중 연결 구조의 트리 형태입니다. 따라서 트리 내 임의의 노드 하나만이라도 참조하고 있으면, 전체 트리가 가비지 컬렉션되지 못합니다. 분리된 DOM(Detached DOM)이란 DOM 트리에서는 제거되었지만 자바스크립트를 통해 여전히 메모리에 남아 있는 노드를 의미합니다.
즉, 변수나 객체에 대한 참조가 코드 어딘가에 존재하는 한, 해당 객체는 DOM에서 제거된 이후에도 가비지 컬렉션되지 않습니다. 따라서 DOM의 특정 부분 사용이 끝났다면 반드시 자바스크립트 쪽의 참조도 함께 제거해야 합니다.
4. 이벤트 리스너(Event Listeners)
addEventListener() 메서드는 요소에 이벤트 핸들러를 연결하며, 하나의 요소에 여러 개의 이벤트 핸들러를 추가할 수 있습니다. 만약 DOM 요소와 해당 요소에 연결된 이벤트 리스너의 생명주기(lifecycle)가 서로 일치하지 않으면, 더 이상 필요 없는 리스너가 메모리에 계속 남아 메모리 누수로 이어질 수 있습니다.
마무리
자바스크립트의 가비지 컬렉터는 대부분의 메모리 관리를 자동으로 처리해 주지만, 개발자가 만들어 낸 불필요한 참조까지는 정리해 주지 못합니다. 전역 변수 남용을 피하고, 클로저와 DOM 참조, 이벤트 리스너의 생명주기를 꼼꼼히 관리하는 것이 안정적인 애플리케이션을 만드는 핵심입니다.