dev.syw

코드 스플리팅, 지연 하이드레이션, 이미지·페이로드 최적화와 측정.

성능 최적화와 번들 분석

Nuxt 애플리케이션이 기능적으로 완성되었다면, 그 다음 단계는 사용자가 실제로 느끼는 체감 속도를 높이는 것입니다. 입문편에서 SSR의 개념과 렌더링 모드를 살펴보았다면, 이 레슨에서는 번들 크기 측정부터 하이드레이션 비용 절감, Core Web Vitals 수치 개선까지 정량적으로 측정하고 개선하는 방법을 다룹니다.

단순히 "SSR이 빠르다"는 통념에서 벗어나, 무엇이 느리고 얼마나 개선됐는지를 수치로 파악하고 근거 있는 최적화를 적용하는 것이 심화 개발자의 역량입니다.

학습 목표

  • nuxi analyze 로 번들을 시각화해 무거운 의존성과 중복 청크를 식별할 수 있다.
  • 동적 importLazyXxx 컴포넌트를 활용해 초기 번들 크기를 줄일 수 있다.
  • 지연/조건부 하이드레이션islands 아키텍처로 클라이언트 JS 실행 비용을 절감할 수 있다.
  • @nuxt/image, 폰트 최적화, 리소스 프리로드로 LCP와 CLS를 개선할 수 있다.
  • useState 남용을 피하고 payload 크기를 줄여 HTML 전송·파싱 비용과 하이드레이션 비용을 절감할 수 있다.

번들 분석 — nuxi analyze

최적화의 첫 단계는 측정입니다. 무엇이 번들을 무겁게 만드는지 시각적으로 파악하려면 nuxi analyze 명령어를 사용합니다. 이 명령어는 내부적으로 Vite의 rollup-plugin-visualizer를 실행해 인터랙티브 트리맵을 생성합니다.

# 번들 분석 실행 (브라우저에서 .nuxt/analyze/index.html 자동 열림)
npx nuxi analyze

분석 보고서에서 확인해야 할 주요 항목은 다음과 같습니다.

확인 항목의심 신호조치
특정 라이브러리 크기lodash 전체, moment.js 등 > 100 kBtree-shaking 가능한 대안 교체
중복 청크같은 모듈이 여러 청크에 포함build.rollupOptions.output.manualChunks 설정
vendor 청크지나치게 큰 단일 vendor.js코드 스플리팅으로 분산
비동기 청크 수너무 많은 소형 청크적절한 병합 필요
// nuxt.config.ts — 무거운 의존성을 별도 청크로 분리
export default defineNuxtConfig({
  vite: {
    build: {
      rollupOptions: {
        output: {
          manualChunks(id) {
            // chart.js는 사용하는 페이지에서만 로드되도록 분리
            if (id.includes('chart.js')) return 'vendor-chart'
            // 공통 유틸리티는 하나의 청크로 묶기
            if (id.includes('node_modules/lodash-es')) return 'vendor-utils'
          }
        }
      }
    }
  }
})

💡 TIP nuxi analyze는 빌드 결과물 기준으로 분석합니다. 개발 서버(nuxi dev)와 실제 번들 구성이 다를 수 있으므로, 최적화 전후를 반드시 프로덕션 빌드로 비교하세요.


코드 스플리팅 — 동적 import와 LazyXxx 컴포넌트

Nuxt는 components/ 디렉터리의 컴포넌트를 자동으로 임포트합니다. 그러나 모든 컴포넌트가 초기 번들에 포함되면 불필요한 JS가 사용자에게 전달됩니다. Lazy 접두사를 붙이면 해당 컴포넌트가 실제로 렌더링될 때만 청크를 로드합니다.

<!-- pages/dashboard.vue -->
<template>
  <div>
    <h1>대시보드</h1>

    <!-- ❌ HeavyChart는 항상 초기 번들에 포함됨 -->
    <!-- <HeavyChart :data="chartData" /> -->

    <!-- ✅ LazyHeavyChart: 실제 마운트 시점에만 청크 로드 -->
    <LazyHeavyChart v-if="showChart" :data="chartData" />
    <button @click="showChart = true">차트 보기</button>
  </div>
