vue3-lottie 메모리 누수 해결

부루베릐·2024년 4월 28일
0

TIL

목록 보기
19/23
post-custom-banner

개요

배경

채팅 서비스를 개발하던 중에 반갑지 않은 이슈를 하나 발견하였다. 시간이 지날수록 서비스의 속도가 느려지거나 가끔 새로고침을 할 때 한참 동안이나 기다려야 로딩이 되는 문제가 있었다. 서비스의 속도가 처음에는 괜찮다가 갈수록 느려지는 것을 보았을 때 메모리 누수 문제가 아닐까 추측하고 있던 상황이었다.

브라우저의 Task Manager 사용하여 메모리 사용량 체크하기

크롬 브라우저의 오른쪽 상단 케밥 메뉴를 클릭하면 More Tools > Task Manager를 통해 브라우저의 각 탭의 메모리 사용량을 확인할 수 있다.

서비스 이곳저곳을 만지면서 메모리 사용량을 체크하고 있던 중, 다음과 같이 갑자기 자바스크립트 메모리의 값이 두 배 이상 뛰는 현상을 발견할 수 있었다.

우리 서비스의 호스트 모드는 여러 개의 채팅방이 있고, 이 채팅방 각각에 상대방이 접속하면 우리 쪽 상담사가 상대방 각각과 실시간으로 1대1 소통을 할 수 있는 서비스이다. 상대방이 채팅에 접속하지 않았을 때 로딩 애니메이션을 표시해주기 위해 로띠 애니메이션을 사용하는데, 채팅이 있는 탭과 채팅이 없는 탭 사이를 왔다갔다하면 위와 같이 자바스크립트 메모리가 갑작스럽게 증가하는 것이다.

따라서 이 로띠 파일 자체가 메모리 누수를 일으키거나, 로띠 파일을 렌더링하기 위해 사용하는 vue3-lottie라는 라이브러리가 문제의 원인일 수 있겠다는 추측을 하게 되었다.

메모리 누수 더 자세하게 측정하기

여기서부터 메모리 누수를 측정하고 문제를 해결하는 과정은 김은수 님의 Chrome DevTools로 JS 메모리 누수(Memory Leak) 디버깅하기 글을 상당 부분 참고하여 작성하였다.

이제 좀 더 자세하게 메모리 사용량을 파악하여 문제를 분석해 보자.

용어 정리

  • JS Heap
    • 자바스크립트 객체와 함수를 저장하는 구역이다. 자바스크립트 엔진의 데이터 저장 공간은 스택과 힙으로 나뉜다. 스택은 문자열, 숫자, 불리언 등 컴파일 타임에 크기가 정해진(static한) 데이터를 저장하고, 힙은 런타임에 동적으로 크기가 달라지는 함수 혹은 객체를 저장한다(참조).
  • Detached DOM
    • 화면에서는 내려갔지만 JS가 계속해서 참조하고 있는 DOM 요소를 이야기한다. JS가 계속 참조하고 있으므로 가비지 컬렉팅의 대상이 되지 않아 메모리 누수의 원인이 된다.
  • Shallow Size
    • 객체가 자신이 차지하는 메모리 크기를 의미한다.
  • Retained Size
    • 객체 자신이 차지하는 메모리 크기에 더해, 이 객체로 인해서 계속해서 살아 있는 다른 객체의 크기까지 합산한 메모리 크기를 의미한다(참조).

Devtools의 Performance Profiling 사용하기

Chrome Devtools > Performance 탭에서 Memory 옵션을 킨 채로 메모리 측정을 진행하였다. 총 4개의 채팅방 중 두 개의 채팅방을 번갈아가면서 이동하였다. 채팅방 하나는 안내 문구 로띠가 표시된 빈 채팅방이었고, 다른 하나는 사용자가 접속하여 로띠 대신 채팅이 화면에 표시된 채팅방이었다.

채팅방 전환을 20회 수행한 뒤 메모리 변화를 관찰하니 JS Heap과 Nodes, Listeners 모두 점차적으로 상승하는 것을 볼 수 있다. 이 말은 JS Heap에 할당된 메모리들이 누수되고 있고, 이벤트 리스너 역시 할당 후 해제되지 않고 있으며 detached DOM이 존재하고 있다는 뜻이다.

Memory Allocation Timeline 사용하기

