[JS] 자바스크립트와 메모리
메모리 영역
프로그램 시작 후 종료될 때까지 메모리에 남아 있는 영역들
1. 코드영역
코드의 정보를 저장하는 영역 (텍스트 영역)
2. 데이터 영역
전역 변수와 정적 변수에 값을 할당하는영역
함수 호출 시 지역변수 매개변수를 저장, 함수가 종료되면 사라지는 영역들
3. 스택영역
- 선입 후출
- 높은 주소에서 낮은 방향으로 메모리 할당
- 컴파일(기계어로 바꾸는 과정)타임에 크기결정
- 프로그램이 자동으로 사용하는 임시 메모리 영역
- 원시 타입: 값을 저장
- 참조 타입: 값이 저장된 주소를 저장
4. 힙영역
- 선입 선출
- 낮은 주소에서 높은 주소의 방향으로 메모리 할당
- 런타임(프로그램 실행)에 크기 결정
- 가변적인 참조형 데이터 값을 저장할 때 사용 객체의 경우, 정해진 사이즈가 없기에 동적으로 변경 가능합니다. 즉, 자바스크립트 엔진은 해당 변수를 위해 얼마만큼의 메모리를 할당해야 할 지 모르기 때문에 힙영역에 객체 인스턴스를 생성하고, 해당 위치를 스택영역에 저장합니다.
오버 플로우
한정된 메모리 공간이 부족해서 메모리 안에 있는 데이터가 넘쳐 흐르는 현상
스택 오버플로우
스택 영역에서 pop이 일어나지 않고 계속 push만 하면 힙 영역까지 넘치게 됩니다.
- 주소를 큰값에서 작은 값으로 채우기에 작은값에서 큰값으로 채우는 힙영역을 침범할 수 있습니다.
- 콜스택을 무한하게 쌓는 경우 예를 들면 재귀함수에서 return 시점을 제대로 만들지 못하면 스택오버플로우를 야기합니다.
힙 오버플로우
- 반대로 작은 주소값에서 큰 값에 할당을 하는 힙은 큰값에서 작은 값으로 주소를 할당하는 스택과 부딪힐 수 있습니다.
- 힙 영역 역시 동적으로 메모리를 할당할 수 있다고 해도 전체 메모리 공간은 한정이 돼 있기 때문에 shift가 일어나지 않고 push만 한다면 오버플로우가 발생합니다.
원인
- 가비지 컬렉팅이 제대로 되지 않았기 때문
- 힙, 스택 두영역이 메모리 주소를 양분해서 쓰고 있기 때문
해결 방법
가비지 컬렉팅이 제대로 될 수 있도록 참조하지만 사용하지 않는 데이터를 삭제합니다.
1. 불필요한 클로저 제거
클로저는 스코프 체이닝을 통해 랙시컬 환경을 참조합니다. 따라서 함수 종료 후에도 클로저가 해당 함수를 참조한다면 값이 계속 남아있습니다. 만약 렉시컬 환경 정보에 불필요하면서 용량이 큰 값이 있다면 문제가 되기에 제거할 필요가 있습니다.
2. 전역변수, 정적변수 최소화
전역변수, 정적변수는 프로그램 종료할 때까지 값이 저장됩니다. 따라서 전역에서 사용되는 경우가 아니라면 함수 종료시 가비지 컬렉팅 대상이 되는 지역변수로 사용하도록 해야 합니다.
3. 불필요한 이벤트 리스너 제거
특정 상황에서만 실행되는 데도 연관된 DOM요소가 제거되기 전까진 계속 메모리에 저장됩니다. 따라서 SPA같은 경우 해당 DOM이 계속 남아있다면 addEventListener
에서 참조하는 모든 변수가 가비지 컬렉팅 대상에서 제외되기에 메모리 누수를 야기합니다.
이를 해결하기 위해 페이지가 언마운트 될 때 removeEventListener
를 사용해 이벤트 리스너를 삭제해 줘야 합니다.
useEffect(() => {
dom.addEventListener("scroll", 콜백함수명);
return () => dom.removeEventListener("scroll", 콜백함수명); // 언마운트시
}, []);
- 이렇게 사용하려면 콜백함수를 따로 변수 할당 후 인자로 넣어줘야 합니다. 그래야 이벤트 핸들러가 뭔지 알 수 있기 때문입니다.
만약 이벤트 횟수가 1번으로 정해져 있다면?
addEventLister 3번 째 인자 { once: true }
addEventListener
의 세번 째 인자에 {once: true} 변수를 넣어 한번 처리 후 제거 될 수 있도록 합니다.
document.addEventListener("keyup", 콜백함수, { once: true });
또는, removeEventListener
를 addEventListener
의 콜백함수에서 처리해줍니다. 콜백 함수에서 이벤트 처리 후, removeEventListener
의 2번째 인자로 현재 실행 중인 함수를 참조할 수 있는 arguments.callee 속성을 넣으면 이벤트 핸들러 실행 후 바로 이벤트 리스너를 제거할 수 있습니다.
dom.addEventListener("scroll", function () {
콜백함수호출;
dom.removeEventListener("scroll", arguments.callee); // arguments.callee === function
});
- 주의: 이 땐 2번째 인자로 화살표함수가 아니라 함수선언문을 넣어줘야 합니다. 그래야 해당 스코프의 arguments 객체가 만들어지기 때문입니다.
4. 불필요한 타이머 제거
setTimeout의 콜백은 지연 시간동안 유지됩니다. 따라서 해당 타이머가 필요없을 땐 clearTimeout을 통해 setTimeout의 반환된 핸들을 사용해 지워줘야 합니다.
const debounce = (fn, delay) => {
let timer = null;
return function () {
const context = this;
const args = arguments;
if (timer) {
clearTimeout(timer); // 새로운 이벤트가 들어오면 이전 타이머가 필요없기에 삭제한다.
}
timer = setTimeout(fn.apply(context, args), delay);
};
};
setInterval의 경우 일정주기로 해당 콜백을 호출하기에 종료시점을 정하지 않으면 계속 참조를 해서 메모리 누수가 발생합니다. 따라서 종료시점을 정해 clearInterval로 가비지 컬렉팅할 수 있도록 합니다.
let cur = 0;
const max = 10;
//1초마다 console.log(cur)
let timer = setInterval(() => {
cur++;
console.log(cur);
}, 1000);
//max 도달시 clearInterval를 해준다.
window.addEventListener("click", () => {
if (cur === max) {
console.log("stop");
clearInterval(timer);
}
});
5. 캐싱 제거
만약 캐싱에 제약이 없다면 캐시 데이터가 쌓여서 메모리 누수가 발생합니다. 이를 해결하기 위해 다음과 같이 객체를 key로 갖고 자신 이외에 key값을 참조하지 않는다면 GC해버리는 WeakMap을 사용해 볼 수 있습니다.
let man = { name: "chris", id: 12345 };
let woman = { name: "diana", id: 54321 };
const weakMapCache = new WeakMap();
function cache(obj) {
if (!weakMapCache.has(obj)) {
const value = `my name is ${obj.name}`;
weakMapCache.set(obj, value);
return value;
}
return [weakMapCache.get(obj), "cached"];
}
cache(man); // 'my name is chris'
cache(woman); // 'my name is diana'
console.log(weakMapCache); // ((…) => 'my name is chris', (…) => 'my name is diana'}
man = null; // { name: "chris", id: 12345 } 객체를 참조하는 대상이 없다!
//그러면 이제 { name: "chris", id: 12345 }에 대한 value, 'my name is chris'를 캐시삭제
console.log(weakMapCache); // ((…) => 'my name is diana'}
6. 분리된 돔 요소 제거
돔 요소를 참조하는 변수가 있다면 해당 돔요소를 삭제하더라도 메모리에 남습니다. 예로, 바닐라 자바스크립트로 웹을 만들 때 돔 조작을 용이하게 하기 위해 다음과 같이 변수에 담아 사용할 때가 그렇습니다.
let bigElement = document.getElementById("bigElement");
document.body.appendChild(bigElement);
//만약 body에서 요소를 아예 삭제하고 싶다면
function deleteBigElement() {
document.body.removeChild(document.getElementById("bigElement"));
}
deleteBigElement(); // 하지만 이렇게 해도 변수 bigElement가 여전히 해당 요소를 참조하기에 GC 대상에서 벗어난다.
bigElement = null; //이제 GC된다.
만약 해당 요소가 더이상 필요 없어진다면, appendChild를 한 부모 요소에 다시 removeChild하고 bigElement변수도 null로 만들어줘야 합니다.
- 문제: 자식 요소 때문에 부모요소를 삭제할 수 없어!
만약 bigElement의 자식 smallElement가 있고 어떤 변수가 얘를 참조할 때, bigElement를 제거하면 어떻게 될까요?
let smallElement = document.getElementById("smallElement");
document.body.appendChild(smallElement);
deleteBigElement();
bigElement = null;
부모는 사라지고 자식은 참조를 당하니 자식만 GC에서 제외될까요? 답은 모두 GC제외된다 입니다. 자식요소가 부모 요소를 참조하기 때문입니다. 즉, 위와 같은 방식으로 삭제해도 저 조그만 자식때문에 bigElement는 GC 당하지 않습는다.
let smallElement = document.getElementById("smallElement");
smallElement = null;
deleteSmallElement();
따라서, 돔 요소를 참조할 땐 GC 여부를 잘 생각해야 합니다.
참고
💻 JavaScript에서 메모리 누수의 원인 및 해결 방법
💻 [JavaScript] 콜 스택과 메모리 힙 관련 이슈
💻 [JS] 📚 이벤트 제거 & 한번만 실행되게 하기 (removeEventListener / once)
💻 [arguments 객체 - JavaScript | MDN](https://developer.mozilla.org/ko/docs/Web/JavaScript/Reference/Functions/arguments) |
💻 setTimeout과 setInterval을 이용한 호출 스케줄링
💻 Fix memory problems - Chrome Developers
💻 자바스크립트의 가비지컬렉션(Garbage Collection)
댓글남기기