Skip to content

Chapter 13. 10,000개 댓글 렌더링 최적화와 메모리 누수 방지 ​

"12장까지 우아하게 완성된 게시판에 10,000개의 댓글이 달리는 순간, 브라우저는 얼어붙었다. 진짜 성능 최적화는 렌더링하지 않아도 될 9,980개의 노드를 DOM에서 지우는 것에서 시작된다."


13.1 10,000개 댓글과 브라우저의 한계 ​

사내 인기 기술 글에 사우들의 댓글이 10,000개 넘게 달렸습니다.
서버에서 데이터를 받아와 6장의 CommentItem.vue를 일반적인 루프로 렌더링하는 순간:

  1. 브라우저가 수초 동안 완전히 멈추고(Freezing), "페이지가 응답하지 않습니다" 경고가 뜹니다.
  2. 스크롤을 내릴 때마다 10fps 이하로 뚝뚝 끊기는 프레임 드랍(Jank)이 발생합니다.
  3. 메모리가 수백 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장까지 빌드업해 온 이 모든 아키텍처에 자동화된 테스트 방어막을 치고 최종 완성본을 배포해 보겠습니다!

Moon HomeLab Engineering Knowledge Hub