Gmail 규모의 효과적인 메모리 관리

John McCutchan
John McCutchan
Loreena Lee
Loreena Lee

소개

JavaScript는 자동 메모리 관리를 위해 가비지 컬렉션을 사용하지만 애플리케이션의 효과적인 메모리 관리를 대체할 수는 없습니다. JavaScript 애플리케이션은 네이티브 애플리케이션과 동일한 메모리 관련 문제(예: 메모리 누수 및 팽창)를 겪고 있지만 가비지 컬렉션 일시중지도 처리해야 합니다. Gmail과 같은 대규모 애플리케이션에서는 작은 애플리케이션에서도 동일한 문제가 발생합니다. Gmail팀이 Chrome DevTools를 사용하여 메모리 문제를 식별, 격리 및 해결한 방법을 알아보세요.

Google I/O 2013 세션

이 자료는 Google I/O 2013에서 발표한 바 있습니다. 아래 동영상을 확인해 보세요.

Gmail에 문제가 있습니다...

Gmail팀에 심각한 문제가 발생했습니다. 리소스가 제한된 노트북과 데스크톱에서 수 기가바이트의 메모리를 소비하는 Gmail 탭의 일화가 점점 더 자주 들리고 종종 브라우저 전체를 다운시킬 수도 있습니다. CPU가 100%에 고정되고 앱이 응답하지 않으며 Chrome의 슬픈 탭 ('종료되었군요')에 관한 이야기 팀은 문제를 해결하는 것은 물론 문제 진단을 시작하는 방법조차 모르고 있었습니다. 그들은 문제가 얼마나 광범위한지 알지 못했으며 사용 가능한 툴은 대규모 애플리케이션으로 확장되지 않았습니다. Chrome 팀은 Chrome팀과 힘을 합쳐 메모리 문제를 분류하고 기존 도구를 개선하며 현장의 메모리 데이터 수집을 가능하게 하는 새로운 기술을 개발했습니다. 하지만 도구를 사용하기 전에 자바스크립트 메모리 관리의 기본사항을 살펴보겠습니다.

메모리 관리 기본사항

JavaScript로 메모리를 효과적으로 관리하려면 기본사항을 이해해야 합니다. 이 섹션에서는 원시 유형, 객체 그래프를 다루고, 일반적인 메모리 팽창과 JavaScript의 메모리 누수에 관한 정의를 제공합니다. JavaScript의 메모리는 그래프로 개념화될 수 있으며, 이 그래프 이론이 JavaScript 메모리 관리 및 힙 프로파일러의 역할을 합니다.

기본 유형

자바스크립트에는 세 가지 프리미티브 유형이 있습니다.

  1. 숫자 (예: 4, 3.14159)
  2. 불리언(true 또는 false)
  3. 문자열 ('Hello World')

이러한 기본 유형은 다른 값을 참조할 수 없습니다. 객체 그래프에서 이러한 값은 항상 리프 또는 종결 노드입니다. 즉, 바깥쪽 가장자리가 없습니다.

컨테이너 유형은 객체 하나뿐입니다. 자바스크립트에서 객체는 연관 배열입니다. 비어 있지 않은 객체는 다른 값 (노드)으로 가장자리가 나가는 내부 노드입니다.

배열은 어떻게 하나요?

JavaScript의 배열은 실제로 숫자 키가 있는 객체입니다. JavaScript 런타임이 배열과 유사한 객체를 최적화하고 내부적으로 배열로 표현하기 때문에 이는 단순화된 것입니다.

용어

  1. 값 - 원시 유형, 객체, 배열 등의 인스턴스입니다.
  2. 변수: 값을 참조하는 이름입니다.
  3. 속성 - 값을 참조하는 객체의 이름입니다.

객체 그래프

자바스크립트의 모든 값은 객체 그래프의 일부입니다. 그래프는 루트(예: window 객체)로 시작합니다. GC 루트의 전체 기간은 개발자가 제어할 수 없습니다. 브라우저에서 생성되고 페이지가 언로드되면 소멸되기 때문입니다. 전역 변수는 실제로 창의 속성입니다.

객체 그래프

언제 값이 쓰레기가 되는가?

