React로 대시보드 만들었다. 처음엔 잘 돌아갔다.
근데 10분 정도 쓰다 보면 점점 느려진다. 탭 전환할 때마다 버벅인다.
새로고침하면? 다시 빨라진다. 또 10분 쓰면 느려진다.
Chrome DevTools Performance 탭을 열었다. 메모리 사용량이 계속 올라간다. 내려오질 않는다.
메모리 누수였다.
JavaScript는 자동으로 메모리를 관리한다. 필요 없어진 객체는 알아서 제거해준다.
근데 왜 메모리 누수가 생기는가?
가비지 컬렉터가 “이거 아직 필요한가?”라고 착각하기 때문이다.
let bigData = new Array(1000000).fill('data');
function processData() {
// bigData 사용
console.log(bigData.length);
}
// processData는 끝났는데 bigData는 계속 메모리에 남아있다
// 왜? 전역 변수니까 가비지 컬렉터가 못 지운다
전역 변수로 선언하면 페이지 닫을 때까지 메모리에 있다. 계속 쌓인다.
제일 흔한 케이스다.
function setupButton() {
const button = document.getElementById('myButton');
button.addEventListener('click', function() {
// 엄청 큰 데이터 처리
const bigData = new Array(1000000).fill('data');
console.log(bigData.length);
});
}
// 컴포넌트 제거해도 이벤트 리스너는 남아있다
// button이 DOM에서 제거되어도 리스너는 메모리에 있다
React에서 흔한 실수
function MyComponent() {
useEffect(() => {
const handleScroll = () => {
console.log('scrolling...');
};
window.addEventListener('scroll', handleScroll);
// 이거 빼먹으면 메모리 누수
// return () => {
// window.removeEventListener('scroll', handleScroll);
// };
}, []);
}
컴포넌트 언마운트돼도 scroll 이벤트 리스너는 살아있다. 컴포넌트 10번 마운트하면 리스너 10개 생긴다.
해결법
useEffect(() => {
const handleScroll = () => {
console.log('scrolling...');
};
window.addEventListener('scroll', handleScroll);
return () => {
window.removeEventListener('scroll', handleScroll);
};
}, []);
cleanup 함수로 이벤트 리스너 제거한다.
타이머도 마찬가지다.
function startTimer() {
setInterval(() => {
console.log('tick');
}, 1000);
}
// 이거 한 번 호출하면 영원히 돌아간다
// 페이지 닫을 때까지
React에서
function Timer() {
const [count, setCount] = useState(0);
useEffect(() => {
const timer = setInterval(() => {
setCount(c => c + 1);
}, 1000);
// 이거 없으면 메모리 누수
return () => clearInterval(timer);
}, []);
return <div>{count}</div>;
}
컴포넌트 언마운트돼도 setInterval은 계속 돌아간다. clearInterval 필수다.
클로저는 외부 변수를 기억한다. 큰 객체를 참조하면 메모리에 계속 남는다.
function createHandler() {
const bigData = new Array(1000000).fill('data');
return function() {
// bigData의 길이만 쓰는데
console.log(bigData.length);
// bigData 전체가 메모리에 남아있다
};
}
const handler = createHandler();
// handler가 살아있는 한 bigData도 메모리에 있다
해결법
function createHandler() {
const bigData = new Array(1000000).fill('data');
const length = bigData.length; // 필요한 것만 저장
return function() {
console.log(length); // 이제 bigData는 가비지 컬렉션 가능
};
}
필요한 값만 따로 저장하면 원본 객체는 메모리에서 제거된다.
const elements = [];
function addElement() {
const div = document.createElement('div');
document.body.appendChild(div);
elements.push(div); // 여기서 참조 저장
}
function removeElement() {
const div = elements.pop();
document.body.removeChild(div);
// div는 DOM에서 제거됐지만
// elements 배열이 참조를 들고 있어서 메모리에 남는다
}
DOM에서 제거해도 JavaScript가 참조를 들고 있으면 메모리에 남는다.
해결법
function removeElement() {
const div = elements.pop();
document.body.removeChild(div);
// 참조도 제거
div = null;
}
JavaScript는 Mark-and-Sweep 알고리즘을 쓴다.
루트(전역 객체, 현재 실행 중인 함수)에서 시작해서 닿을 수 있는 객체를 전부 표시한다.
let obj1 = { name: 'A' };
let obj2 = { name: 'B' };
obj1.ref = obj2; // obj1 → obj2
obj1 = null; // obj1 참조 제거
// obj1은 더 이상 루트에서 닿을 수 없다 → 표시 안 됨
// obj2는? obj1에서만 참조했으니 obj2도 표시 안 됨
표시 안 된 객체를 전부 메모리에서 제거한다.
간단하다. 닿을 수 없으면 지운다.
실제로 메모리 누수를 찾으려면 DevTools를 써야 한다.
Chrome DevTools → Memory 탭 → Heap snapshot
“Detached” 검색하면 DOM에서 제거됐지만 메모리에 남아있는 요소들이 나온다.
// 나쁜 예
let detachedDiv;
function createDiv() {
const div = document.createElement('div');
document.body.appendChild(div);
detachedDiv = div; // 참조 저장
}
function removeDiv() {
document.body.removeChild(detachedDiv);
// detachedDiv가 아직 참조 들고 있음 → Detached DOM
}
// 나쁜 예
var cache = {}; // 전역
function getData(id) {
if (!cache[id]) {
cache[id] = fetchData(id);
}
return cache[id];
}
// 좋은 예
function createCache() {
const cache = {}; // 클로저로 캡슐화
return {
get(id) {
if (!cache[id]) {
cache[id] = fetchData(id);
}
return cache[id];
},
clear() {
cache = {}; // 메모리 해제 가능
}
};
}
일반 Map은 키를 강하게 참조한다. 키가 제거돼도 Map이 들고 있으면 메모리에 남는다.
WeakMap은 약하게 참조한다. 키가 제거되면 Map에서도 자동으로 제거된다.
// 나쁜 예
const userMetadata = new Map();
function addUser(user) {
userMetadata.set(user, { loginCount: 0 });
}
// user 객체가 제거돼도 Map에 남아있다
// 좋은 예
const userMetadata = new WeakMap();
function addUser(user) {
userMetadata.set(user, { loginCount: 0 });
}
// user 객체가 제거되면 WeakMap에서도 자동 제거
function processLargeData() {
let bigArray = new Array(1000000).fill('data');
// 처리
doSomething(bigArray);
// 다 쓰면 명시적으로 제거
bigArray = null;
}
가비지 컬렉터를 기다리지 말고 직접 null 할당하면 빨리 메모리 해제된다.
function useDebounce(callback, delay) {
const timeoutRef = useRef();
useEffect(() => {
return () => {
// cleanup에서 타이머 제거
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
};
}, []);
return (...args) => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
timeoutRef.current = setTimeout(() => {
callback(...args);
}, delay);
};
}
처음에 만든 코드
function InfiniteScroll() {
const [items, setItems] = useState([]);
useEffect(() => {
const handleScroll = () => {
if (window.scrollY + window.innerHeight >= document.body.scrollHeight) {
// 데이터 추가
setItems(prev => [...prev, ...fetchMoreItems()]);
}
};
window.addEventListener('scroll', handleScroll);
// cleanup 없음!
}, []);
return <div>{items.map(item => <Item key={item.id} data={item} />)}</div>;
}
문제는
수정한 코드
function InfiniteScroll() {
const [items, setItems] = useState([]);
const observerRef = useRef();
useEffect(() => {
const observer = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting) {
setItems(prev => [...prev, ...fetchMoreItems()]);
}
});
if (observerRef.current) {
observer.observe(observerRef.current);
}
// cleanup
return () => {
if (observerRef.current) {
observer.unobserve(observerRef.current);
}
};
}, []);
return (
<div>
{items.map(item => <Item key={item.id} data={item} />)}
<div ref={observerRef} />
</div>
);
}
개선점
JavaScript는 자동으로 메모리 관리를 해준다. 근데 완벽하지 않다.
개발자가 실수하면 메모리 누수가 생긴다. 특히 SPA에서 치명적이다.
핵심은 3가지다
메모리 누수는 눈에 안 보인다. 근데 사용자는 느낀다. “왜 이거 점점 느려져?”
코드 작성할 때 cleanup 함수 습관화하면 대부분 예방된다.