JS Heap에서 메모리 누수가 발생하는지를 좀 더 자세히 확인하기 위해 Chrome Devtools > Memory > Allocation instrumentation on timeline을 선택하여 분석을 진행하였다. Allocation profiler를 사용하면 JS Heap에 메모리가 할당되고 해제되는 것을 서비스의 동작에 따라 확인할 수 있다.

서비스 내에서 이런 저런 동작들을 수행하면서 타임라인을 관찰한 결과, 다음과 같이 서비스 내 채팅방을 이동하면 파란 막대기가 튀는 현상을 볼 수 있었다. 채팅방도 다 같은 채팅방이 아니라, 상대방이 접속하지 않아서 대기 로띠가 있는 채팅방으로 이동할 때 아래의 메모리 스파크가 발생하였다.

이 파란색 막대는 힙에 할당된 새로운 객체의 메모리 크기를 의미한다. 막대의 높이가 곧 해당 객체의 메모리 크기이므로 높이가 높을수록 힙에 더 큰 메모리가 할당되었다는 뜻이다. 회색 막대는 가비지 컬렉팅이 된 메모리 크기를 의미하는데, 기본적으로 메모리가 힙에 할당되고 해제될 때 파란색 메모리 영역의 높이보다 회색 메모리 영역의 높이가 더 커야 가비지 컬렉팅이 잘 일어난 것이다. 측정 결과 파란색 막대와 회색 막대의 높이 차이가 얼마 나지 않으므로 가비지 컬렉팅이 제대로 되고 있지 않고 있다는 것을 알 수 있다.

다음과 같이 회색 막대의 높이가 파란색 막대의 높이보다 커야 한다.

하나의 파란 막대 스파크를 기준으로 구역을 선택하고 Summary에서 Retained Size를 내림차순으로 정리해 보자.

AnimationItem2라는 컨테이너가 두 번째를 차지하고 있고, 이 친구의 근원을 찾아보면 vue3-lottie 라이브러리 스크립트가 있다는 것을 알게 되었다. 이 AnimationItem2은 Shallow Size에 비해 Retained Size가 거의 2600배나 차이난다. 자기 자신의 크기에 비해 연결되어 있는 다른 객체들의 총 메모리가 너무나도 크다.

JS Heap 메모리 누수 대응하기

vue3-lottie 로직 지워보기

코드에서 vue3-lottie를 사용하는 로직을 지우고 다시 분석을 돌려보니 아래와 같이 가비지 컬렉팅이 잘 되는 모습을 확인할 수 있다. 이를 보건대, vue3-lottie에 이슈가 있는 것이 역시 분명해 보였다.


분석 결과

이를 통해 vue3-lottie를 사용하는 컴포넌트가 화면에 표시되고 사라지고 다시 등장하는 과정에서 메모리 누수가 발생한다는 결론을 내릴 수 있다. 이는 위에서 Task Manager를 사용하여 자바스크립트 메모리 크기를 확인했을 때와 같은 결론이다.

destroy 메소드 사용

vue3-lottielottie-web이라는 에어비앤비 라이브러리를 vue3 환경에 맞춤으로 쓸 수 있도록 감싼 라이브러리이다. 그러다보니 lottie-web의 동작을 따라 가는 것이 많은데, 로띠 애니메이션 인스턴스를 조작할 수 있는 여러 메소드들 역시 마찬가지다. 이 중 destroy라는 메소드를 통해 로띠 인스턴스를 삭제할 수 있다. 이 destroy 메서드를 사용해서 로띠를 사용하는 컴포넌트가 unmount될 때 로띠도 함께 삭제되도록 개선하였다.

// components/chat-waiting.vue
<template>
  <vue3-lottie ref="lottieAnimation" :animation-data="waitingLottie" :height="80" :width="80" />
  <p>접속 인원이 없습니다.</p>
</template>

<script setup lang="ts">
import { Vue3Lottie } from 'vue3-lottie'
import { onBeforeUnmount, ref } from 'vue'
import waitingLottie from '@/assets/lotties/waiting_lottie.json'

const lottieAnimation = ref(null)

onBeforeUnmount(() => {
  lottieAnimation.value?.destroy()
})
</script>

