dev.syw

불필요한 렌더를 줄이고 번들·런타임 성능을 끌어올리는 실전 최적화 기법.

성능 측정과 최적화

Vue 애플리케이션이 규모를 키워 갈수록 "동작하는 코드"와 "빠른 코드" 사이의 간극이 점점 벌어집니다. 입문 과정에서 computedwatch의 동작 방식을 익혔다면, 이번 강에서는 그 지식을 발판 삼아 렌더링 병목을 진단하고, 컴포넌트 분리·지연 로딩·가상 스크롤·캐싱 전략을 조합하여 사용자가 실감할 수 있는 성능 향상을 이끌어내는 실전 기법을 다룹니다.

최적화는 추측이 아니라 측정에서 시작합니다. 먼저 병목을 정확히 찾고, 그런 다음 올바른 도구로 개입하는 순서를 철저히 따르겠습니다.

학습 목표

  • Vue DevTools와 브라우저 Performance 패널로 렌더 병목을 정량적으로 찾아낼 수 있다.
  • v-once·v-memo·컴포넌트 분리로 불필요한 리렌더를 제거하는 원리를 이해한다.
  • computed 캐싱watch 최적화, 그리고 가상 스크롤로 대용량 리스트를 처리한다.
  • defineAsyncComponent라우트 기반 코드 스플리팅으로 초기 번들 크기를 줄인다.
  • Suspense·KeepAlive의 동작 원리와 메모리 트레이드오프를 설명할 수 있다.

렌더 병목 찾기 — Vue DevTools & Performance 패널

최적화의 첫 걸음은 어디가 느린지를 데이터로 증명하는 것입니다.

Vue DevTools — 컴포넌트 타임라인

Vue DevTools(브라우저 확장 또는 Vite 플러그인)의 Timeline 탭을 열면 각 컴포넌트가 언제, 몇 번, 얼마나 오래 렌더됐는지 ms 단위로 확인할 수 있습니다.

확인 포인트:

지표의심 기준
Render duration단일 컴포넌트 > 16 ms
Patch duration패치 횟수가 이벤트 횟수보다 많음
Re-render count부모 업데이트 시 자식까지 연쇄 렌더

💡 TIP DevTools의 Components 탭 → 컴포넌트 선택 → "Highlight updates" 체크박스를 켜면 화면에서 실제로 리렌더되는 영역이 시각적으로 깜박입니다. 코드를 보기 전에 먼저 이것으로 범위를 좁히세요.

Chrome Performance 패널 — 롱 태스크 추적

Vue DevTools만으로는 자바스크립트 실행 시간의 세부 스택을 알기 어렵습니다. Chrome DevTools의 Performance 탭에서 프로파일을 기록하면 다음을 확인할 수 있습니다.

  • patchprocessComponentrenderComponentRoot 호출 스택
  • 50 ms를 넘는 Long Task (빨간 삼각형)
  • GC(가비지 컬렉션) 빈도 및 지속 시간
# Vite 개발 서버에서 sourcemap 포함 빌드(프로파일링 전용)
vite build --mode development

프로파일 기록 절차:

  1. DevTools → Performance → Record 시작
  2. 느린 인터랙션 재현
  3. Record 중지 후 "Bottom-Up" 탭에서 patch·trigger 함수 비중 확인

v-once·v-memo로 리렌더 억제하기

Vue의 반응형 시스템은 의존성을 자동으로 추적하기 때문에 편리하지만, 정적인 콘텐츠도 추적 대상에 포함되면 낭비가 발생합니다.

v-once — 완전 정적 노드

v-once를 붙이면 해당 노드는 초기 렌더 한 번만 수행되고, 이후 반응형 업데이트에서 완전히 제외됩니다. VNode 자체를 캐시하므로 패치 비용이 0에 가까워집니다.

<template>
  <!-- ✅ 로고·법적고지 등 절대 바뀌지 않는 콘텐츠 -->
  <AppLogo v-once />
  <LegalNotice v-once />

  <!-- 동적인 부분은 v-once 없이 -->
  <UserDashboard :user="currentUser" />
</template>

