Vue 컴포넌트 – 메모리 누수 분석하기

안녕하세요. LINE+ UIT 프런트엔드 개발자 임초이입니다. 최근 웹 뷰에서 메모리 누수 때문에 앱이 크래시되는 문제가 발생했습니다. 처음 접해보는 메모리 누수라 많이 헤맸지만, 원인을 찾아 문제를 해결할 수 있었는데요. 이번 글에서는 메모리 누수가 무엇인지와 메모리 누수를 해결하기 위한 접근법에 대해 이야기해 보겠습니다. 초보자 관점에서 기초부터 차근차근 접근해 보려고 합니다 🙂

  • 환경: Vue 프레임워크를 사용한 SPA(single page application) 프로젝트
  • 문제: 라우터 전환 시 특정 Vue 컴포넌트에서 메모리가 반환되지 않아 메모리 누수가 발생해 앱 크래시로 이어지는 문제

 

메모리 누수란?

프로그램이 작동하며 할당됐던 메모리가 더 이상 사용되지 않는 시점에서도 반환되지 않는 현상입니다. 정상적으로 반환되지 않은 메모리가 계속 누적되면 프로그램에 할당할 수 있는 메모리가 부족해지면서 프로그램이 비정상적으로 작동하거나 크래시가 발생할 수 있습니다. JavaScript의 경우 가비지 컬렉터가 프로그램에 할당된 메모리를 주기적으로 관리해 더 이상 사용되지 않는 메모리는 반환합니다. 따라서 개발자가 매번 메모리 반환을 신경 쓰지 않아도 됩니다. 그렇다면 ‘가비지 컬렉터가 더 이상 사용하지 않는 메모리를 알아서 반환해 주기 때문에 메모리 누수가 없어야 하는 것 아닌가?’하는 의문이 생길 수 있습니다. 하지만 JavaScript에는 더 이상 사용되지 않지만 가비지 컬렉터가 파악하지 못해 반환되지 않는 메모리가 존재합니다. 가비지 컬렉터가 파악하지 못하는 메모리 사용은 아래와 같습니다.

  • 전역 변수
  • 타이머와 콜백
  • DOM 외부의 참조
  • 클로저

이번 글에서 각 항목에 대해 자세하게 설명하지는 않겠습니다. 궁금하신 분들은 4 Types of Memory Leaks in JavaScript and How to Get Rid Of Them를 참고하시기 바랍니다.

 

메모리 누수 분석하기(with Chrome 개발자 도구)

Chrome 개발자 도구를 이용하면 메모리 할당 정보를 확인할 수 있습니다. 개발자 도구를 활용하는 방법을 알아보겠습니다.

 

도구 1: Performance 탭

Chrome 개발자 도구의 Performance 탭에서 라우터 전환 시 메모리 할당에 대한 전반적인 패턴을 다음과 같은 과정으로 확인할 수 있습니다. 

  1. 상단 메뉴의 Record 버튼을 누릅니다.
  2. 메모리 할당을 측정하기 위해 라우터 전환을 반복합니다.
  3. Stop 버튼을 눌러 측정된 패턴을 확인합니다.

JS Heap과 Nodes가 점진적인 증가세를 보인다면 메모리 누수를 의심할 수 있습니다. 중간중간 감소한 부분은 가비지 컬렉터가 메모리를 반환한 것입니다. 일반적으로 메모리에 할당된 요소는 현재 라우터의 요소와 이전 라우터의 요소, 두 가지가 있습니다. 현재 요소는 HTMLElement, 이전 요소는 Detached HTMLElement로 확인할 수 있습니다. Detached HTMLElement는 다음 라우터로 전환할 때 메모리에서 반환됩니다. 만약 현재 요소와 이전 요소가 아닌 요소가 메모리에 할당돼 있다면 이는 메모리 누수를 발생시킨다고 할 수 있습니다(HTMLElement와 Detached HTMLElement에 대해서는 다음 섹션에서 자세히 알아보겠습니다).

