자바스크립트 진영에서 메모리 누수에 대한 이야기가 덜 논의되는 이유
- 클라이언트 측 JavaScript는 일반적으로 서버 사이드 JS에 비해 수명이 짧기 때문입
- 대부분의 경우 웹 페이지는 오랫동안 열려 있지 않으며 사용자가 페이지를 새로 고치거나 탭을 닫으면 메모리가 자동으로 해제돔
하지만 SPA의 시대가 도래하면서 Javascript 메모리 누수는 꽤 자주 일어나는 모습을 보임
- 앱 상태가 사용자의 세션처럼 stateful하기 때문
- 힙 스냅샷을 만들고
- 노이즈를 제거하고
- 스냅샷을 분석하여 메모리 누수를 일으킨 범인을 구별
이는 쉽게 자동화할 수 없는 시간이 많이 걸리는 작업들임.
- queryObjects라는 API를 활용해 크롭 개발자 도구에서 메모리 누수 파악 가능
다른 브라우저의 개발자 도구에서 해당 API를 지원하는지는 확인 필요
QueryObject란
QueryObjects는 생성자를 인풋으로 받아, 해당 생성자로 생성된 객체의 배열을 반환합니다.
const array = new Uint8Array(1_000_000_000); // allocating 1GB memory
queryObjects(Uint8Array); // logs out an array of all existing uint8 arrays
겉보기에는 지금까지 생성된 특정 클래스의 인스턴스 수를 쿼리하는 API처럼 보임
- 그러나 호출될 때마다 가비지 수집을 강제하는 문서화되지 않은 기능(an undocumented feature)이 존재함
개발자 도구의 Performance 패널에서 확인할 수 있음(Major GC)
- 가비지 수집을 트리거한 후 메모리에서 라이브 개체를 쿼리하는 기능은 메모리 할당을 투명하게 지켜볼 수 있게 해줌
- 힙 스냅샷을 살펴보고 비교하지 않고도 메모리 누수가 발생하는 방식을 관찰하고 이해할 수 있음
간단한 사용 사례
일반적인 메모리 누수 패턴 중 일부, 모든 메모리 누수가 발생할 수 있는 상황을 다루는 것은 아님
전역 객체
function fn() {
foo = new Uint8Array(1_000_000_000);
}
fn();
queryObjects(Uint8Array); // logs out an array of uint8Arrays
클로저
- 클로저는 JavaScript를 흥미로운 언어로 만드는 것이지만 공짜는 아님
- 엔진은 변수가 정의된 스코프의 실행을 마친 후에도 자유 변수를 메모리에 유지해야 합니다.
- 물론 브라우저 자체에 버그가 있는 경우가 아니면 일반적으로 이를 메모리 누수와 동일시하지 않습니다.
다음 예제에서 반환된 함수 bar는 객체 테스트가 존재하는 어휘 환경에 대해 닫혀 있으므로(bar closes over object test) 할당된 메모리를 유지합니다.
주1 : 닫혀있다는 개념은 환경변수 집합 간의 관계인듯
주2 : ❌는 메모리 누수가 발생함을 의미
class Test {}
function foo() {
const test = new Test();
test.a = new Uint8Array(1_000_000_000);
test.b = 'foo';
return () => {
console.log(test.b);
};
}
const bar = foo();
queryObjects(Uint8Array); // ❌ [uint8Array]
queryObjects(Test); // ❌ [{Test}]
- 반환된 함수 bar는 test의 속성을 읽습니다.
- 이것이 거대한 Uint8Array(a)와 함께 해당 메모리가 유지되는 이유입니다.
해당 메모리 누수를 고치는 방법
function fn() {
let a;
return () => {
a = new Uint8Array(1_000_000_000);
};
}
const foo = fn();
foo();
위의 예제는 메모리 누수를 식별하기 쉬웠음
아래 예제는 조금 혼란스러움
function fn() {
let a;
return () => {
a = new Uint8Array(1_000_000_000);
};
}
const foo = fn();
foo();
function fn() {
let a;
return () => {
a = new Uint8Array(1_000_000_000);
};
}
const foo = fn();
foo();
queryObjects(Uint8Array); // ❌ [uint8Array]
linter 규칙을 통해 예방
setInterval
- setInterval은 메모리 누수의 또 다른 일반적인 용의자임
- 반복되는 특성으로 인해 메모리 누수는 매우 빠르게 확대(악화)됨.
- useEffect나 watchEffect에서 자주 보임
- setInterval만으로는 메모리 누수가 발생하지 않음
- 해당 함수가 전달된 콜백이 가비지 수집되는 것을 막지는 않음
아래 예에서 매초 foo를 호출하더라도 배열의 메모리는 foo가 실행을 마치면 해제됨.
메모리 누수가 발생하지 않았음.
const myQueryObject = queryObjects; // had to call the original queryObjects from a different reference
function foo() {
const array = new Uint8Array(1_000_000_000);
myQueryObject(Uint8Array); // ✅ empty array
}
setInterval(foo, 1000);
const myQueryObject = queryObjects;
function foo() {
const array = new Uint8Array(1_000);
setInterval(() => {
array; // referencing `array`
}, 1000);
myQueryObject(Uint8Array); // 🔥 ever-growing array of uint8Arrays
}
setInterval(foo, 1000);
해당 메모리 누수를 고치는 방법
clearInterval을 통해 중첩된 setInterval을 지우면 됩니다.
const myQueryObject = queryObjects;
function foo() {
const array = new Uint8Array(1_000);
const intervalId = setInterval(() => {
array; // referencing `array`
}, 1000);
myQueryObject(Uint8Array); // ✅ empty array
clearInterval(intervalId);
}
setInterval(foo, 1000);
Promises
- Promises는 resolve 되거나 reject 되지 않을 경우 연결된 모든 .then() 콜백이 메모리 누수의 원인이 됨
아래 코드는 메모리 누수가 발생할까?
const p = new Promise((resolve, reject) => {});
const giantArrayPromise = p.then(() => new Uint8Array(1_000_000_000));
queryObjects(Uint8Array); // ✅ empty array
답은 아니다임. 애초에 콜백이 실행되지 않아 메모리 할당이 안됨
이전 예제를 약간 수정해봄
이번엔 전역변수 giantArray가 클로저 변수 a에 의해 레퍼런스 되어 콜백 안에서 사용되기에 메모리 누수가 발생
let giantArray = new Uint8Array(1_000_000_000);
function foo() {
const a = giantArray;
return () => a;
}
const p = new Promise((resolve, reject) => {});
p.then(foo());
giantArray = null;
queryObjects(Uint8Array); // ❌ [uint8Array]
마지막으로 resolve 함수가 외부에서 참조될 때도 메모리 누수가 발생하는걸 볼 수 있음
let i = 0;
while (i++ < 100) {
new Promise((resolve) => {
document.body.addEventListener('click', resolve, { once: true });
});
}
queryObjects(Promise); // ❌ Array(100)
removeEventListener로 고칠 수 있음
let i = 0;
while (i++ < 100) {
const handler = () => resolve();
new Promise((resolve) => {
document.body.addEventListener('click', handler, { once: true });
removeEventListener('click', handler);
});
}
queryObjects(Promise); // ✅ empty array
마무리
- queryObjects는 프로덕션에서 메모리 누수를 디버깅하는 데 별로 도움이 되지 않을 수 있음
- 하지만 개발 도중 빠른 피드백 루프를 통해 메모리 누수를 감지하는 훌륭한 도구 역할을 함
- 메모리 누수에 대한 최소한의 재현 가능한 예를 만들고 가정을 신속하게 검증할 때 유용함
- 사파리는 queryHolders라는 메모리 누수 객체를 직접 찍어주는 API가 있음
참고 :
번역 및 정리한 글
https://www.zhenghao.io/posts/queryObjects
JS에서 발생할 수 있는 메모리 누수 리스트와 그들을 고치는 방법
https://nolanlawson.com/2020/02/19/fixing-memory-leaks-in-web-applications/
https://nolanlawson.com/2022/01/05/memory-leaks-the-forgotten-side-of-web-performance/
클로저는 사실 수학 용어(닫혀있음)에서 왔음(SICP에 쓰여져 있음)
https://stackoverflow.com/questions/27092038/what-exactly-does-closing-over-mean
http://point.davidglasser.net/2013/06/27/surprising-javascript-memory-leak.html
https://ui.toast.com/posts/ko_20210611
'FrontEnd' 카테고리의 다른 글
타입스크립트 프로젝트 도입을 통해 얻을수 있는 효과와 아닌것 (0) | 2022.11.20 |
---|---|
B2C IT기업 개발자와 B2B IT기업 개발자의 차이점, B2B에 어울리는 인재상 (0) | 2022.11.19 |
프론트엔드 성능 최적화 가이드 스터디 4장 (0) | 2022.11.19 |
vue3의 반응성(reactivity) 원리 간단 정리 (0) | 2022.11.17 |
프론트엔드 성능 최적화 가이드 3장 스터디 (0) | 2022.11.17 |