본문 바로가기

FrontEnd

크롬 개발자 도구의 QueryObjects 객체로 Javascript 메모리 누수 잡기

반응형

자바스크립트 진영에서 메모리 누수에 대한 이야기가 덜 논의되는 이유

  • 클라이언트 측 JavaScript는 일반적으로 서버 사이드 JS에 비해 수명이 짧기 때문입
  • 대부분의 경우 웹 페이지는 오랫동안 열려 있지 않으며 사용자가 페이지를 새로 고치거나 탭을 닫으면 메모리가 자동으로 해제돔

하지만 SPA의 시대가 도래하면서 Javascript 메모리 누수는 꽤 자주 일어나는 모습을 보임

  • 앱 상태가 사용자의 세션처럼 stateful하기 때문
JavaScript 메모리 누수를 식별하고 디버깅하는 작업
  • 힙 스냅샷을 만들고
  • 노이즈를 제거하고
  • 스냅샷을 분석하여 메모리 누수를 일으킨 범인을 구별

이는 쉽게 자동화할 수 없는 시간이 많이 걸리는 작업들임.

  • 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)

queryObject API 호출 결과 GC가 수행된 모습
  • 가비지 수집을 트리거한 후 메모리에서 라이브 개체를 쿼리하는 기능은 메모리 할당을 투명하게 지켜볼 수 있게 해줌
    • 힙 스냅샷을 살펴보고 비교하지 않고도 메모리 누수가 발생하는 방식을 관찰하고 이해할 수 있음

간단한 사용 사례

일반적인 메모리 누수 패턴 중 일부, 모든 메모리 누수가 발생할 수 있는 상황을 다루는 것은 아님

전역 객체

메모리 누수를 일으키는 가장 쉬운 방법은 window와 같은 전역 개체에 속성을 추가하는 것
function fn() {
  foo = new Uint8Array(1_000_000_000);
}

fn();
queryObjects(Uint8Array); // logs out an array of uint8Arrays

클로저

JavaScript에서는 함수가 자유 변수(예: 함수 외부에서 선언된 변수)에 자유롭게 액세스할 수 있는 클로저를 활용 가능
  • 클로저는 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)와 함께 해당 메모리가 유지되는 이유입니다.

해당 메모리 누수를 고치는 방법

test.b에 대한 별도의 참조를 추가한 뒤 반환하는 함수에서 활용합니다.
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();
이 예에서 foo는 클로저에서 변수 a를 읽지 않고 쓰기만 함.
게다가 외부에서 동봉된 변수 a에 도달할 방법이 없음. 안전하게 가비지 수집할 수 있는 것 같습니다.
 
그러나 queryObjects로 테스트 시 a에 할당된 메모리가 여전히 유지되고 있음을 알 수 있음
function fn() {
  let a;

  return () => {
    a = new Uint8Array(1_000_000_000);
  };
}
const foo = fn();

foo();
queryObjects(Uint8Array); // ❌ [uint8Array]
고도로 최적화된 JavaScript 엔진은 이론적으로는 쓰기 전용 케이스에 대해 유지된 메모리를 해제할 수 있음.
하지만 이는 쉽지 않음. 정지 문제(the halting problem)와 비슷한 결과에 충돌하기 때문.

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);
그러나 중첩된 setInterval은 메모리 누수를 일으킴.
아래 예제에서 queryObjects가 매초마다 새로운 Uint8Array가 추가되는 지속적으로 증가하는 배열을 로깅하는 것을 관찰 가능.
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)
document.body인 이벤트 타겟은 resolve 함수의 참조를 보유하고 resolve된 후에도 GC되지 않도록 함.
아마 resolve가 그것이 온 Promise에 대한 내부 참조를 가지고 있기 때문일 것임

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

 

Zhenghao's site

The official site of Zhenghao He, a software engineer and a TypeScript/JavaScript enthusiast.

www.zhenghao.io

JS에서 발생할 수 있는 메모리 누수 리스트와 그들을 고치는 방법

https://nolanlawson.com/2020/02/19/fixing-memory-leaks-in-web-applications/

 

Fixing memory leaks in web applications

Update: I’ve written a tool to automate many of the steps described in this post. Part of the bargain we struck when we switched from building server-rendered websites to client-rendered SPAs…

nolanlawson.com

https://nolanlawson.com/2022/01/05/memory-leaks-the-forgotten-side-of-web-performance/

 

Memory leaks: the forgotten side of web performance

I’ve researched and learned enough about client-side memory leaks to know that most web developers aren’t worrying about them too much. If a web app leaks 5 MB on every interaction, but…

nolanlawson.com

클로저는 사실 수학 용어(닫혀있음)에서 왔음(SICP에 쓰여져 있음)

https://stackoverflow.com/questions/27092038/what-exactly-does-closing-over-mean

 

What exactly does "closing over" mean?

In conjunction with closures I often read that something closes over something else as a means to explain closures. Now I don't have much difficulty understanding closures, but "closing over" appe...

stackoverflow.com

http://point.davidglasser.net/2013/06/27/surprising-javascript-memory-leak.html

 

A surprising JavaScript memory leak found at Meteor

This week, my teammates at Meteor tracked down a surprising JavaScript memory leak. I searched the web for variations on javascript closure memory leak and came up with nothing relevant, so maybe this is a relatively unknown issue. (Most of what you find f

point.davidglasser.net

https://ui.toast.com/posts/ko_20210611

 

당신이 모르는 자바스크립트의 메모리 누수의 비밀

크롬 개발자도구로 하는 디버깅과 해결책을 찾아서!

ui.toast.com

 

반응형