분리된 창 메모리 누수

분리된 창으로 인해 발생하는 까다로운 메모리 누수를 찾아 해결합니다.

Bartek Nowierski
Bartek Nowierski

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 객체의 속성에 계속 액세스할 수 있으므로 메모리에 유지되어야 합니다. 창에 자바스크립트 객체 또는 iframe이 많이 포함된 경우 창 속성에 관한 남은 자바스크립트 참조가 없어질 때까지 이 메모리를 회수할 수 없습니다.

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

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

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

창 또는 iframe 내의 document 참조가 JavaScript에서 유지되는 경우 해당 문서는 포함된 창 또는 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의 Memory 탭으로 이동하여 사용 가능한 프로파일링 유형 목록에서 Heap Snapshot을 선택합니다. 기록이 완료되면 Summary 뷰에 생성자별로 그룹화된 현재 객체가 메모리에 표시됩니다.

Chrome DevTools에서 힙 스냅샷을 찍는 방법 시연

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

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

힙 스냅샷은 높은 수준의 세부 정보를 제공하며 누수가 발생한 위치를 파악하는 데 적합하지만, 힙 스냅샷 생성은 수동 프로세스입니다. 메모리 누수를 확인하는 또 다른 방법은 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 참조는 가비지 컬렉션이 불가능함을 의미합니다.

해결 방법: 참조 설정 해제

다른 창이나 해당 문서를 참조하는 변수로 인해 메모리에 유지됩니다. 자바스크립트의 객체는 항상 참조이므로 변수에 새 값을 할당하면 원본 객체에 대한 참조가 삭제됩니다. 객체 참조를 '설정 해제'하려면 이러한 변수를 null 값에 재할당하면 됩니다.

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

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

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

솔루션: 모니터링 및 폐기

많은 상황에서 창을 열거나 프레임을 생성하는 JavaScript는 수명 주기를 독점적으로 제어할 수 없습니다. 사용자가 팝업을 닫을 수 있습니다. 또는 새 문서로 이동하면 창 또는 프레임에 포함되었던 문서가 분리될 수 있습니다. 두 경우 모두 브라우저에서 pagehide 이벤트를 실행하여 문서가 언로드되고 있음을 알립니다.

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

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의 Memory 패널에서 분리된 창을 확인할 때 힙 스냅샷을 생성하면 실제로 가비지 컬렉션을 트리거하고 약하게 참조된 창을 삭제합니다. deref()undefined를 반환하는 시점을 감지하거나 새 FinalizationRegistry API를 사용하여 WeakRef를 통해 참조된 객체가 자바스크립트에서 삭제되었는지 확인할 수도 있습니다.

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가 되므로 팝업 창이 상위 창의 참조를 가져오지 못하게 됩니다.

의견

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