Vite 5 고급 가이드 — Rollup·플러그인·HMR·프로덕션 최적화

이 글의 핵심

Vite 5에서 프로덕션 번들러로 쓰이는 Rollup 4와의 연동 방식, 환경별 설정, 커스텀 플러그인 작성, HMR 병목 제거, 프로덕션 청크 전략, React·Vue·Svelte 공식 플러그인과의 조합을 한 번에 정리합니다.

이 글의 핵심

Vite 5는 개발 서버에서는 네이티브 ESM과 esbuild 기반의 사전 번들로 빠른 시작을 제공하며, 프로덕션 빌드에서는 Rollup 4를 통해 트리 쉐이킹·코드 스플리팅·에셋 파이프라인을 완성합니다. 본 글은 입문용 “설치와 기본 설정”을 넘어, 환경별 구성, 커스텀 플러그인, HMR 튜닝, 번들 최적화, React·Vue·Svelte 통합을 실무 관점에서 연결합니다.

선행 지식: vite.config.ts, ESM, import.meta, Node 18+ 환경에 익숙하다고 가정합니다. Vite 기초는 Vite 빌드 도구 입문 글을 먼저 보시면 흐름이 매끄럽습니다.

버전에 대해 한 가지 짚어 둡니다. 이 글은 Vite 5를 기준으로 쓰였고, 이후 Vite 6(Environment API 도입)과 Vite 7(Node 20.19+ 요구, 기본 브라우저 타깃 상향)이 나왔습니다. 개발 서버와 프로덕션 번들을 나눠 보는 구조, 플러그인 훅, HMR 경계, 청크 전략 같은 이 글의 내용은 이후 버전에도 그대로 적용되지만, 설정 키나 기본값은 버전마다 조금씩 바뀌므로 업그레이드할 때는 각 버전의 마이그레이션 문서를 함께 확인해야 합니다. Vite 5부터 Node API의 CommonJS 빌드가 deprecated되어, vite.config.js를 CJS로 두면 The CJS build of Vite's Node API is deprecated 경고가 나오는 것도 이 시기의 대표적인 변화입니다. 설정 파일을 .mjs/.ts로 두거나 package.json에 "type": "module"을 지정하면 사라집니다.


아키텍처: 개발 서버와 프로덕션 번들

esbuild와 Rollup의 역할 분담

개발 모드에서 Vite는 의존성을 esbuild로 사전 번들(pre-bundle) 하며, 애플리케이션 소스는 변환만 거쳐 브라우저 네이티브 ESM으로 제공합니다. 반면 vite build는 Rollup을 통해 청크 그래프를 만들고, 압축·에셋 처리·CSS 분리 등을 수행합니다. 따라서 성능 이슈를 “개발”과 “빌드”로 나누어 진단하는 것이 첫 단계입니다.

의존성을 굳이 사전 번들하는 데는 두 가지 이유가 있습니다. 첫째, lodash-es 같은 패키지는 내부 파일이 수백 개라서 번들하지 않고 네이티브 ESM으로 그대로 제공하면 브라우저가 페이지를 열 때 수백 개의 HTTP 요청을 보내야 합니다. 둘째, npm에는 아직 CommonJS로만 배포되는 패키지가 많은데, 브라우저는 require를 이해하지 못하므로 ESM으로 변환해야 합니다. 사전 번들 결과는 node_modules/.vite에 캐시되고 lockfile이나 설정이 바뀔 때 다시 만들어집니다. 이 구조의 부작용으로, 개발 모드에서는 esbuild가, 빌드에서는 Rollup이 의존성을 처리하므로 CommonJS 변환 방식 차이 때문에 “개발 서버에서는 되는데 빌드 결과에서만 default is not a function 같은 에러가 난다”는 문제가 생길 수 있습니다. 이런 문제를 발견하는 가장 확실한 방법은 배포 전에 vite build && vite preview로 빌드 결과를 실제로 띄워 보는 것입니다.

Vite 4 대비: Rollup 3에서 Rollup 4로