</template>

<script setup lang="ts">
const showChart = ref(false)
const chartData = ref([/* ... */])
</script>

모달, 드로어, 탭 패널처럼 조건부로 표시되는 무거운 UI는 LazyXxx 패턴이 특히 효과적입니다. 수동으로 동적 임포트가 필요한 경우 defineAsyncComponent를 직접 사용할 수도 있습니다.

// composables/useHeavyLib.ts — 라이브러리 지연 로딩
export async function processData(raw: unknown[]) {
  // 실제 호출 시점에만 번들 로드
  const { parse } = await import('some-heavy-parser')
  return parse(raw)
}
// nuxt.config.ts — 특정 컴포넌트를 항상 지연 로딩으로 강제
export default defineNuxtConfig({
  components: [
    {
      path: '~/components/heavy',
      // heavy/ 디렉터리의 모든 컴포넌트를 기본 lazy로 처리
      extensions: ['vue'],
      prefetch: false,
      preload: false,
    }
  ]
})

⚠️ 주의 LazyXxx 컴포넌트는 SSR 중에는 즉시 렌더링됩니다. 클라이언트 측 번들 절감 효과는 있지만, 서버에서의 렌더링 비용은 동일합니다. 서버 렌더링 자체를 피하려면 다음 섹션의 islands/서버 컴포넌트 패턴을 활용하세요.


지연·조건부 하이드레이션과 islands 아키텍처

일반적인 SSR+CSR 방식에서는 서버가 HTML을 렌더링한 후 클라이언트가 동일한 컴포넌트 트리를 다시 실행해 이벤트 핸들러를 붙이는 하이드레이션 과정이 발생합니다. 페이지의 모든 컴포넌트가 즉시 하이드레이션되면 TTI(Time to Interactive)가 늦어집니다.

지연 하이드레이션 (Lazy Hydration)

Nuxt 3.12+에서는 nuxt-lazy-hydration 패키지 없이도 일부 전략을 적용할 수 있습니다. 또한 커뮤니티 모듈 nuxt-lazy-hydration을 활용하면 다양한 조건을 선언적으로 제어할 수 있습니다.

npm install nuxt-lazy-hydration
// nuxt.config.ts
export default defineNuxtConfig({
  modules: ['nuxt-lazy-hydration']
})
<!-- pages/index.vue -->
<template>
  <!-- 뷰포트 진입 시 하이드레이션 (IntersectionObserver 기반) -->
  <LazyHydrationWhenVisible>
    <HeavyCommentSection />
  </LazyHydrationWhenVisible>

  <!-- 유휴 시간에 하이드레이션 (requestIdleCallback 기반) -->
  <LazyHydrationWhenIdle :timeout="3000">
    <RecommendationWidget />
  </LazyHydrationWhenIdle>

  <!-- 특정 이벤트 발생 시 하이드레이션 -->
  <LazyHydrationOnInteraction event="click">
    <ShareButton />
  </LazyHydrationOnInteraction>
</template>

서버 컴포넌트와 .server.vue

Nuxt에서 파일명에 .server.vue를 사용하면 해당 컴포넌트는 서버에서만 렌더링되고 클라이언트 JS가 전혀 전달되지 않습니다. 정적인 마케팅 블록이나 날짜 렌더링처럼 인터랙션이 없는 UI에 적합합니다.

<!-- components/StaticBanner.server.vue -->
<!-- 이 컴포넌트는 서버에서 HTML로만 렌더링되며 클라이언트에 JS 전달 없음 -->
<template>
  <section class="banner">
    <h2>오늘의 특가 — {{ formattedDate }}</h2>
    <p>서버 시간 기준으로 렌더링됩니다.</p>
  </section>
</template>

<script setup lang="ts">
// 서버 전용 코드 — 클라이언트 번들에 포함되지 않음
const formattedDate = new Date().toLocaleDateString('ko-KR')
</script>

NuxtIsland를 활용한 islands 패턴

<NuxtIsland>는 특정 컴포넌트를 독립적으로 서버 렌더링하고 그 결과만 클라이언트로 전달합니다. 클라이언트 사이드 하이드레이션 비용이 제로에 가깝습니다.

