dev.syw

확장 가능한 상태 설계와 SSR/SSG, 빌드·배포까지 운영 단계를 다룬다.

실전 상태 관리·SSR과 배포

입문편에서 Pinia와 Vue Router의 기본 사용법을 익혔다면, 이제는 실제 서비스를 운영하는 관점에서 상태 관리를 어떻게 설계하고 확장할지, 그리고 서버 사이드 렌더링(SSR)·정적 생성(SSG)을 통해 어떻게 성능과 SEO를 확보할지를 다룰 차례입니다.

이번 레슨은 "동작하게 만들기"를 넘어 "프로덕션에서 안전하고 효율적으로 운영하기"에 초점을 맞춥니다. Pinia 플러그인으로 상태 영속화를 구현하는 방법, SSR에서 발생하는 하이드레이션 불일치 문제, Vite 프로덕션 빌드의 환경 변수 관리, 전역 에러 처리와 CDN 배포 전략까지 실무에 바로 적용할 수 있는 내용으로 구성했습니다.

학습 목표

  • Pinia 플러그인모듈화 패턴으로 대규모 상태를 설계할 수 있다.
  • SSRSSG의 차이를 이해하고, 하이드레이션 불일치의 원인을 진단·수정할 수 있다.
  • Nuxt 프로젝트에서 서버/클라이언트 경계를 명확히 구분하고 useAsyncData를 활용할 수 있다.
  • Vite 프로덕션 빌드 최적화와 환경 변수 전략을 적용할 수 있다.
  • 전역 에러 처리, 로깅, 캐싱·CDN 전략을 포함하는 배포 파이프라인을 구성할 수 있다.

Pinia 심화: 모듈화·플러그인·상태 영속화·HMR

스토어 모듈화 전략

입문편에서는 단일 스토어로 간단히 상태를 관리했지만, 실제 애플리케이션에서는 도메인별로 스토어를 분리하고 스토어 간 참조를 적절히 관리해야 합니다.

// stores/auth.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type { User } from '@/types'

export const useAuthStore = defineStore('auth', () => {
  const user = ref<User | null>(null)
  const token = ref<string | null>(null)

  const isAuthenticated = computed(() => !!token.value)

  async function login(credentials: { email: string; password: string }) {
    const response = await fetch('/api/auth/login', {
      method: 'POST',
      body: JSON.stringify(credentials),
    })
    const data = await response.json()
    token.value = data.token
    user.value = data.user
  }

  function logout() {
    user.value = null
    token.value = null
  }

  return { user, token, isAuthenticated, login, logout }
})
// stores/cart.ts — 다른 스토어를 내부에서 참조하는 패턴
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { useAuthStore } from './auth'

export const useCartStore = defineStore('cart', () => {
  const items = ref<CartItem[]>([])
  const authStore = useAuthStore() // ✅ 스토어 내부에서 다른 스토어 사용 가능

  const total = computed(() =>
    items.value.reduce((sum, item) => sum + item.price * item.qty, 0)
  )

  async function checkout() {
    if (!authStore.isAuthenticated) {
      throw new Error('로그인이 필요합니다.')
    }
    // 결제 처리 로직...
  }

  return { items, total, checkout }
})

💡 TIP 스토어 파일이 늘어날수록 stores/index.ts에서 일괄 export하면 import 경로를 단순하게 유지할 수 있습니다.

Pinia 플러그인으로 상태 영속화 구현

Pinia 플러그인은 모든 스토어 인스턴스 생성 시 공통 로직을 주입할 수 있는 강력한 확장 포인트입니다. localStorage 기반 영속화를 직접 구현하면 동작 원리를 완벽히 제어할 수 있습니다.

// plugins/pinia-persist.ts
import type { PiniaPluginContext } from 'pinia'

interface PersistOptions {
  persist?: boolean | { key?: string; paths?: string[] }
}