⚠️ 주의 v-once는 하위 트리 전체를 동결합니다. 자식 컴포넌트 내부에 반응형 데이터가 있어도 업데이트되지 않으니, 진짜 정적인 구간에만 사용하세요.

v-memo — 조건부 메모이제이션

v-memo는 지정한 의존성 배열이 변경될 때만 해당 서브 트리를 리렌더합니다. v-for와 조합하면 특히 강력합니다.

<template>
  <ul>
    <!-- ✅ item.id와 isSelected(item.id)가 둘 다 같으면 패치를 건너뜀 -->
    <li
      v-for="item in items"
      :key="item.id"
      v-memo="[item.id, isSelected(item.id)]"
    >
      <ItemCard :item="item" :selected="isSelected(item.id)" />
    </li>
  </ul>
</template>
<!-- ❌ 잘못된 사용: 매번 바뀌는 값을 의존성으로 지정 -->
<li v-for="item in items" :key="item.id" v-memo="[Date.now()]">
  ...
</li>

💡 TIP v-memo="[]"v-once와 동일하게 동작합니다. 실수로 빈 배열을 전달하지 않도록 주의하세요.

컴포넌트 분리로 업데이트 범위 제한

리렌더 억제의 가장 근본적인 방법은 빠르게 바뀌는 상태와 느리게 바뀌는 UI를 다른 컴포넌트로 분리하는 것입니다.

<!-- ❌ 하나의 거대 컴포넌트 — counter가 바뀌면 heavyList도 재렌더 -->
<script setup>
const counter = ref(0)
const heavyList = ref([/* 수천 개 */])
</script>
<template>
  <button @click="counter++">{{ counter }}</button>
  <HeavyTable :list="heavyList" />
</template>
<!-- ✅ Counter를 별도 컴포넌트로 분리 — heavyList는 영향받지 않음 -->
<!-- CounterWidget.vue -->
<script setup>
const counter = ref(0)
</script>
<template>
  <button @click="counter++">{{ counter }}</button>
</template>
<!-- ParentPage.vue -->
<template>
  <CounterWidget />
  <HeavyTable :list="heavyList" />
</template>

computed 캐싱·watch 최적화·가상 스크롤

computed 캐싱을 성능 도구로 활용하기

computed는 의존성이 바뀌지 않으면 이전 결과를 반환하기 때문에 비용이 큰 연산을 캐시하는 데 이상적입니다. 함수를 methods로 작성하면 렌더마다 재실행되지만, computed는 캐시 덕분에 한 번만 실행됩니다.

// ✅ 필터+정렬이 무거운 경우 computed로 캐시
const filteredSortedProducts = computed(() =>
  products.value
    .filter(p => p.inStock && p.price <= priceLimit.value)
    .sort((a, b) => a.price - b.price)
)

// ❌ 렌더 함수에서 직접 호출 — 렌더마다 재실행
function getFilteredSorted() { /* ... */ }

⚠️ 주의 computed 내부에서 외부 사이드이펙트(API 호출, DOM 조작)를 일으키면 캐시 무효화 타이밍에 예측 불가한 동작이 생깁니다. 사이드이펙트는 반드시 watch나 이벤트 핸들러에 두세요.

watch 최적화 — 불필요한 재실행 차단

// ❌ 객체 전체를 deep watch — 객체의 어느 프로퍼티가 바뀌어도 콜백 실행
watch(userProfile, handler, { deep: true })

// ✅ 필요한 프로퍼티만 지정 — 최소한의 의존성
watch(() => userProfile.value.email, handler)

// ✅ 여러 소스를 하나의 watch로 묶어 콜백 중복 방지
watch(
  [() => userProfile.value.email, () => userProfile.value.role],
  ([newEmail, newRole]) => { /* ... */ }
)

watchEffect 대신 watch를 명시적으로 쓰는 이유도 이와 같습니다. watchEffect는 콜백 내부의 모든 반응형 참조를 자동 추적하므로 의도치 않은 의존성이 생기기 쉽습니다.

대용량 리스트 — 가상 스크롤