Vite 5는 내부적으로 Rollup 4 계열을 사용합니다. 기존 Vite 4 + Rollup 3 시절의 커스텀 플러그인·설정을 이식할 때는 다음을 특히 점검합니다.

  • Import attributes 등 문법·플러그인 API 변화
  • this.resolve / this.parse 기본 동작 차이로 인한 해석 순서 변화
  • 순수 ESM 환경에서의 패키지 exports 필드 해석

공식 마이그레이션 문서의 브레이킹 체인지 목록과 함께, Lockfile과 peer dependency를 재설치하여 재현 가능한 빌드를 만드는 것이 안전합니다.


환경별 설정: 모드, 디렉터리, loadEnv

defineConfig와 조건부 분기

운영·스테이징·로컬에서 동일한 저장소를 쓰되 엔드포인트·플래그만 바꾸려면, 설정 파일에서 mode와 command를 기준으로 분기하는 패턴이 흔합니다.

// vite.config.ts
import { defineConfig, loadEnv } from 'vite';
import react from '@vitejs/plugin-react';

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

  return {
    plugins: [react()],
    define: {
      __APP_VERSION__: JSON.stringify(env.VITE_APP_VERSION ?? '0.0.0'),
    },
    server: {
      port: Number(env.VITE_DEV_PORT) || 5173,
    },
    build: {
      sourcemap: !isProd,
    },
  };
});

loadEnv는 .env, .env.local, .env.[mode] 등을 모드에 맞게 병합합니다. 클라이언트에 노출할 변수만 VITE_ 접두사를 붙이는 관례를 지키면, 실수로 서버 전용 비밀을 번들에 싣는 사고를 줄일 수 있습니다.

여기서 세 번째 인자 ''의 의미를 알아야 합니다. 기본값은 'VITE_'라서 접두사가 붙은 변수만 읽는데, ''를 주면 접두사와 무관하게 모든 환경 변수(DATABASE_URL 같은 비밀 포함)를 설정 파일 안으로 가져옵니다. 설정 파일은 Node에서 실행되므로 이것 자체는 문제가 아니지만, 이렇게 읽은 값을 define에 넘기는 순간 번들에 문자열로 박힙니다. 특히 define: { 'process.env': env }처럼 통째로 넘기는 코드는 모든 비밀을 클라이언트 JS에 그대로 노출하므로 절대 쓰면 안 됩니다. define은 코드 속 식별자를 문자열 그대로 치환하는 기능이라, 위 예제처럼 반드시 JSON.stringify로 감싸야 한다는 점도 자주 실수하는 부분입니다. __APP_VERSION__: env.VITE_APP_VERSION처럼 쓰면 1.2.3이 따옴표 없이 코드에 들어가 문법 에러가 납니다. 또 .env 파일의 값은 빌드 시점에 고정되므로, 같은 빌드 산출물을 스테이징과 운영에 재사용하면서 API 주소만 바꾸고 싶다면 런타임에 /config.json을 불러오는 방식이 필요합니다.

NODE_ENV와 import.meta.env의 관계

Vite는 import.meta.env.MODE, import.meta.env.PROD, import.meta.env.DEV 등을 주입합니다. 라이브러리 코드에서 process.env.NODE_ENV를 직접 참조하는 경우, define 치환 또는 호환 플러그인이 필요할 수 있으므로, 배포 파이프라인에서 프로덕션 빌드 한 번으로 재현되는지 꼭 확인합니다.


Rollup 옵션과 Vite 빌드 파이프라인

build.rollupOptions로 청크 전략 잡기

프로덕션 성능의 핵심은 초기 JS 페이로드와 캐시 무효화 범위의 균형입니다. manualChunks로 벤더·라우트 단위를 나누되, 너무 잘게 쪼개면 HTTP/2에서도 오버헤드가 커질 수 있습니다.