export function piniaPersistedState(context: PiniaPluginContext) {
  const { store, options } = context
  const persistOpts = (options as PersistOptions).persist

  if (!persistOpts) return

  const storageKey =
    typeof persistOpts === 'object' && persistOpts.key
      ? persistOpts.key
      : `pinia-${store.$id}`

  const paths =
    typeof persistOpts === 'object' && persistOpts.paths
      ? persistOpts.paths
      : null

  // 초기 복원
  const stored = localStorage.getItem(storageKey)
  if (stored) {
    try {
      store.$patch(JSON.parse(stored))
    } catch (e) {
      console.warn(`[pinia-persist] 복원 실패: ${storageKey}`, e)
    }
  }

  // 변경 감지 후 저장
  store.$subscribe((_, state) => {
    const toSave = paths
      ? Object.fromEntries(
          paths.map((p) => [p, (state as Record<string, unknown>)[p]])
        )
      : state
    localStorage.setItem(storageKey, JSON.stringify(toSave))
  })
}
// main.ts
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import { piniaPersistedState } from './plugins/pinia-persist'
import App from './App.vue'

const pinia = createPinia()
pinia.use(piniaPersistedState)

createApp(App).use(pinia).mount('#app')
// stores/auth.ts — 플러그인 옵션 선언
export const useAuthStore = defineStore(
  'auth',
  () => {
    const token = ref<string | null>(null)
    // ...
    return { token }
  },
  {
    persist: { key: 'app-auth', paths: ['token'] }, // ✅ token만 영속화
  }
)

Pinia HMR(Hot Module Replacement) 설정

Vite 환경에서 스토어를 수정할 때 상태가 유지되도록 HMR을 명시적으로 등록해야 합니다.

// stores/counter.ts
import { defineStore, acceptHMRUpdate } from 'pinia'

export const useCounterStore = defineStore('counter', () => {
  const count = ref(0)
  return { count }
})

// ✅ 개발 환경 HMR 지원 — 없으면 스토어 수정 시 상태가 초기화됨
if (import.meta.hot) {
  import.meta.hot.accept(acceptHMRUpdate(useCounterStore, import.meta.hot))
}

⚠️ 주의 acceptHMRUpdate를 등록하지 않으면 스토어 파일을 수정할 때마다 전체 페이지가 새로고침되거나 기존 상태가 소실됩니다.

SSR과 SSG 개념, 그리고 하이드레이션의 함정

SSR vs SSG vs CSR 비교

방식렌더링 시점SEOTTFB데이터 신선도
CSR (SPA)브라우저취약빠름실시간
SSR요청마다 서버우수중간실시간
SSG빌드 타임우수매우 빠름빌드 시점 고정
ISR (Nuxt의 경우)빌드 + 재검증우수매우 빠름설정 주기마다 갱신

SSR은 각 요청마다 서버에서 HTML을 완성해 내려보내므로 초기 로딩이 빠르고 크롤러가 완전한 HTML을 읽을 수 있습니다. SSG는 빌드 단계에서 모든 페이지를 미리 생성하므로 CDN에서 직접 서빙할 수 있어 서버 부하가 없습니다.

하이드레이션과 그 함정

하이드레이션(Hydration)은 서버가 생성한 정적 HTML에 Vue 런타임이 연결되어 인터랙티브 상태로 전환하는 과정입니다. 이 과정에서 서버 HTML과 클라이언트 렌더링 결과가 다르면 하이드레이션 불일치(Hydration Mismatch) 오류가 발생합니다.

<!-- ❌ 하이드레이션 불일치 유발 — Date.now()는 서버/클라이언트 타임이 다름 -->
<template>
  <p>현재 시각: {{ new Date().toLocaleTimeString() }}</p>
</template>
<!-- ✅ 클라이언트에서만 렌더링되도록 분리 -->
<template>
  <ClientOnly>
    <p>현재 시각: {{ currentTime }}</p>
    <template #fallback>
      <p>시각 로딩 중...</p>
    </template>
  </ClientOnly>
</template>

<script setup lang="ts">
import { ref, onMounted } from 'vue'

const currentTime = ref('')
onMounted(() => {
  currentTime.value = new Date().toLocaleTimeString()
})
</script>

하이드레이션 불일치의 주요 원인과 해결책을 정리하면 다음과 같습니다.

원인해결책
Date.now(), Math.random() 등 비결정적 값onMounted에서 실행하거나 <ClientOnly> 사용
localStorage, sessionStorage 접근onMounted 또는 process.client 조건부 실행
브라우저 전용 API (window, document)typeof window !== 'undefined' 가드
서버/클라이언트 쿠키 불일치SSR용 쿠키 유틸리티 사용 (useCookie in Nuxt)

