다음과 같은 화면을 본 적 있나요?
크롬은 위와 같이 해당 페이지에서 메모리 누수가 발생했음을 알려줍니다.
메모리 누수가 뭔가요?
- 메모리를 할당하고,
- 사용하고,
- 해제하는 방식으로 작동합니다.
JS 메모리 누수가 문제가 되는 이유
당시 일반적인 웹사이트는 대부분 MPA였습니다.
클라이언트와 서버 간에 여러 요청을 만들어 동작합니다.
웹사이트의 각 페이지는 서버에 요청을 보내고 모든 데이터를 완전히 업데이트합니다.
- 2004년에 웹 개발자는 다시 로드하지 않고 웹 페이지를 업데이트할 수 있는 AJAX(Asynchronous JavaScript and XML)에 대한 관심이 증가했습니다. 이 접근 방식은 SPA(Single Page Application)로 가는 길을 열었습니다.
- Backbone.js의 2009년 릴리스는 경량 클라이언트 측 프레임워크를 제공했습니다.
- 2010년경 AngularJS는 클라이언트 측 MVC(Model-View-Controller) 아키텍처, 양방향 데이터 바인딩, 템플릿 및 종속성 주입과 같은 기능을 제공하여 최초의 진정한 SPA 프레임워크가 되었습니다.
- 핸드폰의 배터리를 소모합니다.
- 노트북의 팬을 돌립니다.
- UI 스레드를 차단합니다.
구식 웹 페이지(MPA)를 사용하면 페이지 사이를 이동할 때마다 새 페이지 로드가 발생하고 브라우저가 메모리를 지우기 때문에
부적절한 메모리 관리의 극적인 결과를 보지 못할 수 있습니다.
그러나 SPA의 등장 이후 메모리 관련 JS 코딩 관행에 더 많은 관심을 기울여야 했습니다.
성능에 심각한 영향을 미치고 심지어 브라우저 탭이 충돌할 수도 있기 때문입니다.
웹 성능 문제의 유형
유형 1: 웹 페이지 호출 직후
- 큰 컴포넌트와 JS/TS 파일을 작은 파일로 분할합니다.
- 사용하지 않는 파일과 코드를 제거합니다.
- 메모이제이션 및 캐싱과 같은 기술을 사용하여 HTTP 요청 수를 줄입니다.
- webpack-bundle-analyzer를 사용하여 번들을 분석하고 일부 거대한 타사 라이브러리를 사용할 가치가 있는지 확인하십시오.
유형 2: 긴 세션 후 — 런타임 성능
상당한 시간 동안 웹 페이지를 다시 로드하지 않고(F5 키 사용) 웹 페이지를 재생한 후 성능 저하를 발견했다면
이는 메모리 누수 신호일 수 있습니다.
메모리 누수의 원인을 식별하고 수정하면 최종 사용자가 소요하는 시간을 줄여 런타임 성능을 최적화할 수 있습니다.
이를 통해 세션 중에 소비되는 리소스 수를 줄이면서 일부 시나리오를 완료할 수 있습니다.
당신의 앱이 보여주는 메모리 누수의 징후
- 속도 저하: 애플리케이션 작업의 긴 세션(몇 시간 또는 하루일 수 있음) 후에 UI가 느려지고 느려집니다.
- 웹 페이지가 충돌합니다.
- 앱이 자주 일시 중지됩니다.
- JS 힙이 시작된 것보다 높게 끝납니다.
- 노드 크기 및/또는 리스너 크기가 증가하는 것을 볼 수 있습니다.
크롬 개발자 도구로 메모리 누수 징후 포착하기
Performance Timeline Record
성능 타임라인 기록은 빈번한 가비지 수집, 메모리 팽창 및 메모리 누수와 같이
페이지 성능에 영향을 미치는 메모리 문제를 찾는 데 도움이 됩니다.
- DevTools에서 성능 패널을 열고
- 메모리 체크박스를 활성화하고
- 기록을 남깁니다.
시작 버튼(3)을 클릭한 후 애플리케이션을 갖고 놀아 봅니다.
일부 화면 또는 대화 상자를 여러 번 열고, 새 항목을 만들고, 일부 항목을 삭제 또는 업데이트하고, 일부 양식에 데이터를 입력하고,
버튼을 여러 번 클릭합니다.
- JS 힙(파란색 선): JavaScript에 필요한 메모리를 나타냅니다. 이 샘플에서는 시작보다 높게 끝납니다(메모리 누수 표시).
- Documents(빨간색 선)
- DOM 노드(녹색 선): 시작보다 높게 끝납니다(메모리 누수 표시).
- Listeners(노란색 선): 시작된 것보다 더 높게 끝납니다(메모리 누수 표시).
수직 파란색 선은 자주 오르락내리락하는 JS 힙 크기를 의미합니다,
이는 빈번한 가비지 수집을 의미하며 성능에 대한 좋은 신호가 아닙니다.
Heap Snapshots
- DevTools에서 메모리 패널을 엽니다.
- 힙 스냅샷 체크박스(2)를 활성화합니다.
- "스냅샷 찍기(3)" 버튼을 클릭합니다. "Snapshot 1"이 준비될 때까지 몇 초간 기다리십시오.
4. 앱을 가지고 놀아봅니다.
5."힙 스냅샷 찍기" 아이콘을 다시 한 번 클릭하여 두 번째 스냅샷을 찍습니다.
- 두 번째로 기록된 스냅샷의 크기가 첫 번째 것보다 크고 다음 기록에서도 패턴이 계속 증가하는 경우 메모리 누수가 원인일 수 있습니다.
- "# New" 열은 두 번째 스냅샷에 할당된 객체(새 배열, 클로저, 이벤트 이미터 등)를 보여줍니다.
- 열 "# Deleted"은 삭제된 객체를 표시합니다.
Heap Profile
메모리 할당을 추적하기 위해 DevTools 메모리 패널의 힙 프로파일을 활용할 수도 있습니다.
- DevTools를 엽니다.
- 메모리 패널로 이동합니다.
- "타임라인에서 할당 계측" 라디오 버튼을 선택합니다.
- "힙 프로파일 기록 시작" 버튼을 누릅니다.
- 수직 파란색 선은 JS 객체에 대한 메모리 할당을 나타냅니다.
- 시간이 지나도 사라지지 않고 회색 선으로 바뀌는 파란색 선은 할당되었지만 해제되지 않은 메모리를 나타냅니다.
메모리 누수를 발생시키는 7가지 자바스크립트 코딩 패턴
메모리 누수의 주된 원인을 한 가지 표현으로 요약해 보라고 하면 원치 않는 참조(reference)일 것입니다.
불필요한 객체, 요소 또는 변수가 있지만
여전히 앱의 일부에서 도달할 수 있는 경우 JS 가비지 수집기가 제거할 수 없습니다.
좋은 소식은 무엇을 찾아야 하는지 알고 있다면 배포하기 전에도 소스 코드에서 이러한 시나리오를 생성하는 패턴을 포착할 수 있다는 것입니다.
다음은 누수를 유발할 수 있는 7가지 일반적인 코딩 패턴입니다.
1. 전역 변수
strict 모드를 사용하지 않고 선언되지 않은 변수에 값을 할당하는 경우 전역 변수로 만듭니다.
하지만 걱정하지 마세요. EcmaScript 5 이상을 사용하는 경우 예전처럼 소스 코드에 use strict를 직접 작성할 필요가 없습니다.
ES5는 당신을 위해 그것을 할 것이고 우발적인 메모리 누수를 방지할 것입니다.
Redux와 같은 글로벌 스토어는 글로벌 변수의 좋은 예입니다.
주의하지 않으면 많은 양의 정보를 처리하며 스토어에 메모리를 계속 추가할 수 있으며, 가비지 수집되지 않습니다.
2. 캐시
메모리 소비를 증가시키는 또 다른 일반적인 원인은 반복적으로 사용되는 데이터를 캐시에 저장하는 것입니다.
세부 정보 화면에서 목록 보기로 이동할 때 매번 서버에 요청하는 것보다 캐시에서 데이터를 가져오는 것을 선호합니다.
이것은 전체 네트워크 왕복을 절약하는 훨씬 빠른 접근 방식입니다.
특히 최종 사용자가 느린 네트워크나 대역폭이 제한된 장치에 있을 때 사용자 경험을 향상시킵니다.
3. 컬렉션
몇 개의 웹 앱에 대해 DevTools로 힙 스냅샷을 만들고 메모리가 어떻게 분산되어 있는지 확인하면
배열과 문자열의 크기가 상당히 얕다는 것을 알 수 있을 것입니다.
종속 객체와 함께 삭제되면 해제되는 메모리를 나타내는 retained size와 달리
shallow size는 객체 자체만 보유하는 메모리 크기입니다.
Array, Map 및 Set을 사용하면 데이터를 저장할 수 있지만,
코드를 현명하게 구현하지 않으면 참조가 영원히 유지되어 누수가 발생할 수 있습니다.
다음은 사용자의 행동을 추적하는 매우 간단한 전자 상거래 앱입니다.
방문한 모든 화면, 확인된 모든 제품 및 검색에서 반환된 각 제품을 checkedItems 컬렉션에 추가한 다음
주기적으로 데이터를 서버로 보냅니다.
function(item, view, timestamp) {
checkedItems.push({item, view, timestamp})
}
메모리에서 배열 참조를 해제하는 두 가지 방법
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 내의 개체에 대한 외부 참조는 해당 요소를 메모리에 유지합니다.
다음 코드 스니펫에서 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
즉, removeEventListener()를 호출하여 정리하지 않으면
doSomething() 리스너와 해당 범위에 유지되는 모든 항목은 gc되지 않습니다.
function doSomething() {
// ...
}
document.addEventListener('keyup', doSomething, {once: true}); // the listener will be automatically removed after running once
5. 타이머
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);
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:
Detached DOM referenced:
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
결론
메모리 누수 문제를 사냥하는 것은 극단적인 엣지 케이스를 가진 복잡한 문제이며 이를 디버깅하는 것은 어려운 작업이 될 수 있습니다.
앱에서 이러한 문제를 방지하려면 이에 대한 인식과 지속적인 경계가 필요합니다.
코드를 설계하고 작성하는 방식에 따라 메모리 조각이 운영 체제에 반환되는지 여부가 결정됩니다.
참고
https://betterprogramming.pub/javascript-memory-leaks-390957523a9e
'FrontEnd' 카테고리의 다른 글
Vue Router : URL 파라미터 활용하기 (0) | 2022.12.07 |
---|---|
Vue3 딥 다이브 : Virtual DOM과 코어 Module 알아보기 (0) | 2022.12.07 |
[번역] 자바스크립트 디버깅 완벽가이드 (0) | 2022.12.04 |
[타입스크립트] Object vs object vs {} (2) | 2022.11.29 |
[번역] Rehydration(재수화)의 위험과 문제 해결하기 (0) | 2022.11.28 |