// vite.config.ts (발췌)
export default defineConfig({
  build: {
    rollupOptions: {
      output: {
        manualChunks(id) {
          if (id.includes('node_modules')) {
            if (id.includes('react-dom')) return 'vendor-react-dom';
            if (id.includes('react')) return 'vendor-react';
            return 'vendor';
          }
        },
      },
    },
  },
});

실무에서는 번들 분석기(rollup-plugin-visualizer 등) 로 실제 청크 크기를 본 뒤, 라이브러리별로 그룹을 조정합니다. 팀 내에서 “벤더는 장기 캐시, 앱 코드는 자주 배포” 같은 정책을 맞춰 두면 운영이 단순해집니다.

예제의 id.includes('react')는 경로 어딘가에 react가 들어간 모든 모듈을 잡으므로 react-router, @tanstack/react-query, react-hook-form까지 vendor-react에 들어갑니다. 의도한 분리라면 /node_modules/react/처럼 경로 구분자까지 포함해 정확히 매칭하는 것이 좋습니다. 더 조심할 것은 manualChunks로 모듈을 억지로 옮기면 청크 사이에 순환 의존이 생길 수 있다는 점입니다. 이 경우 빌드는 성공하지만 브라우저에서 Cannot access 'x' before initialization 같은 초기화 순서 에러가 나고, 개발 서버에서는 재현되지 않아 원인을 찾기 어렵습니다. 모든 node_modules를 하나의 vendor 청크로 모으는 방식도 흔한데, 라우트 하나에서만 쓰는 무거운 라이브러리(차트, 에디터)까지 첫 화면 로딩에 포함되므로 동적 import()로 분리된 라이브러리는 벤더 청크에서 빼는 편이 초기 로딩에 유리합니다.

resolve.alias와 모노레포

모노레포에서 패키지 경로를 workspace:*로 묶을 때, Vite의 resolve.alias는 개발 서버와 빌드 모두에 영향을 줍니다. 조건부 exports 가 있는 패키지는 main/module만 보고 착각하기 쉬우므로, 실제 해석 결과를 vite --debug 또는 플러그인 로그로 확인하는 것이 좋습니다.


커스텀 플러그인 개발

Vite 플러그인의 최소 골격

Vite 플러그인은 Rollup 호환 훅에 더해 configureServer, transformIndexHtml 등 개발 서버 전용 훅을 제공합니다. 아래는 가상 모듈을 노출하는 작은 예입니다.

// plugins/virtual-config.ts
import type { Plugin } from 'vite';

export function virtualConfigPlugin(data: Record<string, unknown>): Plugin {
  const virtualId = '\0virtual:app-config';

  return {
    name: 'virtual-app-config',
    resolveId(id) {
      if (id === 'virtual:app-config') return virtualId;
    },
    load(id) {
      if (id === virtualId) {
        return `export default ${JSON.stringify(data)};`;
      }
    },
  };
}

resolveId에서 \0 접두사를 쓰면 실제 파일 시스템과 충돌을 줄일 수 있습니다. 가상 모듈은 HMR 경로를 설계할 때 모듈 그래프 상 식별자로 취급해야 합니다.

\0 접두사는 Rollup 생태계의 관례로, “이 ID는 실제 파일이 아니니 다른 플러그인(특히 파일을 읽어 변환하는 플러그인)은 건드리지 말라”는 신호입니다. 접두사 없이 virtual:app-config를 그대로 ID로 돌려주면 다른 플러그인이 이것을 경로로 착각해 파일을 찾으려다 ENOENT 에러를 내거나, CSS·JSON 플러그인이 확장자를 보고 엉뚱한 변환을 시도할 수 있습니다. TypeScript에서 이 가상 모듈을 import하려면 declare module 'virtual:app-config' { const config: Record<string, unknown>; export default config; } 같은 타입 선언을 .d.ts에 추가해야 Cannot find module 에러가 사라집니다. 또 data는 설정 파일이 로드될 때 한 번 직렬화되므로, 개발 중 원본 설정이 바뀌어도 반영하려면 configureServer에서 파일을 감시하고 해당 가상 모듈을 무효화하는 처리가 따로 필요합니다.