10,000개 항목을 모두 DOM에 마운트하면 초기 렌더에만 수백 ms가 소요됩니다. **가상 스크롤(Virtual Scroll)**은 화면에 보이는 행만 렌더하고 나머지는 DOM에서 제거하여 DOM 노드 수를 일정하게 유지합니다.

Vue 생태계에서 가장 널리 쓰이는 라이브러리는 vue-virtual-scroller@tanstack/vue-virtual입니다.

npm install vue-virtual-scroller
<script setup>
import { RecycleScroller } from 'vue-virtual-scroller'
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css'

const items = ref(Array.from({ length: 50000 }, (_, i) => ({ id: i, text: `항목 ${i}` })))
</script>

<template>
  <!-- ✅ 50,000개도 DOM 노드는 화면에 보이는 수십 개만 유지 -->
  <RecycleScroller
    class="scroller"
    :items="items"
    :item-size="48"
    key-field="id"
    v-slot="{ item }"
  >
    <div class="item-row">{{ item.text }}</div>
  </RecycleScroller>
</template>

<style>
.scroller { height: 600px; overflow-y: auto; }
.item-row { height: 48px; display: flex; align-items: center; padding: 0 16px; }
</style>

💡 TIP 행 높이가 가변적이라면 DynamicScroller / DynamicScrollerItem을 사용하세요. item-size 대신 각 항목의 실제 높이를 내부적으로 측정합니다.


defineAsyncComponent와 라우트 기반 코드 스플리팅

초기 번들 크기를 줄이는 것은 첫 페이지 로드(FCP/LCP)에 직접 영향을 줍니다. Vue는 defineAsyncComponent와 동적 import()를 통해 컴포넌트를 필요한 시점에 내려받을 수 있게 합니다.

defineAsyncComponent

// ✅ 무거운 차트 컴포넌트를 지연 로딩
import { defineAsyncComponent } from 'vue'

const HeavyChart = defineAsyncComponent({
  loader: () => import('./components/HeavyChart.vue'),
  loadingComponent: LoadingSpinner,   // 로딩 중 표시할 컴포넌트
  errorComponent: ErrorMessage,       // 실패 시 표시할 컴포넌트
  delay: 200,       // 로딩 컴포넌트를 보여주기까지 대기 시간 (ms)
  timeout: 10000,   // 이 시간 초과 시 errorComponent 표시
})
<template>
  <!-- 모달이 열릴 때만 번들을 요청 -->
  <HeavyChart v-if="showChart" :data="chartData" />
</template>

라우트 기반 코드 스플리팅

각 라우트를 동적 import()로 선언하면 Vite/webpack이 자동으로 청크를 분리합니다. 이것이 가장 효과적인 코드 스플리팅 단위입니다.

// router/index.ts
import { createRouter, createWebHistory } from 'vue-router'

const router = createRouter({
  history: createWebHistory(),
  routes: [
    {
      path: '/',
      // ✅ 홈 페이지만 초기 번들에 포함
      component: () => import('@/pages/HomePage.vue'),
    },
    {
      path: '/admin',
      // ✅ 어드민 영역 전체를 별도 청크로 분리
      component: () => import('@/pages/AdminLayout.vue'),
      children: [
        {
          path: 'dashboard',
          component: () => import('@/pages/admin/Dashboard.vue'),
        },
        {
          path: 'users',
          component: () => import('@/pages/admin/UserManagement.vue'),
        },
      ],
    },
  ],
})

export default router
// vite.config.ts — 청크 이름을 명시적으로 지정(디버깅 편의)
export default defineConfig({
  build: {
    rollupOptions: {
      output: {
        manualChunks: {
          'vendor-chart': ['chart.js', 'vue-chartjs'],
          'vendor-editor': ['@tiptap/vue-3'],
        },
      },
    },
  },
})

💡 TIP Vite에는 --report CLI 플래그가 없습니다(그것은 webpack 기반 Vue CLI의 옵션입니다). rollup-plugin-visualizervite.configplugins에 추가한 뒤 vite build를 실행하면 번들 트리맵을 담은 stats.html이 생성됩니다. 설정 없이 한 번만 확인하고 싶다면 npx vite-bundle-visualizer를 실행하세요.


