안녕하세요.
웹 애플리케이션이 점점 복잡해지면서 성능 문제는 흔한 골칫거리가 되었습니다. 특히 사용자 경험에 치명적인 영향을 주는 것 중 하나가 바로 '메모리 누수(Memory Leak)'입니다. JavaScript는 가비지 컬렉터가 자동으로 메모리를 관리해 주지만 우리의 코딩 실수로 인해 더 이상 필요 없는 메모리가 해제되지 못하고 계속 쌓이는 현상이 발생할 수 있죠. 이는 앱을 느리게 만들고 결국에는 브라우저 충돌로 이어지기도 합니다. 이 글에서는 JavaScript 메모리 누수가 왜 발생하며 어떻게 진단하고 효과적으로 해결할 수 있는지 알아보겠습니다.
메모리 누수란 무엇이며 왜 위험한가?
JavaScript 엔진은 가비지 컬렉션(Garbage Collection) 메커니즘을 사용하여 더 이상 접근 불가능한 객체의 메모리를 자동으로 회수합니다. 하지만 어떤 객체가 더 이상 사용되지 않음에도 불구하고 코드 어딘가에서 여전히 그 객체를 참조하고 있다면 가비지 컬렉터는 해당 메모리를 회수할 수 없게 됩니다. 이것이 바로 메모리 누수입니다.
메모리 관리 기초
JavaScript엔진은 Reachability(도달 가능성) 개념을 사용하여 객체가 '살아 있는지' 판단합니다. '루트(roots)'라고 불리는 항상 도달 가능한 값(예: 전역 변수, 현재 실행 중인 함수의 로컬 변수, 이벤트 핸들러 등)으로부터 참조 체인을 따라가서 도달할 수 있는 모든 객체는 살아있다고 간주됩니다. 도달할 수 없는 객체는 '죽은' 객체로 판단되어 가비지 컬렉터에 의해 메모리가 회수됩니다.
발생되는 문제점
작은 누수는 눈에 띄지 않지만 시간이 지남에 따라 또는 애플리케이션 사용량이 많아짐에 따라 메모리 사용량이 지속적으로 증가하여 다음과 같은 문제를 야기할 수 있습니다.
전체적인 성능 저하
메모리 부족으로 인해 가비지 컬렉션이 더 자주, 더 오래 실행되어 애플리케이션 응답성이 느려집니다.
UI 끊김 현상
가비지 컬렉션은 메인 스레드를 블록 할 수 있어 UI가 버벅거리거나 멈추는 현상이 발생합니다.
브라우저 또는 앱 크래시
메모리 사용량이 특정 임계값을 초과하면 브라우저 탭이 멈추거나 충돌할 수 있습니다.
불안정한 사용자 경험 (UX)
예측할 수 없는 시점에 성능 문제가 발생하여 사용자 만족도를 떨어뜨립니다.
흔히 발생하는 JavaScript 메모리 누수 유형
메모리 누수는 다양한 원인으로 발생하지만 웹 개발에서 흔히 마주치는 몇 가지 유형이 있습니다.
전역 변수 사용
'var', 'let', 'const' 키워드 없이 변수를 선언하면 해당 변수는 전역 객체(브라우저에서는 'window', Node.js에서는 'global')의 속성이 됩니다. 전역 변수는 페이지나 프로세스가 종료될 때까지 해제되지 않으므로 대량의 데이터를 전역 변수에 할당하거나 불필요한 전역 변수를 많이 생성하면 메모리 누수의 원인이 됩니다.
예제
function assignGlobalLeaky() {
// 'var', 'let', 'const' 없이 선언하면 전역 객체의 속성이 됨
leakyGlobalData = { largeString: new Array(1000000).join('x') };
}
assignGlobalLeaky();
// leakyGlobalData는 페이지가 닫히기 전까지 가비지 컬렉션되지 않음
console.log(window.leakyGlobalData.largeString.length); // 접근 가능
해결
function avoidGlobalLeak() {
// localData는 함수 실행이 끝나면 더 이상 접근 불가능하므로 가비지 컬렉션 대상이 됨
const localData = { largeString: new Array(1000000).join('x') };
}
avoidGlobalLeak();
// console.log(localData); // ReferenceError: localData is not defined
항상 변수 선언 키워드를 사용하여 변수 스코프를 명확히 하고 전역 스코프 사용을 최소화하세요.
타이머/옵저버 해제 누락
'setInterval', 'setTimeout', 'MutationObserver', 'IntersectionObserver' 등은 명시적으로 해제해주지 않으면 콜백 함수와 그 함수가 참조하는 모든 변수들이 메모리에 계속 남아있을 수 있습니다. 특히 Single Page Application(SPA)에서 페이지 이동 시 이전 페이지에서 설정한 타이머나 옵저버를 해제하지 않는 경우 흔히 발생합니다.
예제
let intervalId;
function startLeakyTimer() {
const largeObject = { data: new Array(500000).fill('payload') }; // 큰 객체
intervalId = setInterval(() => {
// 클로저가 largeObject를 참조하므로 largeObject는 해제되지 않음
console.log('Timer tick:', largeObject.data.length);
}, 1000);
}
startLeakyTimer();
// 이 상태에서 페이지를 이동하거나 관련 UI가 사라져도 타이머는 계속 돌며 largeObject를 붙잡고 있음.
// intervalId를 clearInterval로 해제하지 않으면 누수 발생.
해결
let intervalId;
function startTimer() {
// 가급적 클로저가 큰 객체를 참조하지 않도록 설계하거나,
// 필요한 데이터만 전달하는 방식 고려
intervalId = setInterval(() => {
console.log('Timer running');
}, 1000);
}
function stopTimer() {
if (intervalId) {
clearInterval(intervalId);
intervalId = null; // 참조 해제
console.log('Timer stopped');
}
}
startTimer();
// ... 나중에 타이머가 필요 없어지거나 컴포넌트 언마운트 시 ...
stopTimer();
타이머는 'clearInterval' 또는 'clearTimeout'으로 옵저버는 'observer.disconnect()' 또는 'observer.unobserve()'로 사용 후 반드시 해제해야 합니다.
클로저 오용
클로저는 자바스크립트의 강력한 기능이지만 외부 함수의 특정 변수를 계속 참조하게 만듦으로써 예상치 못한 메모리 누수를 유발할 수 있습니다. 특히 클로저 함수 자체가 어딘가(전역 변수, 이벤트 리스너, 다른 객체의 속성 등)에 의해 계속 참조되고 있고, 그 클로저가 DOM 요소나 큰 객체를 참조하는 경우 문제가 될 수 있습니다.
function createLeakyClosure() {
const largeData = new Array(1000000).join('-'); // 이 데이터가 메모리에 남게 됨
const element = document.getElementById('myElement'); // DOM 요소 참조
return function() {
console.log(largeData.length, element.tagName); // 클로저가 largeData와 element를 참조
};
}
const leakyHandler = createLeakyClosure();
// leakyHandler 함수가 존재하는 한, 그리고 이 함수가 참조하는 largeData와 element가
// 가비지 컬렉션 대상이 되지 않도록 붙잡고 있는 한 누수 발생.
// 예를 들어, 이 함수를 이벤트 리스너로 추가하고 제거하지 않으면 누수됩니다.
// 예: document.body.addEventListener('click', leakyHandler); // 누수 가능성
클로저가 꼭 필요한 변수만 참조하도록 설계하고 불필요하게 큰 객체를 클로저 스코프에 가두지 않도록 주의하세요. 클로저 함수 자체의 생명주기를 관리하여 필요 없어지면 참조를 'null' 등으로 명시적으로 해제하여 가비지 컬렉션이 가능하도록 만드세요. 특히 이벤트 리스너 등으로 클로저를 사용할 때는 반드시 제거 로직을 추가해야 합니다.
DOM 참조 누수
JavaScript 코드에서 DOM 요소를 직접 참조(변수에 할당)하고 있는데 해당 DOM 요소가 DOM 트리에서 제거되었지만 코드 내의 변수가 여전히 해당 요소를 참조하고 있는 경우 발생합니다. 가비지 컬렉터는 DOM 트리에 더 이상 연결되어 있지 않더라도 코드에서 참조하고 있으면 해당 요소를 해제하지 않습니다. 이를 'Detached DOM tree' 문제라고도 부릅니다.
예제
const detachedElements = [];
function addAndRemoveElement() {
const div = document.createElement('div');
div.innerText = '제거될 요소';
document.body.appendChild(div);
// DOM 트리에서 제거
document.body.removeChild(div);
detachedElements.push(div); // 제거된 div 요소의 참조를 배열에 저장
}
addAndRemoveElement();
// detachedElements 배열에 의해 제거된 div 요소(및 그 하위 요소)의 메모리가 해제되지 않고 누수됨
해결
const processedElements = [];
function addAndProperlyRemoveElement() {
let div = document.createElement('div');
div.innerText = '제거될 요소 (정상 처리)';
document.body.appendChild(div);
// DOM 트리에서 제거
document.body.removeChild(div);
// 요소에 대한 참조를 배열에 저장하는 대신 필요한 데이터만 저장하거나,
// 정말 필요하다면 복제해서 사용 (단, 복제도 메모리 사용)
// processedElements.push({ id: 'some-id' });
// 가장 중요: 변수의 참조를 명시적으로 해제
div = null; // 이제 원래 div 요소는 참조되지 않음
}
addAndProperlyRemoveElement();
// 이제 제거된 div 요소는 가비지 컬렉션 대상이 됨
DOM 요소를 제거할 때 코드 내에서 해당 요소에 대한 모든 참조(변수, 배열, 객체 속성 등)를 명시적으로 `null` 등으로 설정하여 참조를 끊어주세요.
이벤트 리스너 제거 누락
DOM 요소에 이벤트 리스너를 추가한 후 해당 요소가 제거되거나 컴포넌트가 파괴될 때 'removeEventListener'를 호출하여 리스너를 제거하지 않으면 누수가 발생할 수 있습니다. 이벤트 리스너가 내부적으로 해당 요소를 참조하고 리스너 함수(그리고 그 클로저 스코프)가 계속 살아남기 때문입니다.
예제
const leakyButton = document.getElementById('leakyButton');
if (leakyButton) {
// 익명 함수를 사용하면 removeEventListener로 제거하기 어려움
leakyButton.addEventListener('click', function() {
const data = { largeContext: new Array(100000).fill('context') };
console.log('Button clicked', data.largeContext.length);
// 이 익명 함수 클로저가 data를 참조하여, 리스너가 살아있는 한 data도 해제되지 않음
});
}
// 이 상태에서 leakyButton 요소를 DOM에서 제거하더라도,
// 이벤트 리스너와 그 클로저(data 포함)는 계속 메모리에 남아 누수 발생
// document.body.removeChild(leakyButton); // 이것만으로는 부족
해결
const cleanButton = document.getElementById('cleanButton');
function handleCleanClick() {
// 필요한 데이터만 참조하거나 스코프를 분리
console.log('Clean button clicked');
}
if (cleanButton) {
cleanButton.addEventListener('click', handleCleanClick);
}
// ... 나중에 cleanButton 요소를 제거하거나 컴포넌트 언마운트 시 ...
if (cleanButton) { // 요소가 아직 존재하는지 확인하는 것이 안전
cleanButton.removeEventListener('click', handleCleanClick);
}
// 또는 한 번만 필요한 이벤트의 경우
const onceButton = document.getElementById('onceButton');
if (onceButton) {
onceButton.addEventListener('click', function() {
console.log('Clicked once');
}, { once: true }); // 이벤트 발생 후 자동으로 리스너 제거
}
'addEventListener'로 추가한 리스너는 반드시 'removeEventListener'로 제거해야 합니다. 이를 위해 이벤트 핸들러 함수를 별도로 정의하여 참조를 유지하거나 이벤트 옵션 객체의 'once: true'를 활용하여 한 번 실행 후 자동으로 제거되도록 할 수 있습니다. 컴포넌트 기반 개발에서는 컴포넌트 언마운트 시점에 리스너를 제거하는 로직을 구현하세요.
캐시 남용
애플리케이션 성능 향상을 위해 데이터를 캐싱하는 것은 일반적입니다. 하지만 캐시의 크기를 제한하지 않거나 더 이상 필요 없는 데이터를 캐시에서 비우는 로직이 없다면 시간이 지남에 따라 캐시가 계속 커져 메모리 누수처럼 동작할 수 있습니다. 엄밀히 말해 누수는 아니지만 불필요한 메모리를 점유한다는 점에서 성능 문제를 야기합니다.
캐시에 최대 크기를 설정하거나 LRU(Least Recently Used) 같은 캐시 만료 정책을 구현하여 사용 빈도가 낮은 오래된 데이터를 자동으로 제거하도록 만드세요. 'Map' 객체나 사용자 정의 캐시 클래스를 활용할 수 있습니다.
정리
전역 변수
변수는 항상 'let', 'const'로 선언하여 블록 스코프를 활용하고 최소한의 범위에서만 변수를 사용하세요.
타이머/옵저버
컴포넌트 언마운트 또는 해당 기능이 비활성화되는 시점에 반드시 'clearInterval', 'clearTimeout', 'observer.disconnect()' 등을 호출하세요.
클로저
클로저가 불필요한 외부 변수(특히 큰 객체나 DOM 요소)를 참조하지 않도록 설계하세요. 클로저 함수 자체의 생명주기를 관리하여 필요 없어지면 참조를 끊어주세요.
DOM 참조
DOM 요소를 변수에 저장했다면 해당 요소가 DOM 트리에서 제거될 때 변수에 `null`을 할당하여 참조를 끊어주세요.
이벤트 리스너
'addEventListener'를 사용했다면 'removeEventListener'로 짝을 맞춰 제거하세요. 특히 동적으로 생성/제거되는 요소에 리스너를 붙일 때 주의해야 합니다.
캐시
단순 객체 대신 'Map'이나 'WeakMap'을 고려하거나 직접 캐시 만료/크기 제한 로직을 구현하세요.
레퍼런스
https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Memory_management
다음 글
[Javascript] 메모리 누수 진단 및 해결하기 #2 - 분석 방법과 해결 방법
'Javascript' 카테고리의 다른 글
| [Javascript] 메모리 누수 진단 및 해결하기 #2 - 분석 방법과 해결 방법 (3) | 2025.06.05 |
|---|---|
| [Javascript] DynamicsCompressorNode를 통해 Video의 소리를 풍부하게 만들기 (1) | 2025.01.20 |
| [Javascript] 마우스 휠로 비디오의 음량을 조절하기 - 2편 (0) | 2025.01.20 |
| [Javascript] 마우스 휠로 비디오의 음량을 조절하기 - 1편 (0) | 2025.01.20 |
| [Web/Javascript] 화살표 함수 vs 일반 함수 (0) | 2025.01.20 |