본문 바로가기

FrontEnd

[번역] 웹 애플리케이션의 메모리 누수 진단하고 고치기

반응형

다음과 같은 화면을 본 적 있나요?

 

Chrome 브라우저 "앗, 맙소사!" 페이지(작성자의 스크린샷)

크롬은 위와 같이 해당 페이지에서 메모리 누수가 발생했음을 알려줍니다.


메모리 누수가 뭔가요?

메모리 누수는 자원 할당을 잘못 관리하여
컴퓨터 프로그램이 더 이상 필요하지 않은 메모리를 해제하지 않아 성능 저하를 초래하는 것을 말합니다.
실행되는 동안 지속적으로 더 많은 메모리를 소비합니다.
 
거의 모든 프로그래밍 언어의 메모리 생명 주기는
  • 메모리를 할당하고,
  • 사용하고,
  • 해제하는 방식으로 작동합니다.

JS 메모리 누수가 문제가 되는 이유

스마트폰 출시와 모바일 앱의 확산 이후
웹 개발자는 이에 영감을 받아 웹 페이지를 디자인하고 구성하는 방식을 변경하기 시작했습니다.
오래 전 웹 개발 환경은 Java/JSP, PHP, ASP 및 Ruby와 같은 서버측 기술을 사용하는
MPA(다중 페이지 응용 프로그램)에 의해 지배되었습니다.
이를 서버 렌더링 애플리케이션이라고 합니다.

당시 일반적인 웹사이트는 대부분 MPA였습니다.
클라이언트와 서버 간에 여러 요청을 만들어 동작합니다.
웹사이트의 각 페이지는 서버에 요청을 보내고 모든 데이터를 완전히 업데이트합니다.

  • 2004년에 웹 개발자는 다시 로드하지 않고 웹 페이지를 업데이트할 수 있는 AJAX(Asynchronous JavaScript and XML)에 대한 관심이 증가했습니다. 이 접근 방식은 SPA(Single Page Application)로 가는 길을 열었습니다.
  • Backbone.js의 2009년 릴리스는 경량 클라이언트 측 프레임워크를 제공했습니다.
  • 2010년경 AngularJS는 클라이언트 측 MVC(Model-View-Controller) 아키텍처, 양방향 데이터 바인딩, 템플릿 및 종속성 주입과 같은 기능을 제공하여 최초의 진정한 SPA 프레임워크가 되었습니다.
SPA(Single Page Application) vs MPA(Multi Page Application)
전체 새 페이지를 로드하는 대신 단일 페이지 애플리케이션(SPA)은
현재 웹 페이지를 새 데이터로 업데이트하여 사용자와 상호 작용하는 웹 앱입니다.
이렇게 하면 웹사이트가 기본 앱처럼 느껴지고 더 나은 상호 작용이 제공됩니다.
SPA에서는 새 페이지로 이동할 때 콘텐츠의 일부만 업데이트되는 방식으로 아키텍처가 배열되기 때문에
페이지 새로 고침이 발생하지 않습니다.
필요한 모든 HTML, JavaScript, CSS 코드 및 리소스는 초기 로드 시 브라우저에서 검색하거나 사용자 작업에 대한 응답으로 페이지에 동적으로 추가됩니다.
대기 시간이 거의 또는 전혀 없이 콘텐츠가 즉시 로드되는 것처럼 보이므로 보다 원활한 사용자 경험을 제공합니다.
그러나 서버 렌더링 웹 사이트 구축에서 클라이언트 렌더링 SPA 및 앱과 같은 동작으로 전환하려면
사용자 장치의 리소스를 훨씬 더 많이 관리해야 하므로 
이전에 존재하지 않던 새로운 종류의 문제가 발생할 수 있습니다.
  • 핸드폰의 배터리를 소모합니다.
  • 노트북의 팬을 돌립니다.
  • UI 스레드를 차단합니다.