enforce와 apply로 실행 순서 제어

  • enforce: 'pre' | 'post': 동일 훅 내에서 다른 플러그인보다 먼저/나중에 실행
  • apply: 'serve' | 'build' | ((config, env) => boolean): 개발 전용·빌드 전용 분리

예를 들어 개발 서버에서만 목(mock) 데이터를 주입하며, 프로덕션에서는 완전히 제거하려면 apply: 'serve'로 비용을 없앨 수 있습니다.

Rollup 플러그인 호환성 점검 체크리스트

  1. buildStart/generateBundle 등에서 부수 효과(파일 쓰기)가 개발 모드에도 돌지 않는지
  2. this.getModuleInfo 등 Rollup 4 API 변화에 맞는지
  3. CSS/에셋을 다루는 경우 Vite의 자산 URL 처리와 충돌하지 않는지

HMR 최적화 기법

병목: 의존성 사전 번들과 감시 범위

HMR이 느릴 때 흔한 원인은 거대한 node_modules 의존성이 매번 재처리되거나, 불필요하게 넓은 파일 감시입니다. optimizeDeps로 명시적 포함/제외를 지정하면 cold start와 재시작 이후 안정성이 좋아집니다.

// vite.config.ts (발췌)
export default defineConfig({
  optimizeDeps: {
    include: ['react', 'react-dom', 'scheduler'],
    exclude: ['큰-로컬-패키지-이름'],
  },
  server: {
    watch: {
      ignored: ['**/coverage/**', '**/dist/**'],
    },
  },
});

모노레포 루트에 로그·빌드 산출물이 많다면 watch.ignored로 OS 파일 감시 한도에 걸리는 문제를 완화할 수 있습니다.

server.hmr와 네트워크 레이어

Docker, WSL2, 원격 컨테이너, HTTPS 프록시 뒤에서 개발할 때는 WebSocket 경로·포트·프로토콜 불일치로 HMR이 끊깁니다. 이때 server.hmr의 클라이언트가 접속해야 할 호스트/포트/프로토콜을 명시적으로 맞춥니다.

server: {
  host: true,
  port: 5173,
  strictPort: true,
  hmr: {
    protocol: 'wss',
    host: 'localhost',
    clientPort: 443,
  },
},

환경마다 값이 달라지면 환경 변수로 주입하며, 팀 문서에 프록시(Nginx, Caddy)의 Upgrade 헤더 설정을 함께 적어 두는 것이 좋습니다.

이 설정이 필요한 전형적인 증상은 브라우저 콘솔에 [vite] failed to connect to websocket이 반복해서 찍히고, 파일을 저장해도 화면이 갱신되지 않는 것입니다. 원인은 대개 “Vite 서버가 듣는 주소”와 “브라우저가 접속하는 주소”가 다르기 때문입니다. 예를 들어 Nginx가 443에서 TLS를 종료하고 5173으로 넘기는 구성이라면, 브라우저는 wss://도메인:443으로 붙어야 하는데 Vite 클라이언트는 기본적으로 ws://...:5173에 붙으려 합니다. clientPort와 protocol은 바로 이 “브라우저 쪽에서 본 주소”를 알려 주는 옵션입니다. Docker 컨테이너 안에서 실행하면 host: true(0.0.0.0 바인딩)가 없어서 호스트에서 접속 자체가 안 되는 경우도 흔합니다. WSL2에서 Windows 쪽 파일 시스템(/mnt/c/...)에 프로젝트를 두면 파일 변경 이벤트가 전달되지 않아 HMR이 동작하지 않는데, 이때는 프로젝트를 WSL 내부 파일 시스템으로 옮기는 것이 가장 확실하고, 불가피하면 server.watch.usePolling: true로 폴링을 켭니다(CPU 사용량이 늘어납니다).

경계 모듈과 “풀 리로드” 최소화

