분리된 창 메모리 누수

분리된 창으로 인한 까다로운 메모리 누수를 찾아 수정합니다.

Bartek Nowierski
Bartek Nowierski

JavaScript의 메모리 누수란 무엇인가요?

메모리 누수는 시간이 지남에 따라 애플리케이션에서 사용한 메모리 양이 의도치 않게 증가하는 것입니다. JavaScript에서 메모리 누수는 객체가 더 이상 필요하지 않지만 함수나 다른 객체에 의해 계속 참조될 때 발생합니다. 이러한 참조는 가비지 수집기에서 불필요한 객체를 재사용하지 못하도록 합니다.

가비지 컬렉터의 작업은 애플리케이션에서 더 이상 연결할 수 없는 객체를 식별하고 재사용하는 것입니다. 이는 객체가 자기 자신을 참조하거나 서로 순환적으로 참조하는 경우에도 작동합니다. 애플리케이션이 객체 그룹에 액세스할 수 있는 참조가 더 이상 남아 있지 않으면 가비지 컬렉션될 수 있습니다.

let A = {};
console.log(A); // local variable reference

let B = {A}; // B.A is a second reference to A

A = null; // unset local variable reference

console.log(B.A); // A can still be referenced by B

B.A = null; // unset B's reference to A

// No references to A are left. It can be garbage collected.

특히 까다로운 메모리 누수 클래스는 애플리케이션이 DOM 요소나 팝업 창과 같이 자체 수명 주기가 있는 객체를 참조할 때 발생합니다. 이러한 유형의 객체는 애플리케이션이 알지 못하는 사이에 사용되지 않을 수 있습니다. 즉, 애플리케이션 코드에 가비지 컬렉션될 수 있는 객체에 대한 유일한 참조가 남아 있을 수 있습니다.

분리된 창이란 무엇인가요?

다음 예에서는 슬라이드쇼 보기 애플리케이션에 발표자 메모 팝업을 열고 닫는 버튼이 포함되어 있습니다. 사용자가 메모 표시를 클릭한 다음 메모 숨기기 버튼을 클릭하는 대신 팝업 창을 직접 닫는다고 가정해 보겠습니다. 팝업이 더 이상 사용되지 않더라도 notesWindow 변수는 여전히 액세스할 수 있는 팝업 참조를 보유합니다.

<button id="show">Show Notes</button>
<button id="hide">Hide Notes</button>
<script type="module">
  let notesWindow;
  document.getElementById('show').onclick = () => {
    notesWindow = window.open('/presenter-notes.html');
  };
  document.getElementById('hide').onclick = () => {
    if (notesWindow) notesWindow.close();
  };
</script>

이는 분리된 창의 예입니다. 팝업 창이 닫혔지만 코드에 팝업 창에 대한 참조가 있어 브라우저가 팝업 창을 소멸하고 메모리를 재사용할 수 없습니다.

페이지에서 window.open()를 호출하여 새 브라우저 창 또는 탭을 만들면 창 또는 탭을 나타내는 Window 객체가 반환됩니다. 이러한 창이 닫히거나 사용자가 창을 탐색한 후에도 window.open()에서 반환된 Window 객체를 사용하여 창에 관한 정보에 계속 액세스할 수 있습니다. 이는 분리된 창의 한 유형입니다. JavaScript 코드는 닫힌 Window 객체의 속성에 여전히 액세스할 수 있으므로 메모리에 유지되어야 합니다. 창에 JavaScript 객체나 iframe이 많이 포함된 경우 창 속성에 대한 JavaScript 참조가 남아 있지 않을 때까지 해당 메모리를 재사용할 수 없습니다.

Chrome DevTools를 사용하여 창이 닫힌 후에도 문서를 유지하는 방법을 보여줍니다.