<template>
  <div>
    <!-- 인터랙티브한 부분: 일반 컴포넌트로 하이드레이션 -->
    <CartButton :count="cartCount" />

    <!-- 정적인 부분: 서버에서 렌더링, JS 없음 -->
    <!-- name은 임의 경로가 아니라 components/islands/ 디렉터리 규칙으로 해석됩니다. -->
    <!-- 즉 이 예시는 components/islands/ProductReviews.vue가 있어야 동작합니다. -->
    <NuxtIsland name="ProductReviews" :props="{ productId: product.id }" />
  </div>
</template>

💡 TIP islands 아키텍처의 효과는 페이지 내 정적 콘텐츠 비율이 높을수록 커집니다. Chrome DevTools의 Coverage 탭에서 사용되지 않는 JS 비율을 측정한 뒤 islands 전환 대상을 선정하세요.


이미지·폰트 최적화와 리소스 프리로드

@nuxt/image

@nuxt/image는 이미지를 자동으로 최적화하여 WebP/AVIF 변환, 반응형 srcset 생성, lazy loading을 처리합니다. LCP 개선에 가장 직접적인 영향을 미치는 모듈입니다.

npm install @nuxt/image
// nuxt.config.ts
export default defineNuxtConfig({
  modules: ['@nuxt/image'],
  image: {
    // Cloudinary, ImageKit 등 외부 프로바이더 설정 가능
    provider: 'ipx', // 기본값: 로컬 처리
    quality: 80,
    formats: ['avif', 'webp'],
    screens: {
      xs: 320, sm: 640, md: 768, lg: 1024, xl: 1280, xxl: 1536
    }
  }
})
<!-- ❌ 일반 img 태그: 최적화 없음, CLS 유발 가능 -->
<!-- <img src="/hero.jpg" alt="히어로 이미지"> -->

<!-- ✅ NuxtImg: WebP 변환 + srcset + 치수 예약으로 CLS 방지 -->
<NuxtImg
  src="/hero.jpg"
  alt="히어로 이미지"
  width="1200"
  height="600"
  sizes="100vw sm:100vw md:1200px"
  format="webp"
  :preload="true"
  loading="eager"
/>

<!-- 일반 콘텐츠 이미지: lazy loading -->
<NuxtImg
  src="/product.jpg"
  alt="상품 이미지"
  width="400"
  height="300"
  loading="lazy"
/>

⚠️ 주의 LCP 대상이 되는 히어로 이미지에는 반드시 :preload="true"loading="eager"를 사용하세요. loading="lazy"를 적용하면 오히려 LCP가 악화됩니다.

폰트 최적화

Google Fonts를 직접 <link>로 불러오면 외부 도메인 연결 비용이 추가됩니다. @nuxtjs/google-fonts 모듈을 사용하면 폰트 파일을 빌드 시점에 다운로드해 자체 서버에서 제공합니다.

npm install @nuxtjs/google-fonts
// nuxt.config.ts
export default defineNuxtConfig({
  modules: ['@nuxtjs/google-fonts'],
  googleFonts: {
    families: {
      'Noto+Sans+KR': [400, 500, 700],
    },
    display: 'swap',    // FOIT 방지
    preload: true,      // <link rel="preload"> 자동 생성
    download: true,     // 폰트를 로컬에 저장해 self-hosting
  }
})

리소스 프리로드와 프리페치

Nuxt는 useHeadlink 옵션으로 프리로드/프리페치를 세밀하게 제어할 수 있습니다.

<script setup lang="ts">
useHead({
  link: [
    // LCP 이미지 프리로드
    {
      rel: 'preload',
      as: 'image',
      href: '/hero.webp',
      type: 'image/webp',
    },
    // 다음 페이지 데이터 프리페치 (유휴 시간 활용)
    {
      rel: 'prefetch',
      href: '/api/next-page-data',
    },
    // 중요 CSS 프리로드
    {
      rel: 'preload',
      as: 'style',
      href: '/critical.css',
    }
  ]
})
</script>