순수 부수 효과가 큰 모듈(전역 스토어 초기화, 사이드 이펙트 import)은 HMR 시 풀 리로드로 떨어지기 쉽습니다. 상태 관리 라이브러리·컨텍스트 설계 시 모듈 단위로 경계를 나누고, 가능하면 import.meta.hot.accept로 국소 갱신을 허용하는 패턴을 고려합니다.

HMR이 동작하는 원리를 알면 “왜 이 파일만 고치면 전체가 새로고침되는가”를 설명할 수 있습니다. 파일이 바뀌면 Vite는 모듈 그래프를 따라 import한 쪽으로 거슬러 올라가며 변경을 받아들일(accept) 모듈을 찾습니다. React 컴포넌트 파일은 플러그인이 자동으로 accept 경계를 만들어 주므로 그 컴포넌트만 교체되지만, 경계를 찾지 못한 채 진입점까지 올라가면 전체 페이지를 다시 불러옵니다. React Fast Refresh에서 흔한 원인은 한 파일에서 컴포넌트와 컴포넌트가 아닌 값(상수, 유틸 함수, Context 객체)을 함께 export하는 것입니다. 이런 파일은 안전하게 부분 교체할 수 없어 경계가 되지 못하고, @vitejs/plugin-react는 이때 콘솔에 “Could not Fast Refresh” 류의 안내를 남깁니다. 컴포넌트와 나머지 export를 파일로 나누는 것만으로 풀 리로드가 사라지는 경우가 많습니다.

오버레이와 로그 소음

server.hmr.overlay로 런타임 에러 오버레이를 끄거나 줄일 수 있습니다. 다만 CI나 스토리북 환경에서는 에러를 숨기기보다 근본 원인을 제거하는 편이 안전합니다.


프로덕션 빌드 최적화

압축기 선택: esbuild vs terser

Vite는 기본적으로 esbuild로 minify합니다. 호이스팅·특정 라이브러리와의 호환 이슈로 더 보수적인 압축이 필요하면 build.minify: 'terser'와 terserOptions를 검토합니다. 대신 빌드 시간이 늘어날 수 있으므로 CI에서 측정합니다. Vite 3부터 terser는 기본 의존성에서 빠졌으므로 npm i -D terser로 직접 설치해야 하며, 설치하지 않으면 terser not found 에러로 빌드가 멈춥니다. terser를 쓰는 흔한 이유 중 하나는 drop_console처럼 esbuild에 없는 세밀한 옵션인데, 콘솔 로그 제거만이 목적이라면 esbuild: { drop: ['console', 'debugger'] }로도 가능합니다.

CSS 코드 스플리팅과 assetsInlineLimit

build.cssCodeSplit은 라우트 단위 CSS 분리에 유리합니다. 작은 에셋은 Data URL 인라인으로 HTTP 요청 수를 줄이는데, build.assetsInlineLimit으로 기준을 조정합니다. 디자인 시스템 아이콘 SVG가 많다면 스프라이트·심볼 스택과 함께 전략을 통일합니다.

build.target과 폴리필

build.target은 출력 문법 수준을 결정합니다. 구형 브라우저 지원 범위가 넓다면 트랜스파일 부담이 커지므로, Browserslist·실 사용자 데이터를 기준으로 타깃을 좁히는 것이 우선입니다. 폴리필은 필요한 API만 core-js 등으로 채우고, 불필요한 전역 주입을 피합니다.

소스맵과 배포

build.sourcemap을 켜면 디버깅은 쉬워지지만 산출물 크기·배포 파이프라인이 무거워집니다. Sentry 등에 숨은 소스맵을 올리는 경우, 공개 디렉터리에 노출되지 않게 CI 단계를 분리합니다. build.sourcemap: 'hidden'은 .map 파일은 만들되 JS 파일 끝의 //# sourceMappingURL 주석을 넣지 않아, 브라우저가 자동으로 소스맵을 찾지 않게 합니다. 다만 .map 파일이 배포 디렉터리에 그대로 남아 있으면 URL을 추측해 원본 소스를 받아 갈 수 있으므로, 에러 추적 서비스에 업로드한 뒤 배포 산출물에서 지우는 단계가 함께 있어야 의미가 있습니다. 앞의 설정 예제처럼 sourcemap: !isProd로 두면 운영 빌드에는 소스맵이 아예 없어 운영 에러의 스택 트레이스가 압축된 코드 기준으로만 남는다는 점도 감안해야 합니다.