<iframe> 요소를 사용할 때도 동일한 문제가 발생할 수 있습니다. iframe은 문서가 포함된 중첩된 창처럼 동작하며, contentWindow 속성은 window.open()에서 반환된 값과 마찬가지로 포함된 Window 객체에 대한 액세스를 제공합니다. JavaScript 코드는 iframe이 DOM에서 삭제되거나 URL이 변경되더라도 iframe의 contentWindow 또는 contentDocument에 대한 참조를 유지할 수 있습니다. 따라서 속성에 계속 액세스할 수 있으므로 문서가 가비지 수집되지 않습니다.

iframe을 다른 URL로 이동한 후에도 이벤트 핸들러가 iframe의 문서를 유지하는 방법을 보여줍니다.

window 또는 iframe 내의 document 참조가 JavaScript에서 유지되는 경우, 포함된 window 또는 iframe이 새 URL로 이동하더라도 해당 문서는 메모리에 유지됩니다. 이는 특히 이 참조를 보유한 JavaScript가 창/프레임이 새 URL로 이동했음을 감지하지 못할 때 문제가 될 수 있습니다. 문서를 메모리에 보관하는 마지막 참조가 언제 되는지 알 수 없기 때문입니다.

분리된 창으로 인해 메모리 누수가 발생하는 방식

기본 페이지와 동일한 도메인에서 창과 iframe을 사용할 때는 일반적으로 문서 경계를 넘어 이벤트를 리슨하거나 속성에 액세스합니다. 예를 들어 이 가이드 시작 부분의 프레젠테이션 뷰어 예시의 변형을 다시 살펴보겠습니다. 시청자가 발표자 노트를 표시할 두 번째 창을 엽니다. 발표자 메모 창은 다음 슬라이드로 이동할 신호로 click 이벤트를 수신 대기합니다. 사용자가 이 메모 창을 닫더라도 원래 상위 창에서 실행 중인 JavaScript는 여전히 발표자 메모 문서에 대한 전체 액세스 권한을 갖습니다.

<button id="notes">Show Presenter Notes</button>
<script type="module">
  let notesWindow;
  function showNotes() {
    notesWindow = window.open('/presenter-notes.html');
    notesWindow.document.addEventListener('click', nextSlide);
  }
  document.getElementById('notes').onclick = showNotes;

  let slide = 1;
  function nextSlide() {
    slide += 1;
    notesWindow.document.title = `Slide  ${slide}`;
  }
  document.body.onclick = nextSlide;
</script>

위의 showNotes()로 만든 브라우저 창을 닫는다고 가정해 보겠습니다. 창이 닫혔음을 감지하기 위해 리슨하는 이벤트 핸들러가 없으므로 문서 참조를 정리해야 한다고 코드에 알리는 항목이 없습니다. nextSlide() 함수는 기본 페이지에서 클릭 핸들러로 바인딩되어 있으므로 여전히 '실행 중'입니다. nextSlidenotesWindow 참조가 포함되어 있으므로 창이 여전히 참조되고 가비지 컬렉션될 수 없습니다.

창에 대한 참조가 창이 닫힌 후에도 가비지 컬렉션을 방지하는 방법을 보여주는 그림

분리된 창이 가비지 컬렉션 대상이 되지 못하도록 실수로 참조가 유지되는 다른 시나리오도 여러 가지 있습니다.

  • 프레임이 의도한 URL로 이동하기 전에 이벤트 핸들러가 iframe의 초기 문서에 등록될 수 있으므로 다른 참조가 정리된 후에도 문서와 iframe이 실수로 참조되어 유지됩니다.

  • 창이나 iframe에 메모리가 많이 필요한 문서가 로드되면 새 URL로 이동한 후에도 실수로 메모리에 오래 유지될 수 있습니다. 이는 상위 페이지가 리스너 삭제를 허용하기 위해 문서 참조를 유지하기 때문에 발생하는 경우가 많습니다.

  • JavaScript 객체를 다른 창 또는 iframe에 전달할 때 객체의 프로토타입 체인에는 객체를 만든 창을 비롯하여 객체가 생성된 환경에 대한 참조가 포함됩니다. 즉, 창 자체에 대한 참조를 유지하지 않는 것만큼 다른 창의 객체에 대한 참조를 유지하지 않는 것도 중요합니다.

    index.html:

    <script>
      let currentFiles;
      function load(files) {
        // this retains the popup:
        currentFiles = files;
      }
      window.open('upload.html');
    </script>
    

    upload.html:

    <input type="file" id="file" />
    <script>
      file.onchange = () => {
        parent.load(file.files);
      };
    </script>
    