💡 TIP <NuxtLink prefetch> 속성을 사용하면 해당 링크가 뷰포트에 들어올 때 목적지 청크를 자동으로 프리페치합니다. 개별 링크는 <NuxtLink :prefetch="false">(또는 no-prefetch)로 끌 수 있으며, 전역 기본값을 바꾸려면 defineNuxtLinkprefetch 기본값을 지정한 커스텀 링크 컴포넌트를 정의해 사용하세요.


Payload 크기 줄이기와 useState 남용 피하기

SSR에서 서버가 렌더링한 데이터를 클라이언트로 전달할 때 __NUXT_DATA__ 형태의 인라인 JSON(payload)이 HTML에 포함됩니다. 이 payload가 커지면 HTML 전송량과 파싱 비용이 늘고, 클라이언트 하이드레이션 비용도 증가해 FCP/LCP/TTI와 메모리 사용에 영향을 줍니다.

useState 남용 문제

useState는 서버-클라이언트 간 상태 공유를 위해 설계되었지만, 불필요하게 큰 데이터를 담으면 payload에 그대로 직렬화됩니다.

// ❌ 잘못된 예: 전체 상품 목록을 useState에 보관
const products = useState('products', () => hugeProductList) // payload에 수 MB가 포함될 수 있음

// ✅ 올바른 예: 서버-클라이언트 공유가 필요한 최소 데이터만 useState에 보관
const selectedProductId = useState<number | null>('selectedProductId', () => null)

// 목록은 useFetch/useAsyncData로 관리 — payload 최소화
const { data: products } = await useFetch('/api/products', {
  pick: ['id', 'name', 'price'], // 필요한 필드만 선택
})

pick과 transform으로 페이로드 축소

useFetchuseAsyncDatapick 옵션과 transform 콜백은 서버에서 클라이언트로 전달되는 데이터를 가공해 payload 크기를 직접적으로 줄입니다.

// pages/products/[id].vue
const { data: product } = await useFetch(`/api/products/${route.params.id}`, {
  // ✅ 화면에 필요한 필드만 직렬화 — API가 100개 필드를 반환해도 5개만 payload에 포함
  pick: ['id', 'name', 'price', 'imageUrl', 'description'],
})

const { data: orders } = await useAsyncData('orders', () => $fetch('/api/orders'), {
  // ✅ transform으로 가공: 클라이언트에 필요 없는 민감 정보 제거
  transform(raw) {
    return raw.map(({ id, status, total }) => ({ id, status, total }))
  },
  // getCachedData로 불필요한 재요청 방지
  getCachedData(key) {
    return useNuxtApp().payload.data[key]
  }
})

페이로드 크기 측정

# 프로덕션 빌드 후 실행
npx nuxi build && npx nuxi preview

크롬 DevTools 네트워크 탭에서 초기 HTML 응답을 선택하고 Preview에서 __NUXT_DATA__ 배열의 크기를 확인합니다. 일반적으로 50 kB 이하를 목표로 합니다.


Core Web Vitals 측정 — Lighthouse와 web-vitals 연동

최적화 결과를 정량적으로 검증하려면 Core Web Vitals를 측정하고 기록해야 합니다.

지표설명좋음 기준
LCP (Largest Contentful Paint)가장 큰 콘텐츠 요소의 렌더링 시간≤ 2.5초
CLS (Cumulative Layout Shift)예상치 못한 레이아웃 이동 누적 점수≤ 0.1
INP (Interaction to Next Paint)사용자 입력에 대한 시각적 응답 시간≤ 200ms

web-vitals 패키지 연동

npm install web-vitals
// plugins/web-vitals.client.ts — 클라이언트 전용 플러그인
import { onCLS, onINP, onLCP } from 'web-vitals'

export default defineNuxtPlugin(() => {
  // 지표를 수집해 분석 엔드포인트로 전송
  function sendToAnalytics(metric: { name: string; value: number; id: string }) {
    // 실제 환경에서는 GA4, Datadog 등의 엔드포인트로 전송
    console.log(`[Web Vitals] ${metric.name}: ${metric.value.toFixed(2)}`)

    // 예시: Beacon API로 비동기 전송 (페이지 언로드 시에도 안전)
    navigator.sendBeacon('/api/vitals', JSON.stringify(metric))
  }

  onCLS(sendToAnalytics)
  onINP(sendToAnalytics)
  onLCP(sendToAnalytics)
})
// server/api/vitals.post.ts — 지표 수집 엔드포인트
export default defineEventHandler(async (event) => {
  const body = await readBody(event)
  // DB에 저장하거나 외부 모니터링 서비스로 포워딩
  console.log('[Server Vitals]', body)
  return { ok: true }
})