구식 웹 페이지(MPA)를 사용하면 페이지 사이를 이동할 때마다 새 페이지 로드가 발생하고 브라우저가 메모리를 지우기 때문에
부적절한 메모리 관리의 극적인 결과를 보지 못할 수 있습니다.

 

그러나 SPA의 등장 이후 메모리 관련 JS 코딩 관행에 더 많은 관심을 기울여야 했습니다.
성능에 심각한 영향을 미치고 심지어 브라우저 탭이 충돌할 수도 있기 때문입니다.


웹 성능 문제의 유형

웹 성능에는 여러 유형이 있습니다. 단순화하기 위해 두 가지 유형을 고려해 보겠습니다.

유형 1: 웹 페이지 호출 직후

Chrome Lighthouse에서 몇 가지 결과를 보여주는 아래 예와 같이 웹 앱을 로드하는 데 시간이 오래 걸리거나
로드를 마친 직후 성능이 좋지 않은 경우:

Chrome DevTools에서 Lighthouse가 측정한 나쁜 성능의 예

이는 메모리 누수를 의미하는 것은 아닙니다. 오히려 성능을 최적화하기 위해 다음 조치 중 하나 이상을 수행해야 한다는 신호입니다.
  • 큰 컴포넌트와 JS/TS 파일을 작은 파일로 분할합니다.
  • 사용하지 않는 파일과 코드를 제거합니다.
  • 메모이제이션 및 캐싱과 같은 기술을 사용하여 HTTP 요청 수를 줄입니다.
  • webpack-bundle-analyzer를 사용하여 번들을 분석하고 일부 거대한 타사 라이브러리를 사용할 가치가 있는지 확인하십시오.

유형 2: 긴 세션 후 — 런타임 성능

상당한 시간 동안 웹 페이지를 다시 로드하지 않고(F5 키 사용) 웹 페이지를 재생한 후 성능 저하를 발견했다면
이는 메모리 누수 신호일 수 있습니다.

메모리 누수의 원인을 식별하고 수정하면 최종 사용자가 소요하는 시간을 줄여 런타임 성능을 최적화할 수 있습니다.
이를 통해 세션 중에 소비되는 리소스 수를 줄이면서 일부 시나리오를 완료할 수 있습니다.


당신의 앱이 보여주는 메모리 누수의 징후

운영 환경에서 대혼란을 일으킬 때까지 메모리 누수를 일으켰다는 사실을 모를 수도 있습니다.
이를 인식하는 일반적인 패턴은 다음과 같습니다.
  1. 속도 저하: 애플리케이션 작업의 긴 세션(몇 시간 또는 하루일 수 있음) 후에 UI가 느려지고 느려집니다.
  2. 웹 페이지가 충돌합니다.
  3. 앱이 자주 일시 중지됩니다.
  4. JS 힙이 시작된 것보다 높게 끝납니다.
  5. 노드 크기 및/또는 리스너 크기가 증가하는 것을 볼 수 있습니다.
JS 힙 및 노드 크기가 시작보다 높게 종료됨

크롬 개발자 도구로 메모리 누수 징후 포착하기

위에서 언급한 마지막 두 가지 메모리 누수 징후를 확인하려면 Chrome DevTools를 사용할 수 있습니다.

Performance Timeline Record

성능 타임라인 기록은 빈번한 가비지 수집, 메모리 팽창 및 메모리 누수와 같이
페이지 성능에 영향을 미치는 메모리 문제를 찾는 데 도움이 됩니다.

기록을 만들려면:
  1. DevTools에서 성능 패널을 열고
  2. 메모리 체크박스를 활성화하고
  3. 기록을 남깁니다.

Chrome DevTools의 성능 타임라인 기록