그 결과 전체 사용된 메모리 용량은 감소하였다. 총 40번 채팅방을 변경해가면서 메모리를 측정했는데, 예전에 JS Heap의 메모리가 30MB까지 올라갔던 것을 생각하면 나아지긴 했다. 하지만 채팅방을 변경하는 횟수와 시간이 지날수록 메모리 사용량이 계속 증가하는 것을 보면 아직 메모리 누수가 완벽하게 잡히지는 않았다는 것을 알 수 있다.

lottie-web으로 변경

아예 vue3-lottie의 베이스가 되는 lottie-web 라이브러리로 로띠 사용 로직을 교체해 보았다.

// components/chat-waiting.vue
<template>
  <div ref="lottieContainer" class="lottie-container" />
  <p>접속 인원이 없습니다.</p>
</template>

<script setup lang="ts">
import lottie from 'lottie-web'
import { onMounted, ref } from 'vue'
import waitingLottie from '@/assets/lotties/waiting_lottie.json'

const lottieContainer = ref(null)

onMounted(() => {
  lottie.loadAnimation({
    container: lottieContainer.value,
    renderer: 'svg',
    loop: true,
    autoplay: true,
    animationData: waitingLottie
  })
})

onUnmounted(() => {
  lottie.destroy()
})
</script>

메모리가 증가하다 일정한 폭으로 떨어지는 그래프가 형성되었다. 일정 주기로 가비지 컬렉터가 돌 때 제대로 GC를 수행하고 있는 것으로 보인다. 그래프를 보면, JS Heap뿐만 아니라 Node와 Listeners의 경우에도 개선 전의 상태와 비교했을 때 계속 증가하지 않고 일정 수준을 지키는 것을 볼 수 있다. 이는 destroy 메서드를 통해 로띠 애니메이션 인스턴스를 제때 삭제하는 것이 detached DOM과 로띠 인스턴스의 이벤트 리스너 정리에도 도움을 준 것이라 이해해도 될 것 같다.

vue3-lottie 라이브러리를 사용하는 과정에서 메모리 누수 이슈가 있던 게 아니었을까 추측해 본다. 나와 비슷한 사람들이 있을까 해서 자료를 찾아보던 중 vue3-lottielottie-web 라이브러리 자체에 메모리 누수 문제가 있다는 제보를 발견하였으나, lottie-web을 사용하였을 때는 가비지 컬렉팅이 잘 수행되는 것으로 보이고 해당 이슈에서 개발진이 테스트했을 때는 메모리 누수 이슈가 발생하지 않았던 것으로 보아 그 이상 파악하기에는 쉽지 않아 보인다.

Detached DOM 측정하기

정말 detached DOM도 GC가 되었는지 확인해보자. 다음의 사진은 Memory > Heap snapshot에서 Snapshot1(로띠가 없는 채팅방)과 Snapshot2(로띠가 있는 채팅방)의 메모리 스냅샷 Comparison 결과값이다. 채팅방에 로띠가 있을 때와 로띠가 없을 때를 Size Delta(메모리 차이) 내림차순으로 Detached DOM 요소들만 필터링하여 비교하였다. Detached DOM이 아예 생기지 않은 것은 아니지만 그 차이가 약 4kb 이하로 굉장히 작다는 것을 알 수 있으므로 신경쓰지 않아도 되겠다.

결과 비교

Heap Snapshot

개선 전 - 24.3MB

개선 후 - 14.9MB

비교

차이 -9.4MB(-38.7%)


Allocation Timelines

개선 전 - 5.6MB

개선 후 - 1.1MB (-4.5MB, -80%)

비교

차이 -4.5MB(-80%)


Performance Profiling

가비지 컬렉팅 후 맨 처음과 채팅방을 40회 변경하였을 때 마지막 저점 비교

개선 전

  • JS Heap: 6.2MB → 16.1MB = 9.9MB
  • Documents: 3 → 2 = -1
  • Nodes: 279 → 2044 = 1764
  • Listeners: 47 → 87 = 40

개선 후

  • JS Heap: 4.5MB → 5.5MB = 1MB
  • Documents: 2 → 2 = 0
  • Nodes: 213 → 413 = 200
  • Listeners: 45 → 45 = 0


결론

메모리 사용량에 있어서 대부분 많은 진전을 이뤘다. 그렇다고 맨 처음 언급했던 서비스 속도와 새로고침 로딩 이슈를 백 퍼센트 해결했다고는 볼 수 없지만, 이번 메모리 누수 해결을 시작으로 한 스텝씩 개선해 나가려 한다.


참고

post-custom-banner

0개의 댓글