⚠️ 주의 Vue는 개발 환경에서 하이드레이션 불일치를 콘솔 경고로 알려주지만 프로덕션에서는 조용히 클라이언트 렌더링으로 덮어씁니다. 이는 화면 깜빡임(content flicker/layout shift)으로 나타날 수 있으므로 개발 중에 반드시 수정해야 합니다. 참고로 이 깜빡임은 CSS 로드 전 스타일 없는 콘텐츠가 잠깐 보이는 FOUC(Flash of Unstyled Content)와는 다른 별개 현상입니다.

Nuxt로 보는 SSR 앱 구조와 서버/클라이언트 경계

Nuxt 프로젝트의 핵심 디렉터리 구조

my-nuxt-app/
├── server/
│   ├── api/          # 서버 전용 API 라우트
│   │   └── users.get.ts
│   └── middleware/   # 서버 미들웨어
├── pages/            # 파일 기반 라우팅
├── composables/      # 자동 import 컴포저블
├── plugins/          # 앱 초기화 플러그인
│   ├── analytics.client.ts   # 클라이언트에서만 실행
│   └── sentry.server.ts      # 서버에서만 실행
└── nuxt.config.ts

파일명에 .client.ts 또는 .server.ts를 붙이면 Nuxt가 자동으로 실행 환경을 분리합니다. 이것이 Nuxt에서 서버/클라이언트 경계를 선언하는 가장 명시적인 방법입니다.

useAsyncData와 useFetch로 데이터 페칭

<!-- pages/products/[id].vue -->
<script setup lang="ts">
const route = useRoute()

// ✅ SSR에서 서버 데이터를 직렬화해 클라이언트로 전달 — 하이드레이션 중 재요청 없음
const { data: product, error, pending } = await useAsyncData(
  `product-${route.params.id}`,  // 캐시 키 — 고유해야 함
  () => $fetch(`/api/products/${route.params.id}`),
  {
    // 캐시 전략: 같은 키로 재요청 시 stale 동안 캐시 반환
    getCachedData: (key, nuxtApp) => nuxtApp.payload.data[key],
  }
)

if (error.value) {
  throw createError({ statusCode: 404, statusMessage: '상품을 찾을 수 없습니다.' })
}
</script>

<template>
  <div v-if="pending">로딩 중...</div>
  <ProductDetail v-else-if="product" :product="product" />
</template>
// server/api/products/[id].get.ts — 서버 전용 코드, 클라이언트에 노출되지 않음
import { db } from '~/server/db'  // DB 접속 정보가 안전하게 서버에만 존재

export default defineEventHandler(async (event) => {
  const id = getRouterParam(event, 'id')
  const product = await db.products.findById(id)

  if (!product) {
    throw createError({ statusCode: 404 })
  }

  return product
})

💡 TIP useAsyncData의 첫 번째 인자인 캐시 키는 페이지 전체에서 고유해야 합니다. 동적 라우트에서 route.params를 포함하지 않으면 다른 상품 페이지로 이동해도 이전 데이터가 캐시에서 반환되는 버그가 발생합니다.

Pinia와 Nuxt SSR 통합 시 주의점

SSR 환경에서 Pinia를 사용할 때 가장 중요한 규칙은 요청 간 상태가 공유되어서는 안 된다는 것입니다.

// ❌ 모듈 스코프에서 싱글턴 상태를 사용하면 서버에서 요청 간 오염 발생
let globalUser: User | null = null

// ✅ Pinia는 각 요청마다 새 인스턴스를 생성하므로 안전
// nuxt.config.ts에서 @pinia/nuxt 모듈 등록만 하면 자동 처리됨
// nuxt.config.ts
export default defineNuxtConfig({
  modules: ['@pinia/nuxt'],
  pinia: {
    storesDirs: ['./stores/**'],  // 자동 import 경로
  },
})

Vite 프로덕션 빌드 최적화와 환경 변수 관리

환경 변수 계층과 보안 경계

Vite의 환경 변수는 접두어에 따라 노출 범위가 결정됩니다. 이 규칙을 정확히 이해하지 못하면 비밀 키가 번들에 포함되는 보안 사고가 발생할 수 있습니다.

# .env.production
VITE_API_BASE_URL=https://api.example.com   # ✅ 클라이언트 번들에 포함됨
VITE_APP_VERSION=1.0.0                       # ✅ 클라이언트 번들에 포함됨

DB_PASSWORD=secret123                        # ✅ Vite가 번들에 포함하지 않음
API_SECRET_KEY=super-secret                  # ✅ 서버 전용, 번들 제외
// vite.config.ts
import { defineConfig, loadEnv } from 'vite'