다음은 메모리 누수가 없는 프로젝트의 Performance 탭입니다.

가비지 컬렉터가 메모리를 반환해 JS Heap과 Nodes가 정상적으로 감소되는 것을 볼 수 있습니다.

다음은 메모리 누수가 발생한 프로젝트의 Performance 탭입니다.

가비지 컬렉터가 메모리를 반환했음에도 JS Heap과 Nodes가 점진적인 증가세를 보이고 있습니다.

 

도구 2: Memory 탭 – 힙 스냅샷 

Chrome 개발자 도구 Memory 탭의 힙 스냅샷(heap snapshots)을 이용하면 특정 라우트에서 메모리에 할당된 요소를 확인할 수 있습니다.

  1. 메모리 할당을 측정하고자 하는 라우트로 이동합니다.
  2. 상단 메뉴의 Take Heap Snapshot 버튼을 누릅니다.
    1. 만약 기존에 기록한 Profile이 없다면 Select profiling type의 Heap snapshot을 선택한 후 Take snapshot 버튼을 누릅니다.
  3. Summary의 Class filter에서엘리먼트를 검색하면 메모리에 할당된 요소를 확인할 수 있습니다.

로컬 환경에서는 Summary의 Class filter에서 Vue 컴포넌트를 검색하면 메모리에 할당된 Vue 컴포넌트를 확인할 수 있습니다. 

다음은 메모리 누수가 없는 프로젝트의 힙 스냅샷입니다.

Step 1
Step 2: Go 버튼을 클릭해 전환된 라우터
Step 3: Back 버튼을 클릭해 전환된 라우터

Step 1에서 메모리에 할당된 HTMLElement가 Step 2에서 DetachedHTMLElement로 전환됩니다. Step 3에서는 Step 1에서 할당된 메모리가 제거되고 Step 2의 HTMLElement가 DetachedHTMLElement로 전환됩니다. 가비지 컬렉터가 메모리 반환을 정상적으로 수행했다는 것을 알 수 있습니다.

다음은 메모리 누수가 발생한 프로젝트의 힙 스냅샷입니다.

Step 1
Step 2
Step 3

Step 1에서 메모리에 할당된 HTMLElement가 Step 2에서 DetachedHTMLElement로 전환됩니다. Step 3에서는 Step 1에서 할당된 메모리가 제거돼야 하지만 여전히 남아 있는 것을 확인할 수 있습니다. 가비지 컬렉터가 수집하지 않는 메모리 때문에 메모리 누수 현상이 발생했다는 것을 알 수 있습니다.

 

도구 3: Memory 탭 – 메모리 할당 타임라인

Memory 탭의 메모리 할당 타임라인으로 라우터 전환 시 메모리에 할당되는 전반적인 요소를 확인할 수 있습니다.

  1. 상단 메뉴의 Start Recording Heap Profile 버튼을 누릅니다.
    1. 만약 기존에 기록한 Profile이 없다면 Select profiling typeAllocation instrumentation on timeline을 선택한 후 Start 버튼을 누릅니다.
  2. 메모리 할당을 측정하기 위해 라우터 전환을 반복합니다.
  3. 실시간으로 할당되고 반환되는 메모리를 확인할 수 있습니다.
  4. Stop Recording Heap Profile 버튼을 누릅니다.
  5. 특정 구간을 선택한 뒤 SummaryClass filter에서 엘리먼트를 검색하면 메모리에 할당된 요소를 확인할 수 있습니다.

로컬 환경에서는 SummaryClass filter에서 Vue 컴포넌트를 검색하면 메모리에 할당된 Vue 컴포넌트를 확인할 수 있습니다.

다음은 메모리 누수가 없는 프로젝트의 Memory 탭의 메모리 할당 타임라인입니다.

  • 그래프의 파란색 영역: 반환되지 않고 할당 요소가 존재하는 메모리
  • 그래프의 회색 영역: 반환된 메모리