분리된 창으로 인한 메모리 누수 감지

메모리 누수를 추적하는 것은 쉽지 않을 수 있습니다. 특히 여러 문서나 창이 관련된 경우 이러한 문제를 재현하는 것은 쉽지 않습니다. 더 복잡하게 만들기 위해 잠재적으로 유출된 참조를 검사하면 검사된 객체가 가비지 컬렉션되지 않도록 하는 추가 참조가 생성될 수 있습니다. 이를 위해 이러한 가능성을 도입하지 않는 도구로 시작하는 것이 좋습니다.

메모리 문제를 디버깅할 때는 힙 스냅샷을 찍는 것이 좋습니다. 이렇게 하면 애플리케이션에서 현재 사용 중인 메모리(생성되었지만 아직 가비지 컬렉션되지 않은 모든 객체)를 특정 시점에 확인할 수 있습니다. 힙 스냅샷에는 크기와 객체를 참조하는 변수 및 폐쇄자의 목록을 비롯하여 객체에 관한 유용한 정보가 포함됩니다.

Chrome DevTools의 힙 스냅샷 스크린샷으로, 대형 객체를 유지하는 참조를 보여줍니다.
대형 객체를 유지하는 참조를 보여주는 힙 스냅샷입니다.

힙 스냅샷을 기록하려면 Chrome DevTools의 메모리 탭으로 이동하여 사용 가능한 프로파일링 유형 목록에서 힙 스냅샷을 선택합니다. 녹화가 완료되면 요약 뷰에 생성자별로 그룹화된 메모리의 현재 객체가 표시됩니다.

Chrome DevTools에서 힙 스냅샷을 찍는 방법을 보여주는 데모입니다.

힙 덤프를 분석하는 것은 쉽지 않은 작업이며 디버깅의 일환으로 올바른 정보를 찾는 것은 매우 어려울 수 있습니다. 이를 위해 Chromium 엔지니어인 yossik@peledni@는 분리된 창과 같은 특정 노드를 강조 표시하는 데 도움이 되는 독립형 힙 클리너 도구를 개발했습니다. 트레이스에서 힙 클리너를 실행하면 보관 그래프에서 다른 불필요한 정보가 삭제되므로 트레이스가 더 깔끔해지고 읽기 쉬워집니다.

프로그래매틱 방식으로 메모리 측정

힙 스냅샷은 세부적인 정보를 제공하며 누수가 발생하는 위치를 파악하는 데 매우 유용하지만 힙 스냅샷을 찍는 것은 수동 프로세스입니다. 메모리 누수를 확인하는 또 다른 방법은 performance.memory API에서 현재 사용 중인 JavaScript 힙 크기를 가져오는 것입니다.

Chrome DevTools 사용자 인터페이스의 한 섹션 스크린샷
팝업이 생성, 닫힘, 참조 해제될 때 DevTools에서 사용된 JS 힙 크기를 확인합니다.

performance.memory API는 JavaScript 힙 크기에 관한 정보만 제공합니다. 즉, 팝업의 문서 및 리소스에서 사용하는 메모리는 포함되지 않습니다. 전체 상황을 파악하려면 현재 Chrome에서 시험 중인 새로운 performance.measureUserAgentSpecificMemory() API를 사용해야 합니다.

창문 탈착으로 인한 누수 방지 솔루션