Suspense로 비동기 의존 컴포넌트 로딩 제어

defineAsyncComponentsetup()에서 await를 사용하는 컴포넌트를 다룰 때 Suspense는 로딩 상태를 선언적으로 관리할 수 있게 해줍니다.

async setup과 Suspense

<!-- UserProfile.vue — async setup (코어 Vue, 표준 fetch + await) -->
<script setup lang="ts">
import { ref } from 'vue'

const props = defineProps<{ id: string }>()

// ✅ 이 컴포넌트는 Suspense의 #default 슬롯 안에서만 정상 동작
const user = ref(null)
user.value = await (await fetch(`/api/users/${props.id}`)).json()
</script>
<template>
  <div>{{ user.name }}</div>
</template>
<!-- ParentPage.vue -->
<template>
  <Suspense>
    <!-- 비동기 컴포넌트가 준비될 때까지 #fallback 표시 -->
    <template #default>
      <UserProfile :id="userId" />
    </template>
    <template #fallback>
      <ProfileSkeleton />
    </template>
  </Suspense>
</template>

onErrorCaptured로 에러 경계 구현

<!-- AsyncBoundary.vue — 재사용 가능한 에러+Suspense 래퍼 -->
<script setup>
import { ref, onErrorCaptured } from 'vue'

const error = ref(null)
onErrorCaptured((err) => {
  error.value = err
  return false // 상위 전파 차단
})
</script>

<template>
  <div v-if="error" class="error-state">
    <p>{{ error.message }}</p>
    <button @click="error = null">재시도</button>
  </div>
  <Suspense v-else>
    <template #default>
      <slot />
    </template>
    <template #fallback>
      <slot name="loading"><DefaultSpinner /></slot>
    </template>
  </Suspense>
</template>
<!-- 사용 예 -->
<AsyncBoundary>
  <template #default>
    <HeavyDataTable />
  </template>
  <template #loading>
    <TableSkeleton />
  </template>
</AsyncBoundary>

⚠️ 주의 Suspense는 현재(Vue 3.x) 실험적 API입니다. 한 번 resolved된 Suspense는 #default 슬롯의 루트 노드가 교체될 때만 다시 pending 상태로 복귀하며, 더 깊이 중첩된 새 비동기 의존성은 pending을 유발하지 않습니다. 그래서 깊은 곳에 있는 비동기 컴포넌트의 교체를 제대로 다루려면 그 위치에 중첩 Suspense를 두어야 합니다.


KeepAlive 캐싱과 메모리 트레이드오프

<KeepAlive>는 컴포넌트가 언마운트될 때 DOM과 상태를 메모리에 보존했다가, 다시 활성화될 때 재마운트 없이 즉시 표시합니다. 탭 전환·라우터 뷰에서 탁월한 UX를 만들지만, 잘못 쓰면 메모리 누수로 이어집니다.

기본 사용

<template>
  <!-- ✅ 탭 전환 시 입력 상태와 스크롤 위치가 유지됨 -->
  <KeepAlive>
    <component :is="currentTab" />
  </KeepAlive>

  <!-- ✅ 라우터 뷰와 함께 -->
  <RouterView v-slot="{ Component }">
    <KeepAlive :max="10" :include="cachedRoutes">
      <component :is="Component" :key="$route.fullPath" />
    </KeepAlive>
  </RouterView>
</template>

include·exclude·max로 캐시 제어

<KeepAlive
  :include="['ProductList', 'SearchResults']"
  :exclude="['AdminPanel']"
  :max="5"
>
  <component :is="activeComponent" />
</KeepAlive>
옵션역할
include이름이 일치하는 컴포넌트만 캐시
exclude이름이 일치하는 컴포넌트는 캐시하지 않음
max캐시 인스턴스 상한 (LRU 방식으로 오래된 것부터 제거)

onActivated·onDeactivated 훅

<script setup>
import { onActivated, onDeactivated } from 'vue'

// ✅ 컴포넌트가 캐시에서 복귀할 때 데이터만 갱신 (풀 마운트 없이)
onActivated(async () => {
  await refreshData()
})