시작 버튼(3)을 클릭한 후 애플리케이션을 갖고 놀아 봅니다.
일부 화면 또는 대화 상자를 여러 번 열고, 새 항목을 만들고, 일부 항목을 삭제 또는 업데이트하고, 일부 양식에 데이터를 입력하고,
버튼을 여러 번 클릭합니다.

 
기록을 중지하고 아래와 유사한 결과를 볼 수 있을 때까지 기다립니다.
성능 타임라인 기록
이 성능 타임라인 기록의 메모리 사용량을 다음과 같이 분류할 수 있습니다.
  • JS 힙(파란색 선): JavaScript에 필요한 메모리를 나타냅니다. 이 샘플에서는 시작보다 높게 끝납니다(메모리 누수 표시).
  • Documents(빨간색 선)
  • DOM 노드(녹색 선): 시작보다 높게 끝납니다(메모리 누수 표시).
  • Listeners(노란색 선): 시작된 것보다 더 높게 끝납니다(메모리 누수 표시).

수직 파란색 선은 자주 오르락내리락하는 JS 힙 크기를 의미합니다,

이는 빈번한 가비지 수집을 의미하며 성능에 대한 좋은 신호가 아닙니다.

Heap Snapshots

Chrome DevTools에서 사용할 수 있는 또 다른 기능은 메모리 패널입니다.
연속적인 힙 스냅샷을 찍고 이를 비교하여 웹 페이지에서 얼마나 많은 메모리가 할당되고 소비되었는지,
그리고 메모리가 JavaScript 객체, 프리미티브, 문자열, 함수, DOM 노드 등에 어떻게 분산되어 있는지에 대한 통찰력을 얻을 수 있습니다.
  1. DevTools에서 메모리 패널을 엽니다.
  2. 힙 스냅샷 체크박스(2)를 활성화합니다.
  3. "스냅샷 찍기(3)" 버튼을 클릭합니다. "Snapshot 1"이 준비될 때까지 몇 초간 기다리십시오.
Chrome DevTools: Heap snapshot

4. 앱을 가지고 놀아봅니다.

5."힙 스냅샷 찍기" 아이콘을 다시 한 번 클릭하여 두 번째 스냅샷을 찍습니다.

  • 두 번째로 기록된 스냅샷의 크기가 첫 번째 것보다 크고 다음 기록에서도 패턴이 계속 증가하는 경우 메모리 누수가 원인일 수 있습니다.
6. "Summary"을 클릭한 다음 "Comparison"를 클릭하여 차이를 확인합니다.
  • "# New" 열은 두 번째 스냅샷에 할당된 객체(새 배열, 클로저, 이벤트 이미터 등)를 보여줍니다.
  • 열 "# Deleted"은 삭제된 객체를 표시합니다.

Heap Profile

메모리 할당을 추적하기 위해 DevTools 메모리 패널의 힙 프로파일을 활용할 수도 있습니다.

  1. DevTools를 엽니다.
  2. 메모리 패널로 이동합니다.
  3. "타임라인에서 할당 계측" 라디오 버튼을 선택합니다.
  4. "힙 프로파일 기록 시작" 버튼을 누릅니다.

Chrome DevTools: record heap profile

 
5. 앱을 가지고 놀면서 메모리 누수를 일으키는 것으로 의심되는 시나리오나 작업을 수행합니다.
6. 그런 다음 "기록 중지" 빨간색 원 버튼을 누릅니다.
Taking an “Allocation instrumentation on timeline” on the memory panel of chrome DevTools
  • 수직 파란색 선은 JS 객체에 대한 메모리 할당을 나타냅니다.
  • 시간이 지나도 사라지지 않고 회색 선으로 바뀌는 파란색 선은 할당되었지만 해제되지 않은 메모리를 나타냅니다.
마우스로 해당 라인을 선택하면 자세한 내용을 볼 수 있습니다.

메모리 누수를 발생시키는 7가지 자바스크립트 코딩 패턴

메모리 누수의 주된 원인을 한 가지 표현으로 요약해 보라고 하면 원치 않는 참조(reference)일 것입니다.

 

불필요한 객체, 요소 또는 변수가 있지만

여전히 앱의 일부에서 도달할 수 있는 경우 JS 가비지 수집기가 제거할 수 없습니다.

 