Lighthouse CI로 회귀 방지

npm install -D @lhci/cli
// lighthouserc.json
{
  "ci": {
    "collect": {
      "url": ["http://localhost:3000", "http://localhost:3000/products"],
      "numberOfRuns": 3
    },
    "assert": {
      "assertions": {
        "categories:performance": ["error", { "minScore": 0.9 }],
        "largest-contentful-paint": ["error", { "maxNumericValue": 2500 }],
        "cumulative-layout-shift": ["error", { "maxNumericValue": 0.1 }]
      }
    },
    "upload": {
      "target": "temporary-public-storage"
    }
  }
}
# package.json 스크립트 추가
# "lhci": "lhci autorun"
npx lhci autorun

💡 TIP Lighthouse는 실험실 측정(lab data)이므로 실제 사용자 경험(field data)과 차이가 있을 수 있습니다. web-vitals 패키지로 수집한 현장 데이터와 함께 분석해야 의미 있는 최적화 방향을 잡을 수 있습니다.


요약

  • nuxi analyze 로 번들을 시각화해 무거운 의존성과 중복 청크를 식별하고, manualChunks로 분리한다.
  • LazyXxx 접두사와 동적 import로 코드 스플리팅을 제어해 초기 JS 페이로드를 줄인다.
  • .server.vue<NuxtIsland> 를 활용해 인터랙션 없는 컴포넌트의 하이드레이션 비용을 제로로 만든다.
  • @nuxt/image 로 이미지를 자동 최적화하고, LCP 대상 이미지는 preload: true로 우선 로드한다.
  • useFetchpick/transform 으로 payload를 최소화하고, useState는 진정으로 공유가 필요한 최소 데이터에만 사용한다.
  • web-vitals 패키지로 LCP/CLS/INP를 실 사용자 기준으로 수집하고, Lighthouse CI로 성능 회귀를 자동으로 차단한다.

연습문제

  1. 현재 프로젝트에 nuxi analyze를 실행했더니 date-fns 전체(약 70 kB gzip)가 번들에 포함되어 있습니다. 필요한 함수는 formatparseISO 두 가지뿐입니다. 번들 크기를 줄이기 위한 import 방식을 올바르게 작성하세요.

    힌트 date-fns는 ESM 서브패스 방식의 named export를 지원합니다. 기존 코드가 import dateFns from 'date-fns' 형태라면 무엇을 바꿔야 할지 생각해 보세요.

  2. 상품 상세 페이지(pages/products/[id].vue)에서 useAsyncData로 가져온 상품 데이터에는 내부 관리용 필드(internalCode, supplierInfo, costPrice)가 포함되어 있습니다. 이 필드들이 클라이언트 payload에 노출되지 않도록 transform 옵션을 사용해 필터링하세요.

    힌트 transform 콜백의 반환값이 곧 data.value에 담기는 값입니다.

  3. components/HeavyEditor.vue라는 풍부한 텍스트 에디터 컴포넌트가 있습니다. 이 컴포넌트는 "편집" 버튼을 클릭할 때만 표시됩니다. 초기 페이지 로드 시 이 컴포넌트의 JS가 다운로드되지 않도록 템플릿을 수정하세요.

    힌트 Nuxt의 자동 임포트 네이밍 컨벤션을 활용하면 추가 코드 없이 해결됩니다.

  4. web-vitals 패키지를 사용해 LCP 값이 4초를 초과할 경우 콘솔에 경고 메시지를 출력하는 Nuxt 클라이언트 플러그인을 작성하세요.

    힌트 플러그인 파일명에 .client.ts를 붙이면 서버에서는 실행되지 않습니다.


💡 연습문제 풀이

불러오는 중…

함께 보면 좋은 자료

댓글 0

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

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