분리된 창으로 인해 메모리 누수가 발생하는 가장 일반적인 두 가지 사례는 상위 문서가 닫힌 팝업 또는 삭제된 iframe에 대한 참조를 유지하는 경우와 창 또는 iframe의 예기치 않은 탐색으로 인해 이벤트 핸들러가 등록 취소되지 않는 경우입니다.

예: 팝업 닫기

다음 예에서는 두 개의 버튼을 사용하여 팝업 창을 열고 닫습니다. Close Popup 버튼이 작동하려면 열린 팝업 창에 대한 참조가 변수에 저장됩니다.

<button id="open">Open Popup</button>
<button id="close">Close Popup</button>
<script>
  let popup;
  open.onclick = () => {
    popup = window.open('/login.html');
  };
  close.onclick = () => {
    popup.close();
  };
</script>

언뜻 보면 위의 코드는 일반적인 함정을 피하는 것처럼 보입니다. 팝업의 문서에 대한 참조가 유지되지 않고 팝업 창에 이벤트 핸들러가 등록되지 않습니다. 하지만 Open Popup 버튼을 클릭하면 popup 변수가 열려 있는 창을 참조하게 되며 이 변수는 Close Popup 버튼 클릭 핸들러의 범위에서 액세스할 수 있습니다. popup가 재할당되거나 클릭 핸들러가 삭제되지 않는 한 해당 핸들러의 popup에 대한 닫힌 참조는 가비지 컬렉션을 실행할 수 없음을 의미합니다.

해결 방법: 참조를 설정 해제합니다.

다른 창 또는 문서를 참조하는 변수는 메모리에 유지됩니다. JavaScript의 객체는 항상 참조이므로 변수에 새 값을 할당하면 원래 객체에 대한 참조가 삭제됩니다. 객체 참조를 '재설정'하려면 이러한 변수를 null 값으로 재할당하면 됩니다.

이를 이전 팝업 예에 적용하면 닫기 버튼 핸들러를 수정하여 팝업 창에 대한 참조를 'unset'할 수 있습니다.

let popup;
open.onclick = () => {
  popup = window.open('/login.html');
};
close.onclick = () => {
  popup.close();
  popup = null;
};

이렇게 하면 도움이 되지만 open()를 사용하여 만든 창과 관련된 또 다른 문제가 드러납니다. 사용자가 맞춤 닫기 버튼을 클릭하지 않고 창을 닫으면 어떻게 될까요? 사용자가 열어둔 창에서 다른 웹사이트를 탐색하기 시작한다면 어떻게 해야 하나요? 원래는 닫기 버튼을 클릭할 때 popup 참조를 설정 해제하는 것으로 충분해 보였지만 사용자가 해당 버튼을 사용하여 창을 닫지 않으면 여전히 메모리 누수가 발생합니다. 이 문제를 해결하려면 이러한 사례를 감지하여 이러한 사례가 발생할 때 남아 있는 참조를 설정 해제해야 합니다.

해결 방법: 모니터링 및 삭제

대부분의 경우 창을 열거나 프레임을 만드는 JavaScript는 수명 주기를 배타적으로 제어하지 않습니다. 팝업은 사용자가 닫을 수 있으며 새 문서로 이동하면 이전에 창이나 프레임에 포함된 문서가 분리될 수 있습니다. 두 경우 모두 브라우저는 pagehide 이벤트를 실행하여 문서가 언로드되고 있음을 알립니다.