루트에서 해당 값으로의 경로가 없으면 값이 가비지가 됩니다. 즉, 루트부터 시작하여 스택 프레임에 있는 모든 객체 속성과 변수를 철저히 검색하면 값에 도달할 수 없으며 가비지가 됩니다.

가비지 그래프

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

자바스크립트의 메모리 누수는 페이지의 DOM 트리에서 연결할 수 없지만 여전히 자바스크립트 객체에서 참조하는 DOM 노드가 있을 때 가장 흔히 발생합니다. 최신 브라우저에서 의도치 않게 누수가 발생하는 일은 점점 더 어려워지고 있지만, 여전히 생각보다 쉽게 일어날 수 있습니다. 다음과 같이 DOM 트리에 요소를 추가한다고 가정해 보겠습니다.

email.message = document.createElement("div");
displayList.appendChild(email.message);

그런 다음 표시 목록에서 요소를 삭제합니다.

displayList.removeAllChildren();

email가 존재하는 한 메시지에서 참조하는 DOM 요소는 현재 페이지의 DOM 트리에서 분리되어 있더라도 삭제되지 않습니다.

블로트(Bloat)란 무엇인가요?

최적의 페이지 속도를 위해 필요한 것보다 많은 메모리를 사용하면 페이지가 팽창됩니다. 간접적으로는 메모리 누수도 팽창을 유발하지만 이는 의도적으로 설계된 것은 아닙니다. 크기 제한이 없는 애플리케이션 캐시는 메모리 팽창의 일반적인 원인입니다. 또한 호스트 데이터(예: 이미지에서 로드된 픽셀 데이터)로 인해 페이지가 팽창될 수 있습니다.

가비지 컬렉션이란?

가비지 컬렉션은 JavaScript에서 메모리를 회수하는 방법입니다. 그러면 브라우저에서 결정하게 됩니다. 수집 중에는 GC 루트에서 시작되는 객체 그래프를 순회하여 실시간 값을 검색하는 동안 페이지의 모든 스크립트 실행이 중단됩니다. 연결할 수 없는 모든 값은 가비지로 분류됩니다. 가비지 값의 메모리는 메모리 관리자에 의해 회수됩니다.

V8 가비지 컬렉터 자세히 알아보기

가비지 컬렉션이 발생하는 방식을 더 자세히 이해하기 위해 V8 가비지 컬렉터를 자세히 살펴보겠습니다. V8은 세대 기반 수집기를 사용합니다. 기억은 젊은 세대와 노인의 두 세대로 나뉩니다. 젊은 세대는 할당과 수집이 빠르고 빈번하게 이루어집니다. 이전 세대 내에서 할당 및 수집은 더 느리고 빈도도 낮습니다.

세대 수집기

V8은 2세대 수집기를 사용합니다. 값의 기간은 값이 할당된 이후 할당된 바이트 수로 정의됩니다. 실제로 어떤 가치의 연식은 대개 그 값이 유지된 젊은 세대 컬렉션의 수로 근사됩니다. 값이 충분히 오래되면 이전 세대에 적용됩니다.

실제로 새로 할당된 값은 오래 지속되지 않습니다. Smalltalk 프로그램에 대한 연구에 따르면, 젊은 세대의 컬렉션이 수집된 후에는 값의 7% 만 생존하는 것으로 나타났습니다. 런타임에 대한 유사한 연구에 따르면 평균적으로 새로 할당된 값의 90~70%가 이전 세대에 적용되지 않습니다.

젊은 세대

V8의 Younggeneration 힙은 from 및 to라는 두 개의 공간으로 분할됩니다. 메모리가 공간에서 할당됩니다. 할당은 매우 빠르게 진행됩니다. 즉, to 공간이 가득 차면 새로운 세대의 컬렉션이 트리거됩니다. 새로운 세대 컬렉션은 먼저 이전 세대와 공간을 전환하고, 이전 공간에서 공간으로 (지금의 공간)을 스캔하고, 모든 실시간 값을 공간에 복사하거나 이전 세대로 유지합니다. 일반적인 새로운 세대의 수집에는 약 10밀리초가 소요됩니다.