좋은 소식은 무엇을 찾아야 하는지 알고 있다면 배포하기 전에도 소스 코드에서 이러한 시나리오를 생성하는 패턴을 포착할 수 있다는 것입니다.

다음은 누수를 유발할 수 있는 7가지 일반적인 코딩 패턴입니다.


1. 전역 변수

전역 변수는 루트에서 액세스할 수 있으며 가비지 수집되지 않습니다.

strict 모드를 사용하지 않고 선언되지 않은 변수에 값을 할당하는 경우 전역 변수로 만듭니다. 
하지만 걱정하지 마세요. EcmaScript 5 이상을 사용하는 경우 예전처럼 소스 코드에 use strict를 직접 작성할 필요가 없습니다.
ES5는 당신을 위해 그것을 할 것이고 우발적인 메모리 누수를 방지할 것입니다.

 

Redux와 같은 글로벌 스토어는 글로벌 변수의 좋은 예입니다.
주의하지 않으면 많은 양의 정보를 처리하며 스토어에 메모리를 계속 추가할 수 있으며, 가비지 수집되지 않습니다.

전역 변수가 많은 데이터를 저장하는 경우 필요하지 않을 때 null로 지정하거나 재할당하는 것을 고려하십시오.

2. 캐시

메모리 소비를 증가시키는 또 다른 일반적인 원인은 반복적으로 사용되는 데이터를 캐시에 저장하는 것입니다.

 

세부 정보 화면에서 목록 보기로 이동할 때 매번 서버에 요청하는 것보다 캐시에서 데이터를 가져오는 것을 선호합니다.

 

이것은 전체 네트워크 왕복을 절약하는 훨씬 빠른 접근 방식입니다.
특히 최종 사용자가 느린 네트워크나 대역폭이 제한된 장치에 있을 때 사용자 경험을 향상시킵니다.

하지만, 사용하지 않는 객체를 제거하지 않고 크기를 제한하는 일부 논리 없이 객체를 캐시에 계속 추가하면
캐시가 무한정 커져 메모리 소비가 높아집니다.
캐시 크기에 대한 상한선이 있어야 합니다.

 

3. 컬렉션

몇 개의 웹 앱에 대해 DevTools로 힙 스냅샷을 만들고 메모리가 어떻게 분산되어 있는지 확인하면
배열과 문자열의 크기가 상당히 얕다는 것을 알 수 있을 것입니다.

종속 객체와 함께 삭제되면 해제되는 메모리를 나타내는 retained size와 달리
shallow size는 객체 자체만 보유하는 메모리 크기입니다.

메모리 그래프의 루트에서 도달할 수 없는 모든 개체는 가비지 수집됩니다.

아래는 Typed Arrays(90MB 이상) 및 JS Arrays(약 3MB)로 인해 주로 발생한 힙 스냅샷 크기가 약 100MB 증가한 것을 볼 수 있는 예입니다.
참고: JavaScript의 Typed Arrays는 메모리 버퍼에서 원시 이진 데이터를 읽고 쓸 수 있는 배열과 유사한 객체입니다.

Types arrays holding over 90MB in a heap snapshot

Chrome DevTools Memory Profiler로 두 개의 힙 스냅샷을 만들고 비교하기

Array, Map 및 Set을 사용하면 데이터를 저장할 수 있지만,
코드를 현명하게 구현하지 않으면 참조가 영원히 유지되어 누수가 발생할 수 있습니다.

 

다음은 사용자의 행동을 추적하는 매우 간단한 전자 상거래 앱입니다.
방문한 모든 화면, 확인된 모든 제품 및 검색에서 반환된 각 제품을 checkedItems 컬렉션에 추가한 다음
주기적으로 데이터를 서버로 보냅니다.

