메모리 누수(Memory Leak)가 발생하는 일반적인 원인과 방지 방법을 설명해주세요.
힌트
전역 변수, 이벤트 리스너, 클로저, setInterval을 생각해보세요.
정답 및 해설
메모리 누수(Memory Leak)가 발생하는 일반적인 원인과 방지 방법을 설명해주세요.
메모리 누수(Memory Leak)란 더 이상 필요하지 않은 메모리가 가비지 컬렉터(GC)에 의해 회수되지 못하고 계속 점유되는 현상입니다. JavaScript는 가비지 컬렉션을 자동으로 수행하지만, 코드에서 의도치 않게 참조를 유지하면 GC가 메모리를 회수하지 못합니다. 누수가 누적되면 성능 저하, 앱 크래시로 이어집니다.
가비지 컬렉션 기본 이해
JavaScript 엔진은 도달 가능성(Reachability) 을 기준으로 메모리를 관리합니다. 루트(전역 변수, 콜 스택 등)에서 참조를 따라갈 수 있으면 살아있는 객체, 그렇지 않으면 수거 대상입니다.
let user = { name: 'Alice' }; // 메모리에 객체 생성, user가 참조
user = null; // 참조를 끊음 → GC 수거 대상
원인 1: 실수로 생성된 전역 변수
strict mode를 사용하지 않으면, 선언 없이 변수에 값을 할당할 때 전역 변수가 생성됩니다. 전역 변수는 애플리케이션이 종료될 때까지 메모리에 남습니다.
// 나쁜 예 — 전역 변수 생성
function processData() {
result = []; // var/let/const 없이 할당 → 전역 변수
for (let i = 0; i < 100000; i++) {
result.push({ id: i, data: new Array(1000).fill('x') });
}
}
// 좋은 예 — strict mode로 방지
'use strict';
function processData() {
// result = []; // ReferenceError 발생
const result = []; // 함수 스코프 내 변수
// ...
return result;
}
원인 2: 해제되지 않은 이벤트 리스너
이벤트 리스너는 콜백 함수와 그 클로저를 통해 외부 객체에 대한 참조를 유지합니다. 리스너를 제거하지 않으면 해당 객체도 GC 대상이 되지 않습니다.
// 나쁜 예 — 리스너를 제거하지 않음
class DataManager {
constructor() {
this.data = new Array(100000).fill('데이터');
window.addEventListener('resize', this.handleResize.bind(this));
// DataManager 인스턴스가 사라져도 window가 참조를 유지
}
handleResize() {
console.log('리사이즈 처리');
}
}
// 좋은 예 — cleanup 메서드 구현
class DataManager {
constructor() {
this.data = new Array(100000).fill('데이터');
this.handleResize = this.handleResize.bind(this);
window.addEventListener('resize', this.handleResize);
}
handleResize() {
console.log('리사이즈 처리');
}
destroy() {
window.removeEventListener('resize', this.handleResize);
this.data = null;
}
}
React에서의 useEffect cleanup
function WindowSizeTracker() {
const [size, setSize] = useState({ width: 0, height: 0 });
useEffect(() => {
function handleResize() {
setSize({ width: window.innerWidth, height: window.innerHeight });
}
window.addEventListener('resize', handleResize);
// cleanup 함수 — 컴포넌트 언마운트 또는 재실행 시 호출됨
return () => {
window.removeEventListener('resize', handleResize);
};
}, []);
return <div>{size.width} x {size.height}</div>;
}
원인 3: 클로저로 인한 참조 유지
클로저는 외부 함수의 변수를 참조하므로, 클로저가 살아있는 한 해당 변수도 GC 대상이 되지 않습니다.
// 나쁜 예 — 큰 데이터를 클로저가 참조
function createHandler() {
const largeData = new Array(1000000).fill('대용량 데이터');
// largeData를 실제로 사용하지 않지만 클로저가 참조를 유지
return function handler() {
console.log('handler called');
// largeData를 사용하지 않지만 GC 불가
};
}
const handler = createHandler();
// handler가 살아있는 한 largeData도 메모리에 남음
// 좋은 예 — 필요한 값만 추출
function createHandlerGood() {
const largeData = new Array(1000000).fill('대용량 데이터');
const needed = largeData.length; // 필요한 값만 추출
// largeData는 더 이상 참조되지 않으므로 GC 가능
return function handler() {
console.log('길이:', needed);
};
}
원인 4: 해제되지 않은 타이머
setInterval은 명시적으로 clearInterval을 호출하지 않으면 영원히 실행되며, 콜백 내에서 참조하는 모든 것을 메모리에 유지합니다.
// 나쁜 예
function startPolling() {
const cache = new Map();
setInterval(() => {
// cache를 계속 참조 → GC 불가
cache.set(Date.now(), fetchData());
}, 1000);
// intervalId를 저장하지 않아 clearInterval 불가
}
// 좋은 예
function startPolling() {
const cache = new Map();
const intervalId = setInterval(() => {
cache.set(Date.now(), fetchData());
}, 1000);
// cleanup 함수 반환
return function stopPolling() {
clearInterval(intervalId);
cache.clear();
};
}
const stopPolling = startPolling();
// 나중에 정리할 때
stopPolling();
React에서의 타이머 cleanup
function Timer() {
const [count, setCount] = useState(0);
useEffect(() => {
const intervalId = setInterval(() => {
setCount((prev) => prev + 1);
}, 1000);
return () => clearInterval(intervalId); // cleanup
}, []);
return <div>카운트: {count}</div>;
}
원인 5: DOM 참조를 변수에 보관 후 DOM에서 제거
DOM 요소를 JavaScript 변수에 저장해 두고, 해당 요소를 DOM에서 제거해도 변수가 참조를 유지하면 메모리에서 해제되지 않습니다.
// 나쁜 예
const elements = [];
function addAndTrack() {
const div = document.createElement('div');
div.textContent = '아이템';
document.body.appendChild(div);
elements.push(div); // 참조를 배열에 저장
}
function removeAll() {
document.body.innerHTML = ''; // DOM에서는 제거됨
// 하지만 elements 배열이 여전히 참조를 유지 → GC 불가
}
// 좋은 예
function removeAll() {
document.body.innerHTML = '';
elements.length = 0; // 배열의 참조도 제거
}
방지 방법 요약
WeakMap / WeakRef 활용
WeakMap과 WeakSet은 키 객체에 대한 **약한 참조(Weak Reference)**를 사용합니다. 해당 객체에 다른 참조가 없으면 GC가 수거할 수 있습니다.
// Map — 강한 참조, GC 불가
const cache = new Map();
let obj = { data: '...' };
cache.set(obj, '캐시 값');
obj = null; // obj를 null로 해도 Map이 참조를 유지
// WeakMap — 약한 참조, GC 가능
const weakCache = new WeakMap();
let obj2 = { data: '...' };
weakCache.set(obj2, '캐시 값');
obj2 = null; // obj2에 대한 참조가 없으므로 GC 수거 가능
방지 체크리스트
| 원인 | 해결책 |
|---|---|
| 전역 변수 실수 생성 | 'use strict' 사용, ESLint no-undef 규칙 |
| 이벤트 리스너 미제거 | removeEventListener, useEffect cleanup |
| 클로저 참조 | 필요한 값만 추출, 클로저 라이프사이클 관리 |
| 타이머 미제거 | clearInterval / clearTimeout, useEffect cleanup |
| DOM 참조 보관 | WeakMap 사용, 제거 시 참조도 함께 정리 |
| 대용량 데이터 캐시 | LRU 캐시, WeakRef + FinalizationRegistry |
메모리 누수 디버깅
Chrome DevTools의 Memory 탭을 활용합니다.
- Heap Snapshot: 특정 시점의 메모리 상태 스냅샷
- Allocation Timeline: 시간별 메모리 할당 추적
- Allocation Sampling: 함수별 메모리 사용량 샘플링
// 개발 환경에서 메모리 사용량 확인
if (performance.memory) {
console.log('사용 중인 JS 힙:', performance.memory.usedJSHeapSize / 1024 / 1024, 'MB');
console.log('총 JS 힙:', performance.memory.totalJSHeapSize / 1024 / 1024, 'MB');
}
메모리 누수는 한 번에 크게 발생하기보다 서서히 누적되는 경우가 많습니다. 컴포넌트/클래스의 생명주기를 명확히 설계하고, 생성한 리소스는 반드시 소멸 시 정리하는 습관이 중요합니다.