export default defineConfig(({ mode }) => {
  const env = loadEnv(mode, process.cwd(), '')

  return {
    build: {
      rollupOptions: {
        output: {
          // 벤더 라이브러리와 앱 코드를 분리 — 긴 캐시 활용
          manualChunks: {
            vendor: ['vue', 'vue-router', 'pinia'],
            ui: ['@headlessui/vue', '@heroicons/vue'],
          },
        },
      },
      // 청크 크기 경고 임계값 (기본 500KB)
      chunkSizeWarningLimit: 800,
    },
    define: {
      // 빌드 타임에 상수로 치환 — tree-shaking에 유리
      __APP_VERSION__: JSON.stringify(process.env.npm_package_version),
      __BUILD_TIME__: JSON.stringify(new Date().toISOString()),
    },
  }
})

코드 스플리팅과 동적 import

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

const router = createRouter({
  history: createWebHistory(),
  routes: [
    {
      path: '/',
      component: () => import('@/pages/Home.vue'), // ✅ 코드 스플리팅
    },
    {
      path: '/admin',
      // ✅ 동적 import만으로 자동 코드 스플리팅
      component: () => import('@/pages/Admin.vue'),
      meta: { requiresAuth: true },
    },
    {
      path: '/dashboard',
      component: () => import('@/pages/Dashboard.vue'),
    },
  ],
})

export default router

⚠️ 주의 /* webpackChunkName: "admin" */ 같은 매직 코멘트는 webpack 전용이라 Vite(Rollup) 프로젝트에서는 무시되어 청크 이름에 영향을 주지 않습니다. 또한 /* @vite-ignore */는 청크를 묶는 기능이 아니라, Vite가 동적 import 경로를 정적 분석·경고하지 않도록 지시하는 주석입니다. Vite에서 청크 이름이나 그룹화를 제어하려면 build.rollupOptions.outputmanualChunks(함수 형태)나 chunkFileNames를 사용하세요.

// vite.config.ts — 함수형 manualChunks로 청크 그룹화·이름 제어
export default defineConfig({
  build: {
    rollupOptions: {
      output: {
        // 모듈 경로에 따라 같은 청크로 묶고 이름을 지정
        manualChunks(id) {
          if (id.includes('/pages/Admin')) return 'admin'
          if (id.includes('/pages/Dashboard')) return 'dashboard'
        },
        // 청크 파일명 패턴 지정
        chunkFileNames: 'assets/[name]-[hash].js',
      },
    },
  },
})
<!-- 컴포넌트 레벨 지연 로딩 -->
<script setup lang="ts">
import { defineAsyncComponent } from 'vue'

// ✅ 무거운 차트 라이브러리는 필요할 때만 로드
const HeavyChart = defineAsyncComponent({
  loader: () => import('@/components/HeavyChart.vue'),
  loadingComponent: ChartSkeleton,
  errorComponent: ErrorFallback,
  delay: 200,      // 200ms 후 로딩 컴포넌트 표시
  timeout: 10000,  // 10초 초과 시 에러 컴포넌트
})
</script>

번들 분석

# 번들 구성 시각화 — 불필요하게 큰 의존성 파악
npm run build -- --mode production
npx vite-bundle-analyzer dist/stats.json

💡 TIP rollup-plugin-visualizer를 Vite 플러그인으로 추가하면 빌드 시 자동으로 stats.html이 생성되어 청크별 크기를 트리맵으로 확인할 수 있습니다.

전역 에러 처리·로깅·성능 모니터링

Vue 전역 에러 핸들러

// main.ts
import { createApp } from 'vue'
import App from './App.vue'

const app = createApp(App)

// ✅ Vue 컴포넌트 내 처리되지 않은 에러 전역 포착
app.config.errorHandler = (err, instance, info) => {
  // info: 에러가 발생한 생명주기 훅 또는 이벤트 핸들러 이름
  console.error('[Vue Error]', { err, info, component: instance?.$options.name })

  // 프로덕션에서는 Sentry 등 에러 추적 서비스로 전송
  if (import.meta.env.PROD) {
    reportError({ error: err, context: info })
  }
}

// 경고 처리 (개발 환경에서만)
if (import.meta.env.DEV) {
  app.config.warnHandler = (msg, instance, trace) => {
    console.warn('[Vue Warn]', msg, trace)
  }
}
// composables/useErrorBoundary.ts — 컴포넌트 단위 에러 경계
import { onErrorCaptured, ref } from 'vue'