function(item, view, timestamp) {
 checkedItems.push({item, view, timestamp})
}
데이터를 백엔드로 보낸 후 checkedItems 배열을 지우는 것을 잊으면 메모리 누수가 발생합니다.
이유는 해당 데이터 구조 내의 개체에 대한 참조를 계속 유지하고
사용자가 탭을 닫지 않는 한 새 참조를 계속 추가하기 때문입니다.

메모리에서 배열 참조를 해제하는 두 가지 방법

1번째 방법

[]를 참조하도록 변경
let list1 = [i, j, n];
let list2 = list1;
list1 = [];

이 경우 list2는 기존 list1을 참조하여 i,j,n은 메모리 해제되지 않음

2번째 방법

length 속성을 0으로 변경

let list1 = [i, j, n];
let list2 = list1;
list1.length = 0;
이 결과 list1과 list2가 모두 지워지고 i, j 및 n이 메모리에서 제거됩니다.

list1 내의 개체에 대한 외부 참조는 해당 요소를 메모리에 유지합니다.
다음 코드 스니펫에서 i와 j는 list2가 여전히 참조하고 있기 때문에 메모리에 남아 있습니다.

let list1 = [i, j, n];
let list2 = [i, j];
list1.length = 0;
 

4. 이벤트 리스너

웹 앱이 키보드 이벤트, 마우스 이벤트 및 스크롤 이벤트를 수신해야 하는 경우
이러한 모든 패턴이 버그가 있는 코드로 메모리를 쉽게 누수할 수 있는 패턴임을 기억해야 합니다.

 

이벤트 리스너는 해당 범위에서 캡처된 객체 및 변수가 가비지 수집되는 것을 방지합니다.
따라서 리스닝를 중지하는 것을 잊어버리면 메모리 누수를 예상할 수 있습니다.

 

addEventListener는 자바스크립트에서 이벤트 리스너를 추가하는 가장 일반적인 방법이며
해당 객체는 다음 사건이 발생할 때까지 활성 상태를 유지합니다.

  • removeEventListener()를 사용하여 명시적으로 제거
  • 연결된 DOM 요소가 제거됩니다.
function doSomething() {
  // ...
}
document.addEventListener('keydown', doSomething); // add listener
document.removeEventListener('keydown', doSomething); // remove listener
위의 예에서 document를 제거할 수 없습니다.

즉, removeEventListener()를 호출하여 정리하지 않으면
doSomething() 리스너와 해당 범위에 유지되는 모든 항목은 gc되지 않습니다.

 

이벤트 리스너를 한 번만 실행해야 하는 경우
세 번째 매개변수 {once: true}를 addEventListener()에 추가하여 리스너 함수가 작업을 수행한 후 자동으로 제거되도록 할 수 있습니다.
function doSomething() {
   // ...
}
document.addEventListener('keyup', doSomething, {once: true}); // the listener will be automatically removed after running once

5. 타이머

영원히 실행되는 타이머는 할당된 개체를 유지하므로 메모리가 누수될 수 있습니다.
타이밍 이벤트를 처리하는 JavaScript의 두 가지 주요 메서드는 다음과 같습니다.

setTimeout(function, milliseconds)

<input type="button" name="greeting" value="Wait for greeting"
onclick="setTimeout('alert(\'Hello world!\')', 3000)"/>

setInterval(function, milliseconds)

let numberOfNotifications = getNotifications().length;
setInterval(function() {
    let div = document.getElementById('notificationIcon');
    // Show the number of new messages on the notification icon
    div.innerHTML = JSON.stringify(numberOfNotifications));
}, 10000);
40초마다 실행되는 반복 타이머를 만드는 경우 clearTimeout 또는 clearInterval을 사용하여 정리해야 합니다.

6. 클로저(주변 상태 참조를 에워쌈;포함함)

클로저는 주변 상태(어휘적 환경)에 대한 참조와 함께 묶인(주변 상태에 대한 참조를 에워싸는) 함수의 조합입니다.
즉, 클로저는 내부 함수에서 외부 함수의 범위에 대한 액세스를 제공합니다.”