React·Vue·Svelte 통합

React: @vitejs/plugin-react와 SWC 옵션

공식 React 플러그인은 Fast Refresh를 지원합니다. 대규모 코드베이스에서는 @vitejs/plugin-react-swc 로 컴파일러 레이어를 바꿔 리프레시·빌드 시간을 줄이는 사례가 많습니다. 둘 다 쓸 수 없으므로 하나만 선택합니다.

import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [
    react({
      babel: {
        plugins: [['babel-plugin-styled-components', { displayName: true, ssr: false }]],
      },
    }),
  ],
});

스타일드 컴포넌트·emotion 등 Babel 플러그인이 필수라면 SWC 대신 Babel 경로를 유지하는 결정이 자연스럽습니다.

Vue: @vitejs/plugin-vue와 <script setup>

Vue SFC는 Vite와 궁합이 좋습니다. 타입 기반 defineProps 등 최신 문법을 쓸 때는 vue-tsc로 빌드 전 타입 검증을 병행합니다. 라우트 기반 코드 스플리팅은 import() 동적 로딩과 함께 청크 이름을 관찰합니다.

Svelte: vite-plugin-svelte와 전처리

SvelteKit이 아닌 순수 Vite + Svelte를 쓸 때는 vite-plugin-svelte 설정에서 전처리기(sass, postcss) 와 HMR 안정성 옵션을 맞춥니다. 스타일 전역 오염을 막기 위해 컴포넌트 스코프 CSS 전략을 팀 규칙으로 두면 HMR 경로도 단순해집니다.

프레임워크 공통: SSR·미들웨어

Vite 자체는 SSR 가이드를 제공하지만, Next·Nuxt·SvelteKit 같은 프레임워크가 통합 스택을 제공하는 경우가 많습니다. “Vite만” 도입할지 “풀스택 프레임워크”를 쓸지에 따라 라우팅·데이터 로딩·환경 분리 책임이 달라지므로, 팀 역량과 배포 환경에 맞춰 선택합니다.


트러블슈팅 요약

증상우선 확인
cold start 느림optimizeDeps, 사전 번들 캐시, 불필요한 플러그인
HMR 미작동프록시·WSS·server.hmr·방화벽
빌드 OOM청크 분할, build.rollupOptions, Node 메모리 플래그
해석 오류resolve.dedupe, 패키지 exports, alias 충돌

맺음말

Vite 5는 개발 경험과 프로덕션 번들을 명확히 분리된 두 레이어로 다루는 도구입니다. Rollup 4 기반 빌드, loadEnv를 통한 환경 주입, 플러그인의 apply/enforce, HMR 네트워크·사전 번들 튜닝, 프레임워크 플러그인 선택을 한 흐름으로 설계하면, 팀 전체의 빌드 시간과 디버깅 비용을 동시에 줄일 수 있습니다. 변경이 클 때는 마이그레이션 문서·번들 리포트·실제 배포 스테이징 세 가지를 항상 세트로 두시기 바랍니다.


자주 묻는 질문 (FAQ)

Q. vite build가 메모리 부족(OOM)으로 실패하면 어떻게 하나요?

A. 먼저 build.rollupOptions의 manualChunks 등으로 거대한 청크를 나눠 한 번에 처리하는 모듈 그래프를 줄여 봅니다. 프로젝트 규모 자체가 크다면 Node의 —max-old-space-size 플래그로 힙 한도를 늘리는 것이 현실적인 대응입니다. 빌드에만 필요한 무거운 플러그인이 있다면 apply: ‘build’처럼 적용 범위를 명시하고, 불필요한 플러그인을 걷어내는 것도 메모리와 시간을 함께 줄여 줍니다.


같이 보면 좋은 글