직관적으로 볼 때, 애플리케이션이 수행하는 각 할당으로 인해 공간이 더 많이 소진되고 GC 일시 중지가 발생한다는 점을 이해해야 합니다. 게임 개발자는 16ms 프레임 시간 (초당 60프레임을 달성하는 데 필요)을 보장하려면 애플리케이션을 0으로 할당해야 합니다. 단일 세대의 컬렉션이 프레임 시간의 대부분을 차지하기 때문입니다.

신세대 힙

이전 세대

V8의 이전 힙은 수집을 위해 마크 컴팩트 알고리즘을 사용합니다. 이전 세대 할당은 값이 새로운 세대에서 이전 세대로 사용될 때마다 발생합니다. 이전 세대 컬렉션이 발생할 때마다 젊은 세대의 컬렉션도 실행됩니다. 애플리케이션은 초 단위로 일시중지됩니다. 이전 세대 컬렉션이 드물기 때문에 실제로는 허용됩니다.

V8 GC 요약

가비지 컬렉션을 통한 자동 메모리 관리는 개발자의 생산성에는 좋지만 값을 할당할 때마다 가비지 컬렉션 일시중지에 점점 가까워집니다. 가비지 컬렉션 일시중지는 버벅거림을 유발하여 애플리케이션의 분위기를 떨어뜨릴 수 있습니다. JavaScript가 메모리를 관리하는 방법을 이해했으니 이제 애플리케이션에 적합한 선택을 할 수 있습니다.

Gmail 문제 해결

지난 한 해 동안 수많은 기능과 버그 수정이 Chrome DevTools에 적용되어 그 어느 때보다 강력한 기능을 구현했습니다. 또한 브라우저 자체에서 Performance.memory API를 주요하게 변경하여 Gmail 및 기타 모든 애플리케이션이 필드에서 메모리 통계를 수집할 수 있게 했습니다. 이 멋진 도구들로 무장하면 불가능했던 일처럼 여겨졌던 작업이 곧 범인을 추적하는 흥미진진한 게임이 되었습니다.

도구 및 기술

Field Data 및 performance.memory API

Chrome 22부터 performance.memory API가 기본적으로 사용 설정됩니다. Gmail과 같이 오랫동안 실행되는 애플리케이션의 경우 실제 사용자의 데이터는 매우 중요합니다. 이 정보를 통해 하루에 8~16시간을 Gmail을 사용하고 하루에 수백 통의 메일을 수신하는 고급 사용자와 Gmail을 사용하는 평균적인 사용자, 일주일에 10여 개 정도의 메일을 수신하는 고급 사용자를 구분할 수 있습니다.

이 API는 다음 세 가지 데이터를 반환합니다.

  1. jsHeapSizeLimit - 자바스크립트 힙이 제한된 메모리 양 (바이트)입니다.
  2. totalJSHeapSize - 자바스크립트 힙이 할당한 메모리 양(바이트)(여유 공간 포함).
  3. usedJSHeapSize - 현재 사용 중인 메모리 양 (바이트)입니다.

한 가지 유의해야 할 점은 API가 전체 Chrome 프로세스에 대한 메모리 값을 반환한다는 것입니다. Chrome이 기본 모드는 아니지만 특정 상황에서 Chrome이 동일한 렌더기 프로세스에서 여러 탭을 열 수 있습니다. 즉,performance.memory에서 반환하는 값에는 앱이 포함된 탭 외에도 다른 브라우저 탭의 메모리 공간이 포함될 수 있습니다.

규모에 맞게 메모리 측정

Gmail은 JavaScript를 통해 Performance.memory API를 사용하여 약 30분마다 메모리 정보를 수집했습니다. 많은 Gmail 사용자가 앱을 한 번에 며칠 동안 사용하지 않기 때문에 팀에서는 시간 경과에 따른 메모리 증가와 전반적인 메모리 사용량 통계도 추적할 수 있었습니다. 팀은 무작위 사용자 표본에서 메모리 정보를 수집하도록 Gmail을 계측한 지 며칠 만에 평균 사용자 사이에서 메모리 문제가 얼마나 광범위한지 파악하기에 충분한 데이터를 얻었습니다. 또한 기준을 설정하고 수신되는 데이터 스트림을 사용하여 메모리 소비를 줄이는 목표에 대한 진행 상황을 추적했습니다. 결국 이 데이터는 메모리 회귀를 포착하는 데도 사용됩니다.