클로저는 외부 함수 변수에 대한 참조를 보유할 수 있기 때문에
가비지 컬렉터가 이러한 변수가 보유한 메모리를 해제하는 것을 방지합니다.

function outerFunc() {
  var name = 'Rakia';
  function innerFunc() { console.log(name); }
  return innerFunc;
}

var displayName = outerFunc();
displayName();

 

  • displayName은 outerFunc를 실행할 때 생성되는 innerFunc 함수의 인스턴스에 대한 참조입니다.
  • 클로저 innerFunc는 상위 스코프의 name에 액세스할 수 있습니다.
  • innerFunc는 어휘 환경에 대한 참조를 유지하므로 외부 함수가 displayName을 호출한 후 실행을 완료한 경우에도 변수 name을 계속 사용할 수 있습니다.

7. 무한 스크롤 /  사용되지 않는 DOM 참조

Infinite DOM:

애플리케이션에 Facebook의 "더 많은 게시물 표시" 또는 YouTube의 "더 많은 비디오 로드"와 유사한 무한 스크롤 기능이 있는 경우
문서 객체 모델(DOM) 노드의 수가 무한대로 증가합니다.
가상화(virtualization)를 사용하여 이 문제를 해결할 수 있습니다.
 

Detached DOM referenced:

DOM 요소가 더 이상 사용되지 않으면 JavaScript에서 참조해서는 안 됩니다.
그렇지 않으면 DOM 트리에서 제거한 후에도 가비지 수집되지 않습니다.
 
다음 예제의 코드는 div 요소를 만들어 document.body에 추가합니다.
그런 다음 deleteDivElement() 내에서 removeChild()를 호출합니다.
여전히 div를 가리키는 변수 detachedDiv가 있고 힙 스냅샷에 분리된 HTMLDivElement가 표시되기 때문에
이 로직은 예상대로 작동하지 않습니다.
function createDivElement() {
  const div = document.createElement('div');
  div.id = 'new';
  return div;
}

function deleteDivElement() {
  document.body.removeChild(document.getElementById('new'));
}

const detachedDiv = createDivElement();
document.body.appendChild(detachedDiv);

deleteDivElement(); // Heap snapshot will show detached div#new because there is still
// a constant detachedDiv referencing the DOM element even after deleteDivElement() is called

DOM 요소를 가리키는 변수 detachedDiv를 함수 appendDivElement()의 로컬 범위로 이동하면 됩니다.

function createDivElement() {
  const div = document.createElement('div');
  div.id = 'new';
  return div;
}

// DOM references are inside the function scope
function appendDivElement() {
  const detachedDiv = createDivElement();
  document.body.appendChild(detachedDiv);
}

function deleteDivElement() {
  document.body.removeChild(document.getElementById('new'));
}

appendDivElement();
deleteDivElement(); // no detached div#new element in the Heap Snapshot after this call

결론

메모리 누수 문제를 사냥하는 것은 극단적인 엣지 케이스를 가진 복잡한 문제이며 이를 디버깅하는 것은 어려운 작업이 될 수 있습니다.
앱에서 이러한 문제를 방지하려면 이에 대한 인식과 지속적인 경계가 필요합니다.

 

코드를 설계하고 작성하는 방식에 따라 메모리 조각이 운영 체제에 반환되는지 여부가 결정됩니다.

이 게시물을 통해 얻은 통찰력이 코드 디자인을 최적화하고 앱을 수정하는 데 도움이 되기를 바랍니다.
한 번에 모든 것을 할 필요는 없습니다.
사례에 맞는 팁을 선택하고 메모리 누수 괴물을 길들일 때까지 가끔 적용하십시오.

참고

udemy 유료 강의

https://betterprogramming.pub/javascript-memory-leaks-390957523a9e

 

How to Identify, Diagnose, and Fix Memory Leaks in Web Apps

Boost your web app’s performance by writing optimized code and using Chrome DevTools

betterprogramming.pub

 

반응형