export function useErrorBoundary() {
  const error = ref<Error | null>(null)

  onErrorCaptured((err) => {
    error.value = err
    return false // 에러 전파 중단 — 상위 errorHandler로 올라가지 않음
  })

  function resetError() {
    error.value = null
  }

  return { error, resetError }
}
<!-- components/ErrorBoundary.vue -->
<script setup lang="ts">
import { useErrorBoundary } from '@/composables/useErrorBoundary'

const { error, resetError } = useErrorBoundary()
</script>

<template>
  <div v-if="error" class="error-boundary">
    <p>문제가 발생했습니다: {{ error.message }}</p>
    <button @click="resetError">다시 시도</button>
  </div>
  <slot v-else />
</template>

Web Vitals 성능 모니터링

// utils/performance.ts
import type { Metric } from 'web-vitals'

export async function initWebVitals() {
  // web-vitals v4부터 FID가 폐지되고 INP가 Core Web Vitals 응답성 지표로 대체됨
  const { onCLS, onINP, onFCP, onLCP, onTTFB } = await import('web-vitals')

  const report = (metric: Metric) => {
    // 성능 데이터를 분석 엔드포인트로 전송
    const body = JSON.stringify({
      name: metric.name,
      value: metric.value,
      rating: metric.rating, // 'good' | 'needs-improvement' | 'poor'
      id: metric.id,
      page: window.location.pathname,
    })

    // sendBeacon은 페이지 언로드 시에도 안정적으로 전송
    navigator.sendBeacon('/api/vitals', body)
  }

  onCLS(report)
  onINP(report)
  onFCP(report)
  onLCP(report)
  onTTFB(report)
}
// main.ts
if (import.meta.env.PROD) {
  initWebVitals()
}

배포 파이프라인과 캐싱·CDN 전략

Vite 빌드 출력물의 캐싱 전략

Vite는 프로덕션 빌드 시 파일명에 콘텐츠 해시를 포함하므로, 내용이 변경된 파일만 새 해시를 갖게 됩니다.

dist/
├── index.html                    # 캐시 없음 (항상 최신)
├── assets/
│   ├── index-Bx4J2kD.js         # 앱 코드 — 변경 시 새 해시
│   ├── vendor-CQpz8Rkv.js       # 벤더 라이브러리 — 거의 변경 없음
│   └── style-Dm7Rz1A.css        # CSS
# nginx.conf — 해시 포함 정적 자산은 1년 캐시, HTML은 캐시 금지
server {
  location / {
    try_files $uri $uri/ /index.html;
    add_header Cache-Control "no-cache, no-store, must-revalidate";
  }

  location /assets/ {
    add_header Cache-Control "public, max-age=31536000, immutable";
    gzip_static on;
  }
}

CI/CD 파이프라인 예시 (GitHub Actions)

# .github/workflows/deploy.yml
name: Build and Deploy

on:
  push:
    branches: [main]

jobs:
  build-and-deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'

      - name: Install dependencies
        run: npm ci  # ✅ npm install 대신 ci 사용 — lockfile 기반 재현 가능 설치

      - name: Type check
        run: npm run type-check

      - name: Run tests
        run: npm run test

      - name: Build
        run: npm run build
        env:
          VITE_API_BASE_URL: ${{ secrets.VITE_API_BASE_URL }}

      - name: Deploy to CDN
        uses: cloudflare/wrangler-action@v3
        with:
          apiToken: ${{ secrets.CF_API_TOKEN }}
          command: pages deploy dist --project-name=my-app

CDN 캐시 무효화 전략

CDN을 사용할 때 배포 후 이전 캐시가 남아있는 문제는 파일명 해시 전략으로 대부분 해결되지만, index.html처럼 항상 최신이어야 하는 파일은 별도 처리가 필요합니다.

// Nuxt에서 ISR(증분 정적 재생성) 설정
// nuxt.config.ts
export default defineNuxtConfig({
  routeRules: {
    '/': { prerender: true },              // 빌드 시 정적 생성
    '/products/**': { swr: 3600 },         // 1시간마다 백그라운드 재검증
    '/admin/**': { ssr: true, cache: false }, // SSR, 캐시 없음
    '/api/**': { cors: true, headers: { 'cache-control': 's-maxage=60' } },
  },
})