필드 측정을 통해 추적 목적 외에도 메모리 공간과 애플리케이션 성능 간의 상관관계에 대한 유용한 정보를 얻을 수 있습니다. Gmail팀은 '메모리를 많이 사용할수록 성능이 좋아진다'는 믿음과 달리, 메모리 공간이 클수록 일반적인 Gmail 작업의 지연 시간이 길어진다는 점을 발견했습니다. 이들은 이 사실을 깨달았고, 자신들의 기억을 되찾고자 하는 의욕이 그 어느 때보다 커졌습니다.

규모에 맞게 메모리 측정

DevTools 타임라인으로 메모리 문제 식별

성능 문제를 해결하는 첫 번째 단계는 문제가 있음을 증명하고, 재현 가능한 테스트를 만들고, 문제에 대한 기준 측정을 수행하는 것입니다. 재현 가능한 프로그램이 없으면 문제를 안정적으로 측정할 수 없습니다. 기준 측정이 없으면 실적을 얼마나 향상했는지 알 수 없습니다.

DevTools Timeline 패널은 문제가 있음을 증명하기에 가장 적합한 도구입니다. 웹 앱 또는 페이지를 로드하고 상호작용할 때 어디에 시간이 소비되는지에 대한 전체적인 개요를 제공합니다. 리소스 로드부터 JavaScript 파싱, 스타일 계산, 가비지 컬렉션 일시중지, 다시 그리기에 이르는 모든 이벤트가 타임라인에 표시됩니다. 메모리 문제를 조사할 수 있도록 타임라인 패널에는 할당된 총 메모리, DOM 노드 수, 창 객체 수, 할당된 이벤트 리스너 수를 추적하는 메모리 모드도 있습니다.

문제가 있음을 증명

메모리 누수가 의심되는 작업의 시퀀스를 식별하여 시작하세요. 타임라인 기록을 시작하고 작업 순서를 실행합니다. 하단의 휴지통 버튼을 사용하여 전체 가비지 컬렉션을 강제합니다. 몇 번 반복한 후 톱니형 모양의 그래프가 표시되면 곧 사용할 수 있는 많은 객체를 할당한 것입니다. 그러나 작업 시퀀스로 인해 유지된 메모리가 발생하지 않을 것으로 예상되고 DOM 노드 수가 시작한 기준으로 다시 감소하지 않을 경우 누수가 있다고 의심할 만한 합당한 이유가 있습니다.

톱니 모양 그래프

문제가 있음을 확인하고 나면 DevTools 힙 프로파일러에서 문제의 원인을 식별하는 데 도움을 받을 수 있습니다.

DevTools 힙 프로파일러로 메모리 누수 찾기

Profiler 패널은 CPU 프로파일러와 힙 프로파일러를 모두 제공합니다. 힙 프로파일링은 객체 그래프의 스냅샷을 만드는 방식으로 작동합니다. 스냅샷이 생성되기 전에 젊은 세대와 노인 세대가 모두 가비지를 수집합니다. 즉, 스냅샷이 생성되었을 때 활성 상태였던 값만 표시됩니다.

힙 프로파일러에는 이 문서에서 다루지 못하는 기능이 너무 많지만 자세한 문서는 Chrome 개발자 사이트에서 확인할 수 있습니다. 여기서는 힙 할당 프로파일러를 중점적으로 살펴보겠습니다.

힙 할당 프로파일러 사용

힙 할당 프로파일러는 힙 프로파일러의 자세한 스냅샷 정보를 타임라인 패널의 증분 업데이트 및 추적과 결합합니다. 프로필 패널을 열고 Record Heap Allocations 프로필을 시작하고 일련의 작업을 수행한 다음 분석을 위한 기록을 중지합니다. 할당 프로파일러는 기록 기간 동안 힙 스냅샷을 주기적으로 (최대 50ms마다) 촬영하고 기록이 끝날 때 최종 스냅샷을 하나 찍습니다.

힙 할당 프로파일러

