채팅 서비스를 개발하던 중에 반갑지 않은 이슈를 하나 발견하였다. 시간이 지날수록 서비스의 속도가 느려지거나 가끔 새로고침을 할 때 한참 동안이나 기다려야 로딩이 되는 문제가 있었다. 서비스의 속도가 처음에는 괜찮다가 갈수록 느려지는 것을 보았을 때 메모리 누수 문제가 아닐까 추측하고 있던 상황이었다.
크롬 브라우저의 오른쪽 상단 케밥 메뉴를 클릭하면 More Tools > Task Manager를 통해 브라우저의 각 탭의 메모리 사용량을 확인할 수 있다.
서비스 이곳저곳을 만지면서 메모리 사용량을 체크하고 있던 중, 다음과 같이 갑자기 자바스크립트 메모리의 값이 두 배 이상 뛰는 현상을 발견할 수 있었다.
우리 서비스의 호스트 모드는 여러 개의 채팅방이 있고, 이 채팅방 각각에 상대방이 접속하면 우리 쪽 상담사가 상대방 각각과 실시간으로 1대1 소통을 할 수 있는 서비스이다. 상대방이 채팅에 접속하지 않았을 때 로딩 애니메이션을 표시해주기 위해 로띠 애니메이션을 사용하는데, 채팅이 있는 탭과 채팅이 없는 탭 사이를 왔다갔다하면 위와 같이 자바스크립트 메모리가 갑작스럽게 증가하는 것이다.
따라서 이 로띠 파일 자체가 메모리 누수를 일으키거나, 로띠 파일을 렌더링하기 위해 사용하는 vue3-lottie라는 라이브러리가 문제의 원인일 수 있겠다는 추측을 하게 되었다.
여기서부터 메모리 누수를 측정하고 문제를 해결하는 과정은 김은수 님의 Chrome DevTools로 JS 메모리 누수(Memory Leak) 디버깅하기 글을 상당 부분 참고하여 작성하였다.
이제 좀 더 자세하게 메모리 사용량을 파악하여 문제를 분석해 보자.
Chrome Devtools > Performance 탭에서 Memory 옵션을 킨 채로 메모리 측정을 진행하였다. 총 4개의 채팅방 중 두 개의 채팅방을 번갈아가면서 이동하였다. 채팅방 하나는 안내 문구 로띠가 표시된 빈 채팅방이었고, 다른 하나는 사용자가 접속하여 로띠 대신 채팅이 화면에 표시된 채팅방이었다.
채팅방 전환을 20회 수행한 뒤 메모리 변화를 관찰하니 JS Heap과 Nodes, Listeners 모두 점차적으로 상승하는 것을 볼 수 있다. 이 말은 JS Heap에 할당된 메모리들이 누수되고 있고, 이벤트 리스너 역시 할당 후 해제되지 않고 있으며 detached DOM이 존재하고 있다는 뜻이다.
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배나 차이난다. 자기 자신의 크기에 비해 연결되어 있는 다른 객체들의 총 메모리가 너무나도 크다.
코드에서 vue3-lottie
를 사용하는 로직을 지우고 다시 분석을 돌려보니 아래와 같이 가비지 컬렉팅이 잘 되는 모습을 확인할 수 있다. 이를 보건대, vue3-lottie
에 이슈가 있는 것이 역시 분명해 보였다.
분석 결과
이를 통해 vue3-lottie
를 사용하는 컴포넌트가 화면에 표시되고 사라지고 다시 등장하는 과정에서 메모리 누수가 발생한다는 결론을 내릴 수 있다. 이는 위에서 Task Manager를 사용하여 자바스크립트 메모리 크기를 확인했을 때와 같은 결론이다.
vue3-lottie
는 lottie-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까지 올라갔던 것을 생각하면 나아지긴 했다. 하지만 채팅방을 변경하는 횟수와 시간이 지날수록 메모리 사용량이 계속 증가하는 것을 보면 아직 메모리 누수가 완벽하게 잡히지는 않았다는 것을 알 수 있다.
아예 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-lottie
와 lottie-web
라이브러리 자체에 메모리 누수 문제가 있다는 제보를 발견하였으나, lottie-web
을 사용하였을 때는 가비지 컬렉팅이 잘 수행되는 것으로 보이고 해당 이슈에서 개발진이 테스트했을 때는 메모리 누수 이슈가 발생하지 않았던 것으로 보아 그 이상 파악하기에는 쉽지 않아 보인다.
정말 detached DOM도 GC가 되었는지 확인해보자. 다음의 사진은 Memory > Heap snapshot에서 Snapshot1(로띠가 없는 채팅방)과 Snapshot2(로띠가 있는 채팅방)의 메모리 스냅샷 Comparison 결과값이다. 채팅방에 로띠가 있을 때와 로띠가 없을 때를 Size Delta(메모리 차이) 내림차순으로 Detached DOM 요소들만 필터링하여 비교하였다. Detached DOM이 아예 생기지 않은 것은 아니지만 그 차이가 약 4kb 이하로 굉장히 작다는 것을 알 수 있으므로 신경쓰지 않아도 되겠다.
개선 전 - 24.3MB
개선 후 - 14.9MB
비교
차이 -9.4MB(-38.7%)
개선 전 - 5.6MB
개선 후 - 1.1MB (-4.5MB, -80%)
비교
차이 -4.5MB(-80%)
가비지 컬렉팅 후 맨 처음과 채팅방을 40회 변경하였을 때 마지막 저점 비교
개선 전
개선 후
메모리 사용량에 있어서 대부분 많은 진전을 이뤘다. 그렇다고 맨 처음 언급했던 서비스 속도와 새로고침 로딩 이슈를 백 퍼센트 해결했다고는 볼 수 없지만, 이번 메모리 누수 해결을 시작으로 한 스텝씩 개선해 나가려 한다.