💡 TIP Cloudflare나 Vercel 같은 플랫폼을 사용하면 배포 시 자동으로 이전 캐시를 무효화하는 기능을 제공합니다. 자체 CDN(AWS CloudFront 등)을 운영한다면 배포 스크립트에 invalidation 명령을 포함해야 합니다.

런타임 환경 설정 분리

빌드 타임에 환경 변수를 번들에 삽입하면 환경마다 다시 빌드해야 합니다. 런타임 설정 방식을 사용하면 하나의 빌드 결과물로 여러 환경에 배포할 수 있습니다.

<!-- index.html — 런타임 설정 주입 -->
<script>
  window.__APP_CONFIG__ = {
    apiBaseUrl: '%%API_BASE_URL%%',  // 서버 사이드 템플릿으로 치환
    featureFlags: {},
  }
</script>
HTML
// utils/config.ts
interface AppConfig {
  apiBaseUrl: string
  featureFlags: Record<string, boolean>
}

export function getConfig(): AppConfig {
  // 런타임 주입 설정이 있으면 우선, 없으면 Vite 환경 변수 폴백
  return (window as Window & { __APP_CONFIG__?: AppConfig }).__APP_CONFIG__ ?? {
    apiBaseUrl: import.meta.env.VITE_API_BASE_URL,
    featureFlags: {},
  }
}

요약

  • Pinia 플러그인($subscribe, $patch)을 활용하면 상태 영속화·로깅 등 횡단 관심사를 스토어 코드와 분리할 수 있으며, HMR을 위해 acceptHMRUpdate를 반드시 등록해야 합니다.
  • SSR과 SSG는 SEO·성능 면에서 CSR보다 유리하지만, 하이드레이션 불일치를 유발하는 비결정적 코드(Date.now(), 브라우저 전용 API)는 onMounted 또는 <ClientOnly>로 격리해야 합니다.
  • Nuxt에서 .server.ts/.client.ts 파일명 규약과 useAsyncData의 캐시 키 전략을 올바르게 사용해야 데이터 이중 요청과 요청 간 상태 오염을 방지할 수 있습니다.
  • Vite 환경 변수VITE_ 접두어가 있어야 클라이언트 번들에 포함되며, manualChunks로 벤더와 앱 코드를 분리하면 캐시 효율이 극대화됩니다.
  • app.config.errorHandleronErrorCaptured를 계층적으로 사용하면 전역 에러를 포착해 Sentry 등 외부 서비스로 전송할 수 있고, web-vitals로 실사용자 성능 지표를 수집할 수 있습니다.
  • 정적 자산의 콘텐츠 해시를 CDN에서 장기 캐시하고, index.html은 캐시를 비활성화하는 것이 가장 단순하고 효과적인 캐시 전략입니다.

연습문제

  1. Pinia 플러그인을 직접 작성하여 특정 스토어의 액션 호출 시마다 { action, before, after, timestamp } 형태로 콘솔에 로그를 남기는 액션 로거를 구현하세요.

  2. 다음 컴포넌트는 SSR 환경에서 하이드레이션 불일치를 일으킵니다. 원인을 설명하고 올바르게 수정하세요.

    <template>
      <div>
        <p>방문자 ID: {{ visitorId }}</p>
        <p>테마: {{ isDark ? '다크' : '라이트' }}</p>
      </div>
    </template>
    <script setup lang="ts">
    const visitorId = Math.random().toString(36).slice(2)
    const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches
    </script>
    
  3. Vite 프로덕션 빌드에서 vue, vue-router, piniavendor 청크로 분리하고, /src/pages 아래의 모든 페이지 컴포넌트가 라우터 레벨에서 지연 로딩되도록 vite.config.tsrouter/index.ts를 구성하세요.

  4. Nuxt 앱에서 /products 목록은 1시간마다 백그라운드 재검증(SWR), /products/[id] 상세 페이지는 빌드 시 정적 생성, /cart는 SSR이되 캐시 없이 항상 최신 데이터를 사용하도록 nuxt.config.tsrouteRules를 작성하세요.

힌트 각 문제는 이번 레슨의 코드 예제를 직접 응용하면 해결할 수 있습니다. 플러그인의 $onAction API, Vue의 <ClientOnly> 컴포넌트, Vite의 manualChunks, Nuxt의 routeRules를 확인하세요.

💡 연습문제 풀이

불러오는 중…

함께 보면 좋은 자료

댓글 0

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

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