vue3-lottie 메모리 누수 해결

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

TIL

목록 보기
12/16

개요

배경

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

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

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

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

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

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

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

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

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

Devtools의 Performance Profiling 사용하기

용어 정리

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

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를 내림차순으로 정리해 보자.

용어 정리

  • Distance
    • 루트 객체로부터 얼마나 떨어져 있는지를 나타내는 지표이다. 만약 Distance가 1이면 루트 객체가 직접 참조하고 있다는 뜻이다.
  • Shallow Size
    • 객체 자신이 차지하는 메모리 크기를 의미한다.
  • Retained Size
    • Shallow 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>

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

결과 비교

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


결론

우리 서비스는 브라우저를 종료하지 않고 계속해서 켜 놓는 경우가 많은 서비스이다. 따라서 조금의 메모리 누수도 큰 문제로 다가올 수 있다. 해결법은 간단했지만, 실제로 메모리 사용량에서도 큰 진전을 이뤘고, Performance 탭과 Memory 탭의 사용법도 익혔다. 숫자와 데이터를 통해 메모리 사용량을 측정하고 메모리 누수를 판정하는 과정을 배울 수 있었다는 것이 큰 결실이었다.


참고

0개의 댓글