안녕하세요. 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 탭에서 라우터 전환 시 메모리 할당에 대한 전반적인 패턴을 다음과 같은 과정으로 확인할 수 있습니다.
- 상단 메뉴의 Record 버튼을 누릅니다.
- 메모리 할당을 측정하기 위해 라우터 전환을 반복합니다.
- 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)을 이용하면 특정 라우트에서 메모리에 할당된 요소를 확인할 수 있습니다.
- 메모리 할당을 측정하고자 하는 라우트로 이동합니다.
- 상단 메뉴의 Take Heap Snapshot 버튼을 누릅니다.
- 만약 기존에 기록한 Profile이 없다면 Select profiling type의 Heap snapshot을 선택한 후 Take snapshot 버튼을 누릅니다.
- 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 탭의 메모리 할당 타임라인으로 라우터 전환 시 메모리에 할당되는 전반적인 요소를 확인할 수 있습니다.
- 상단 메뉴의 Start Recording Heap Profile 버튼을 누릅니다.
- 만약 기존에 기록한 Profile이 없다면 Select profiling type의 Allocation instrumentation on timeline을 선택한 후 Start 버튼을 누릅니다.
- 메모리 할당을 측정하기 위해 라우터 전환을 반복합니다.
- 실시간으로 할당되고 반환되는 메모리를 확인할 수 있습니다.
- Stop Recording Heap Profile 버튼을 누릅니다.
- 특정 구간을 선택한 뒤 Summary의 Class filter에서 엘리먼트를 검색하면 메모리에 할당된 요소를 확인할 수 있습니다.
로컬 환경에서는 Summary의 Class 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 컴포넌트는 어떻게 찾아야 할까요? 명확한 솔루션은 없지만, 제가 메모리 누수를 해결하기 위해 진행한 방법을 설명해 보겠습니다.
- 위 '메모리 누수 분석하기' 과정을 통해 메모리 누수가 발생하는 라우터를 확인한다.
- 라우터 최상위 컴포넌트의 내부 컴포넌트를 최소화해가며 메모리에서 반환되지 않는 내부 컴포넌트를 찾는다.
- 메모리에서 반환되지 않는 내부 컴포넌트에서 과정 2를 반복한다.
- 메모리에서 반환되지 않는 특정 컴포넌트를 도출해 메모리 누수의 원인이 되는 소스 코드를 발견한다(앞서 설명한 가비지 컬렉터가 파악하지 못하는 메모리 영역).
- 수정한다.
이때 유의해야 할 점은 메모리에서 반환되지 않는 컴포넌트가 하나 이상일 수 있다는 점을 염두에 두어야 한다는 것입니다. 즉, 위 과정 2에서 발견되는 컴포넌트가 여러 개일 수 있다는 것입니다. 제가 해결한 메모리 누수 문제는 아래 두 가지가 원인이었습니다.
- Vue 컴포넌트의 이벤트에
$on
은 있지만$off
가 없다는 점 - Vue 컴포넌트를 싱글톤 클래스에 저장해 두고 라우터 전환 시 제거하지 않았다는 점
마치며
프로젝트에 메모리 누수가 있다는 것을 어떻게 증명해야 할지, 증명한 뒤에는 어떻게 해결해야 할지 고민했던 제 경험을 바탕으로 글을 작성해 봤습니다. 메모리 누수 해결은 메모리 누수를 발생시키는 컴포넌트를 찾는 게 핵심이라고 생각합니다. 이번 글에서 설명한 방식대로 Chrome 개발자 도구를 이용하면 그 작업이 조금 더 쉽게 진행되지 않을까 합니다. 제 경험이 메모리 누수로 고민하고 계신 분들께 도움이 되길 바랍니다 :)
참고문헌