pagehide 이벤트는 닫힌 창과 현재 문서에서 벗어난 탐색을 감지하는 데 사용할 수 있습니다. 하지만 한 가지 중요한 주의점이 있습니다. 새로 생성된 모든 창과 iframe에는 빈 문서가 포함된 후 제공된 경우 지정된 URL로 비동기식으로 이동합니다. 따라서 초기 pagehide 이벤트는 창 또는 프레임을 만든 직후, 즉 대상 문서가 로드되기 직전에 실행됩니다. 참조 정리 코드는 타겟 문서가 언로드될 때 실행되어야 하므로 이 첫 번째 pagehide 이벤트를 무시해야 합니다. 이를 위한 여러 가지 기법이 있지만 가장 간단한 방법은 초기 문서의 about:blank URL에서 발생하는 pagehide 이벤트를 무시하는 것입니다. 팝업 예에서 표시되는 모습은 다음과 같습니다.

let popup;
open.onclick = () => {
  popup = window.open('/login.html');

  // listen for the popup being closed/exited:
  popup.addEventListener('pagehide', () => {
    // ignore initial event fired on "about:blank":
    if (!popup.location.host) return;

    // remove our reference to the popup window:
    popup = null;
  });
};

이 기법은 코드가 실행되는 상위 페이지와 동일한 유효 출처가 있는 창과 프레임에서만 작동한다는 점에 유의해야 합니다. 다른 출처에서 콘텐츠를 로드할 때는 보안상의 이유로 location.hostpagehide 이벤트를 모두 사용할 수 없습니다. 일반적으로 다른 출처에 대한 참조를 유지하지 않는 것이 가장 좋지만, 드물게 필요한 경우 window.closed 또는 frame.isConnected 속성을 모니터링할 수 있습니다. 이러한 속성이 닫힌 창 또는 삭제된 iframe을 나타내도록 변경되면 이에 대한 참조를 모두 해제하는 것이 좋습니다.

let popup = window.open('https://example.com');
let timer = setInterval(() => {
  if (popup.closed) {
    popup = null;
    clearInterval(timer);
  }
}, 1000);

솔루션: WeakRef 사용

JavaScript는 최근에 가비지 컬렉션을 실행할 수 있는 객체를 참조하는 새로운 방법인 WeakRef를 지원하게 되었습니다. 객체에 대해 생성된 WeakRef는 직접 참조가 아니라 가비지 컬렉션되지 않은 한 객체 참조를 반환하는 특수 .deref() 메서드를 제공하는 별도의 객체입니다. WeakRef를 사용하면 창이나 문서의 현재 값에 액세스하면서도 가비지 컬렉션을 허용할 수 있습니다. pagehide와 같은 이벤트 또는 window.closed와 같은 속성에 대한 응답으로 수동으로 설정 해제해야 하는 창 참조를 유지하는 대신 필요한 경우 창에 대한 액세스 권한을 얻습니다. 창이 닫히면 가비지 컬렉션이 실행되어 .deref() 메서드가 undefined를 반환하기 시작합니다.

<button id="open">Open Popup</button>
<button id="close">Close Popup</button>
<script>
  let popup;
  open.onclick = () => {
    popup = new WeakRef(window.open('/login.html'));
  };
  close.onclick = () => {
    const win = popup.deref();
    if (win) win.close();
  };
</script>

WeakRef를 사용하여 창이나 문서에 액세스할 때 고려해야 할 한 가지 흥미로운 세부정보는 일반적으로 창이 닫히거나 iframe이 삭제된 후에도 참조를 짧은 시간 동안 사용할 수 있다는 점입니다. 이는 WeakRef가 연결된 객체가 가비지 컬렉션될 때까지 값을 계속 반환하기 때문입니다. 가비지 컬렉션은 JavaScript에서 비동기식으로, 일반적으로 유휴 시간 중에 발생합니다. 다행히 Chrome DevTools 메모리 패널에서 분리된 창을 확인할 때 힙 스냅샷을 가져오면 실제로 가비지 컬렉션이 트리거되고 약한 참조 창이 삭제됩니다. deref()undefined을 반환하는 시점을 감지하거나 새 FinalizationRegistry API를 사용하여 WeakRef를 통해 참조된 객체가 JavaScript에서 삭제되었는지 확인할 수도 있습니다.