// ✅ 캐시로 들어갈 때 리소스 해제
onDeactivated(() => {
  stopPolling()
})
</script>

메모리 트레이드오프

캐시 인스턴스 증가
      ↓
힙 메모리 사용량 증가
      ↓
GC 압력 상승 → 장기간 사용 시 FPS 저하 가능

실무 권고 사항:

  • max를 반드시 지정하세요. 무제한 캐시는 SPA에서 메모리 누수와 동일합니다.
  • 대형 컴포넌트(지도·에디터·차트)는 KeepAlive 대신 상태만 Pinia에 보존하고 컴포넌트는 재마운트하는 패턴이 더 안전합니다.
  • onDeactivated에서 WebSocket·인터벌·이벤트 리스너를 반드시 정리하세요.

요약

  • 측정 먼저: Vue DevTools Timeline과 Chrome Performance 패널로 병목을 숫자로 확인한 뒤 최적화하세요. 직관에 의존한 최적화는 실효가 없거나 역효과를 낳습니다.
  • v-once·v-memo: 정적 노드나 변경 조건이 명확한 서브 트리의 렌더 비용을 즉시 0에 가깝게 줄일 수 있습니다.
  • 컴포넌트 분리: 업데이트 범위를 좁히는 가장 근본적인 방법이며, computed 캐싱과 함께 사용하면 배가 됩니다.
  • 가상 스크롤: 수만 개 리스트도 DOM 노드를 화면 크기에 비례한 수십 개로 유지하여 렌더 성능을 유지합니다.
  • 코드 스플리팅: 라우트 단위 동적 import()defineAsyncComponent로 초기 번들을 분리하고, Suspense로 로딩 상태를 선언적으로 제어합니다.
  • KeepAlive: 재마운트 비용을 제거하지만 maxonDeactivated 정리 없이는 메모리 문제로 이어집니다.

연습문제

  1. 아래 컴포넌트에는 불필요한 리렌더가 발생합니다. 문제를 찾고, v-memo 또는 컴포넌트 분리를 적용하여 수정하세요.
<script setup>
import { ref } from 'vue'
const tick = ref(0)
const items = ref(Array.from({ length: 1000 }, (_, i) => ({ id: i, name: `item-${i}` })))
setInterval(() => tick.value++, 1000)
</script>
<template>
  <p>경과 시간: {{ tick }}초</p>
  <ul>
    <li v-for="item in items" :key="item.id">{{ item.name }}</li>
  </ul>
</template>

힌트 tickitems 중 어느 쪽이 변경되는지, 그리고 두 UI 영역이 같은 컴포넌트에 있을 필요가 있는지 생각해 보세요.

  1. 다음 watch는 의도치 않게 과도한 콜백 실행을 유발합니다. 최소한의 변경으로 최적화하세요.
const form = reactive({ name: '', email: '', address: { city: '', zip: '' } })
watch(form, (val) => {
  console.log('email changed:', val.email)
}, { deep: true })

힌트 watch의 첫 번째 인수로 함수를 전달하는 방법을 떠올려 보세요.

  1. 어드민 대시보드 라우트(/admin)와 그 하위 페이지들이 모두 초기 번들에 포함되어 있습니다. Vue Router의 동적 import()를 사용하여 /admin 하위 전체를 별도 청크로 분리하세요.

힌트 webpackChunkName(webpack) 또는 Vite의 자동 청크 분리를 활용하세요.

  1. 탭을 전환할 때마다 API 요청이 재발생하는 문제가 있습니다. KeepAlive를 적용하되, 탭이 다시 활성화될 때 데이터를 갱신하고, 비활성화될 때는 폴링을 중단하도록 구현하세요.

힌트 onActivatedonDeactivated 훅, 그리고 KeepAlive의 max 옵션을 함께 활용하세요.


💡 연습문제 풀이

불러오는 중…

함께 보면 좋은 자료

댓글 0

Vue.js 심화” 강좌에 대한 댓글입니다.

댓글을 작성하려면 로그인이 필요합니다.