다크 모드
Chapter 13. 10,000개 댓글 렌더링 최적화와 메모리 누수 방지
"12장까지 우아하게 완성된 게시판에 10,000개의 댓글이 달리는 순간, 브라우저는 얼어붙었다. 진짜 성능 최적화는 렌더링하지 않아도 될 9,980개의 노드를 DOM에서 지우는 것에서 시작된다."
13.1 10,000개 댓글과 브라우저의 한계
사내 인기 기술 글에 사우들의 댓글이 10,000개 넘게 달렸습니다.
서버에서 데이터를 받아와 6장의 CommentItem.vue를 일반적인 루프로 렌더링하는 순간:
- 브라우저가 수초 동안 완전히 멈추고(Freezing), "페이지가 응답하지 않습니다" 경고가 뜹니다.
- 스크롤을 내릴 때마다 10fps 이하로 뚝뚝 끊기는 프레임 드랍(Jank)이 발생합니다.
- 메모리가 수백 MB로 치솟습니다.
원인은 두 가지입니다:
- 깊은 Proxy 오버헤드:
ref에 10,000개의 중첩 객체를 넣으면 모든 속성에 Proxy를 씌우느라 CPU가 혹사당합니다. - 물리적 DOM 트리의 한계: 보이지도 않는 9,980개의 노드까지 브라우저가 레이아웃(Reflow)을 계산하기 때문입니다.
13.2 최적화 1: shallowRef로 깊은 반응형 오버헤드 삭감
우리가 대량의 댓글을 다룰 때, 각 댓글의 내부 속성을 개별적으로 변경할 일은 거의 없습니다. 대부분 "목록 전체를 통째로 교체"합니다.
이때 일반 ref 대신 **shallowRef**를 쓰면 메모리 오버헤드를 90% 이상 날려버릴 수 있습니다:
typescript
import { shallowRef } from 'vue';
// ✅ shallowRef: 10,000개 객체의 내부 속성을 재귀 감시하지 않고, 오직 최상위 .value 교체만 감시!
const commentsShallow = shallowRef<Comment[]>(hugeList);
// 갱신 시에는 최상위 배열 참조 교체
commentsShallow.value = [...commentsShallow.value, newComment];13.3 최적화 2: 60줄로 직접 만드는 초경량 VirtualList.vue
인간의 눈은 모니터 화면 크기(보통 15~20개 항목)밖에 보지 못합니다.
**가상 스크롤(Virtual Scroll)**의 핵심 아이디어:
"DOM에는 오직 화면에 보이는 20개 노드만 렌더링하고,
보이지 않는 영역은 투명한 상/하단 여백(Padding)으로 대체하여 브라우저 스크롤바 높이만 완벽히 속인다!"
[코드 13-1] src/components/common/VirtualList.vue
html
<template>
<div
class="virtual-scroll-container"
:style="{ height: `${containerHeight}px` }"
@scroll.passive="handleScroll"
>
<div :style="phantomStyle">
<!-- 실제로 뷰포트에 걸쳐있는 소수의 아이템만 렌더링 -->
<div
v-for="item in visibleItems"
:key="item.id"
class="virtual-item-wrapper"
:style="{ height: `${itemHeight}px` }"
>
<slot :item="item"></slot>
</div>
</div>
</div>
</template>
<script setup lang="ts" generic="T extends { id: number | string }">
import { ref, computed } from 'vue';
interface Props<T> {
items: T[];
itemHeight: number; // 행당 높이 (px)
containerHeight: number; // 뷰포트 높이 (px)
buffer?: number; // 여유 버퍼 개수
}
const props = withDefaults(defineProps<Props<T>>(), { buffer: 5 });
const scrollTop = ref(0);
function handleScroll(e: Event) {
scrollTop.value = (e.target as HTMLElement).scrollTop;
}
const startIndex = computed(() => {
return Math.max(0, Math.floor(scrollTop.value / props.itemHeight) - props.buffer);
});
const endIndex = computed(() => {
const visibleCount = Math.ceil(props.containerHeight / props.itemHeight);
return Math.min(props.items.length, startIndex.value + visibleCount + (props.buffer * 2));
});
const visibleItems = computed(() => props.items.slice(startIndex.value, endIndex.value));
const phantomStyle = computed(() => ({
paddingTop: `${startIndex.value * props.itemHeight}px`,
paddingBottom: `${(props.items.length - endIndex.value) * props.itemHeight}px`,
boxSizing: 'border-box' as const
}));
</script>
<style scoped>
.virtual-scroll-container { overflow-y: auto; position: relative; border: 1px solid #dee2e6; }
.virtual-item-wrapper { box-sizing: border-box; }
</style>13.4 우리 게시판 댓글 목록에 가상 스크롤 장착하기
상세 화면(PostDetailView.vue)에 이 가상 스크롤 컴포넌트를 장착합니다:
html
<VirtualList
:items="tenThousandComments"
:itemHeight="60"
:containerHeight="500"
>
<template #default="{ item }">
<div class="comment-row">
<strong>{{ item.author }}</strong>: {{ item.content }}
</div>
</template>
</VirtualList>이제 댓글이 10,000개가 달려도 브라우저 메모리에는 항상 25개의 DOM 노드만 상주하며, 스크롤은 60fps로 실크처럼 부드럽게 미끄러집니다.
13.5 다음 챕터로 이어지는 질문
이제 우리의 게시판은 jQuery 레거시 시절과는 비교도 할 수 없을 만큼 빠르고, 우아하며, 견고해졌습니다.
하지만 이 거대한 코드를 사내 운영 환경에 배포하기 전, 마지막 관문이 남아있습니다:
"우리가 리팩터링한 usePagination, usePostStore, 그리고 컴포넌트들이 실제로 의도대로 동작하는지 매번 손으로 클릭해보며 검증해야 할까요? 백엔드 서버가 꺼져 있어도 프론트엔드 비즈니스 로직을 자동으로 검증할 수는 없을까요?"
마지막 Chapter 14에서는 Vitest와 **MSW(Mock Service Worker)**를 도입하여, 우리가 1장부터 13장까지 빌드업해 온 이 모든 아키텍처에 자동화된 테스트 방어막을 치고 최종 완성본을 배포해 보겠습니다!