let popup = new WeakRef(window.open('/login.html'));

// Polling deref():
let timer = setInterval(() => {
  if (popup.deref() === undefined) {
    console.log('popup was garbage-collected');
    clearInterval(timer);
  }
}, 20);

// FinalizationRegistry API:
let finalizers = new FinalizationRegistry(() => {
  console.log('popup was garbage-collected');
});
finalizers.register(popup.deref());

해결 방법: postMessage를 통해 통신

창이 닫히거나 탐색에서 문서를 언로드할 때 이를 감지하면 분리된 창을 가비지 컬렉션할 수 있도록 핸들러를 삭제하고 참조를 설정 해제할 수 있습니다. 그러나 이러한 변경사항은 때때로 더 근본적인 문제인 페이지 간 직접 결합을 해결하기 위한 구체적인 수정사항입니다.

창과 문서 간의 비활성 참조를 방지하는 보다 전체적인 대안 접근 방식을 사용할 수 있습니다. 교차 문서 통신을 postMessage()로 제한하여 분리를 설정합니다. 원래 발표자 메모 예시를 다시 생각해 보면 nextSlide()와 같은 함수는 메모 창을 참조하고 콘텐츠를 조작하여 메모 창을 직접 업데이트했습니다. 대신 기본 페이지는 postMessage()를 통해 비동기식으로 간접적으로 메모 창에 필요한 정보를 전달할 수 있습니다.

let updateNotes;
function showNotes() {
  // keep the popup reference in a closure to prevent outside references:
  let win = window.open('/presenter-view.html');
  win.addEventListener('pagehide', () => {
    if (!win || !win.location.host) return; // ignore initial "about:blank"
    win = null;
  });
  // other functions must interact with the popup through this API:
  updateNotes = (data) => {
    if (!win) return;
    win.postMessage(data, location.origin);
  };
  // listen for messages from the notes window:
  addEventListener('message', (event) => {
    if (event.source !== win) return;
    if (event.data[0] === 'nextSlide') nextSlide();
  });
}
let slide = 1;
function nextSlide() {
  slide += 1;
  // if the popup is open, tell it to update without referencing it:
  if (updateNotes) {
    updateNotes(['setSlide', slide]);
  }
}
document.body.onclick = nextSlide;

이렇게 하려면 여전히 창이 서로를 참조해야 하지만 어느 창도 다른 창의 현재 문서 참조를 유지하지 않습니다. 메시지 전달 접근 방식은 창 참조가 한곳에서 유지되는 설계를 권장합니다. 즉, 창을 닫거나 탐색할 때 단일 참조만 설정 해제하면 됩니다. 위 예에서 showNotes()만 메모 창 참조를 유지하며 pagehide 이벤트를 사용하여 참조가 정리되도록 합니다.

해결 방법: noopener를 사용한 참조 피하기

페이지에서 통신하거나 제어할 필요가 없는 팝업 창이 열리는 경우 창 참조를 얻지 않을 수 있습니다. 이 방법은 다른 사이트에서 콘텐츠를 로드할 창이나 iframe을 만들 때 특히 유용합니다. 이러한 경우 window.open()는 HTML 링크의 rel="noopener" 속성과 마찬가지로 작동하는 "noopener" 옵션을 허용합니다.

window.open('https://example.com/share', null, 'noopener');

"noopener" 옵션을 사용하면 window.open()null를 반환하므로 실수로 팝업 참조를 저장할 수 없습니다. 또한 window.opener 속성이 null이므로 팝업 창이 상위 창 참조를 가져오는 것을 방지합니다.

의견

이 도움말의 제안사항이 메모리 누수를 찾고 수정하는 데 도움이 되기를 바랍니다. 분리된 창을 디버그하는 다른 기법이 있거나 이 도움말이 앱의 누수를 찾는 데 도움이 되었다면 알려주세요. 트위터 @_developit에서 저를 찾으실 수 있습니다.