마지막에 메모리에 남아있는 HTMLElement와 DetachedHTMLElement를 제외하고는 가비지 컬렉터가 메모리 반환을 정상적으로 수행했다는 것을 알 수 있습니다.

다음은 메모리 누수가 발생한 프로젝트의 Memory 탭의 메모리 할당 타임라인입니다.

처음에 메모리에 할당됐던 HTMLElement가 DetachedHTMLElement로 전환돼 마지막까지 메모리에 남아있는 것을 확인할 수 있습니다. 가비지 컬렉터가 수집하지 않은 메모리 때문에 메모리 누수가 발생했다는 것을 알 수 있습니다.

 

메모리 누수 개선하기

프로젝트에 메모리 누수가 있다는 것을 확인했다면 이를 개선해야 합니다. Vue 컴포넌트는 하나의 컴포넌트가 내부적으로 다른 컴포넌트를 참조($parent, $children)하기 때문에 한 컴포넌트가 메모리에 남는 문제가 발생하면 참조 관계로 얽혀 있는 다른 컴포넌트도 메모리에 남는 문제가 발생합니다. 따라서 메모리 누수를 유발한 핵심 Vue 컴포넌트를 찾는 것이 주요 개선 방법이라고 할 수 있습니다.

<component2>가 라우터 전환에도 불구하고 메모리에서 반환되지 않으면 parent: <component1>와 child: <component4>를 참조하게 됩니다. <component1>은 child: <component3>를 참조하고 있으므로 결국 <component1>, <component2>, <component3>, <component4>가 모두 메모리에 남는 문제가 발생합니다.

그럼 메모리 누수를 유발한 핵심 Vue 컴포넌트는 어떻게 찾아야 할까요? 명확한 솔루션은 없지만, 제가 메모리 누수를 해결하기 위해 진행한 방법을 설명해 보겠습니다.

  1. 위 ‘메모리 누수 분석하기’ 과정을 통해 메모리 누수가 발생하는 라우터를 확인한다.
  2. 라우터 최상위 컴포넌트의 내부 컴포넌트를 최소화해가며 메모리에서 반환되지 않는 내부 컴포넌트를 찾는다.
  3. 메모리에서 반환되지 않는 내부 컴포넌트에서 과정 2를 반복한다.
  4. 메모리에서 반환되지 않는 특정 컴포넌트를 도출해 메모리 누수의 원인이 되는 소스 코드를 발견한다(앞서 설명한 가비지 컬렉터가 파악하지 못하는 메모리 영역).
  5. 수정한다.

이때 유의해야 할 점은 메모리에서 반환되지 않는 컴포넌트가 하나 이상일 수 있다는 점을 염두에 두어야 한다는 것입니다. 즉, 위 과정 2에서 발견되는 컴포넌트가 여러 개일 수 있다는 것입니다. 제가 해결한 메모리 누수 문제는 아래 두 가지가 원인이었습니다.

  • Vue 컴포넌트의 이벤트에 $on은 있지만 $off가 없다는 점
  • Vue 컴포넌트를 싱글톤 클래스에 저장해 두고 라우터 전환 시 제거하지 않았다는 점

 

마치며

프로젝트에 메모리 누수가 있다는 것을 어떻게 증명해야 할지, 증명한 뒤에는 어떻게 해결해야 할지 고민했던 제 경험을 바탕으로 글을 작성해 봤습니다. 메모리 누수 해결은 메모리 누수를 발생시키는 컴포넌트를 찾는 게 핵심이라고 생각합니다. 이번 글에서 설명한 방식대로 Chrome 개발자 도구를 이용하면 그 작업이 조금 더 쉽게 진행되지 않을까 합니다. 제 경험이 메모리 누수로 고민하고 계신 분들께 도움이 되길 바랍니다 🙂

참고문헌