상단의 막대는 힙에서 새 객체가 발견된 시점을 나타냅니다. 각 막대의 높이는 최근에 할당된 객체의 크기에 해당하며, 막대의 색상은 해당 객체가 최종 힙 스냅샷에 아직 남아 있는지 여부를 나타냅니다. 파란색 막대는 타임라인 끝에 아직 활성 상태인 객체를 나타내고, 회색 막대는 타임라인 중에 할당되었지만 지금은 가비지로 수집된 객체를 나타냅니다.

위의 예에서는 작업이 10회 수행되었습니다. 샘플 프로그램은 다섯 개의 객체를 캐시하므로 마지막 다섯 개의 파란색 막대가 예상됩니다. 하지만 가장 왼쪽의 파란색 막대는 잠재적인 문제를 나타냅니다. 그런 다음 위 타임라인의 슬라이더를 사용하여 특정 스냅샷을 확대하고 해당 시점에 최근 할당된 객체를 확인할 수 있습니다. 힙에서 특정 객체를 클릭하면 힙 스냅샷 하단 부분에 해당 객체의 보존 트리가 표시됩니다. 객체에 대한 보존 경로를 검사하면 객체가 수집되지 않은 이유를 이해하기에 충분한 정보를 얻을 수 있으며 필요한 코드를 변경하여 불필요한 참조를 삭제할 수 있습니다.

Gmail의 메모리 위기 해결

Gmail 팀은 위에서 설명한 도구와 기술을 사용하여 몇 가지 버그 카테고리를 식별할 수 있었습니다. 즉, 제한되지 않은 캐시, 실제로 발생하지 않는 무언가를 기다리는 콜백 배열이 무한히 늘어나는 현상, 이벤트 리스너가 의도치 않게 대상을 유지하는 이벤트 리스너가 있습니다. 이러한 문제를 해결함으로써 Gmail의 전체 메모리 사용량이 크게 감소했습니다. 99% 의 사용자는 이전보다 80% 적은 메모리를 사용했으며 중앙값 사용자의 메모리 사용량은 거의 50% 감소했습니다.

Gmail 메모리 사용량

Gmail은 메모리를 적게 사용했기 때문에 GC 일시중지 지연 시간이 줄어 전반적인 사용자 환경이 향상되었습니다.

또한 Gmail 팀은 메모리 사용량에 대한 통계를 수집하여 Chrome 내부에서 가비지 컬렉션 회귀를 발견할 수 있었습니다. 구체적으로, Gmail의 메모리 데이터에서 할당된 총 메모리와 라이브 메모리 사이의 차이가 극적으로 증가하면서 두 가지 단편화 버그가 발견되었습니다.

액션 유도하기

스스로 다음 질문을 던져보세요.

  1. 앱이 메모리를 얼마나 사용하고 있나요? 일반적인 믿음과는 달리 메모리를 너무 많이 사용하면 전반적인 애플리케이션 성능에 순 부정적인 영향을 미칠 수 있습니다. 정확한 숫자를 파악하기는 어렵지만 페이지에서 사용 중인 추가 캐싱이 성능에 상당한 영향을 미치는지 확인해야 합니다.
  2. 내 페이지 유출은 무료인가요? 페이지에서 메모리 누수가 발생할 경우 페이지 성능뿐만 아니라 다른 탭에도 영향을 줄 수 있습니다. 객체 추적기를 사용하여 누수의 범위를 좁히세요.
  3. 내 페이지의 GC는 얼마나 자주 이루어지나요? Chrome 개발자 도구타임라인 패널에서 GC 일시중지를 확인할 수 있습니다. 페이지가 GC를 자주 하는 경우, 너무 자주 할당하여 새로운 세대의 메모리를 낭비하고 있을 가능성이 높습니다.

결론

위기 상황에서 시작했습니다. 특히 JavaScript 및 V8에서 메모리 관리의 핵심 기본사항을 다루었습니다. 또한 Chrome의 최신 빌드에서 사용할 수 있는 새로운 객체 추적기 기능을 비롯한 도구의 사용 방법을 배웠습니다. Gmail팀은 이러한 지식을 바탕으로 메모리 사용량 문제를 해결했고 성능이 개선된 것을 확인했습니다. 웹 앱에서도 동일한 작업을 수행할 수 있습니다.