FE
[Vite] Vite 들어보셨어요? (Vite 구성)
| 서론
안녕하세요, 팡일입니다.
프론트엔드 프로젝트를 진행하다 보면 vite.config.ts 파일을 자주 마주하게 됩니다. 처음에는 단순히 플러그인이나 alias 정도를 설정하는 파일처럼 보이지만, 실제로는 개발 서버 실행 방식, 빌드 전략, 환경 변수 처리, 조건부 설정까지 프로젝트의 실행 환경 전반을 제어하는 중요한 파일입니다.
이번 글에서는 Vite 공식 문서의 'VITE 구성' 내용을 바탕으로, config 파일이 어떻게 로드되고 실행되는지, defineConfig를 사용하는 이유는 무엇인지, 환경별 설정과 환경 변수는 어떻게 다루는지 정리해보려고 합니다.
단순히 설정 코드를 복사해서 사용하는 것을 넘어, 각 옵션이 어떤 흐름에서 동작하는지 이해하는 데 초점을 맞춰보겠습니다.
| Vite 구성 파일이란?
Vite는 실행 시 프로젝트 루트에서 vite.config.js 또는 vite.config.ts 파일을 자동으로 탐색하여 설정을 적용합니다. 이 파일은 개발 서버 동작 방식부터 번들링 전략까지, 프로젝트의 전반적인 빌드 환경을 제어하는 핵심 설정 파일입니다.
가장 기본적인 형태는 다음과 같이 단순한 객체를 export 하는 구조입니다.
export default {
// config options
}하지만 실제 프로젝트에서는 플러그인을 추가하거나, 외부 모듈을 import하는 등 설정이 점점 확장되기 때문에 단순 객체 형태를 넘어서는 구조를 사용하게 됩니다. 이때 자연스럽게 ES Module 문법을 사용하게 되며, Node.js의 기본 동작 방식과의 차이를 이해하는 것이 중요해집니다.
1) Vite와 ES Module
일반적으로 Node.js 환경에서는 import / export 문법을 사용하기 위해 package.json에 아래 설정이 필요합니다
{
"type": "module"
}이 설정이 없으면 Node는 기본적으로 CommonJS 방식(require)을 사용하기 때문에, ES Module 문법을 제대로 해석하지 못합니다.
하지만 Vite는 이 제약에서 비교적 자유롭습니다.
Vite는 config 파일을 그대로 실행하는 것이 아니라, 실행 전에 내부적으로 번들링 및 트랜스파일 과정을 거쳐 Node가 이해할 수 있는 형태로 변환한 뒤 실행합니다. 이 과정을 통해 type: "module" 설정이 없어도 import 문법이나 최신 JavaScript, TypeScript 문법을 문제없이 사용할 수 있습니다.
즉, Vite의 구성 파일은 단순한 Node 실행 파일이 아니라, Vite가 한 번 가공해서 실행하는 파일이라고 이해하는 것이 더 정확합니다.
번들링이란?: 여러 개로 나뉘어 있는 파일들을 하나로 묶어, 실행하기 쉬운 형태로 만드는 과정입니다.ex) 예를 들어, 여러 JavaScript 파일을 하나의 파일로 합쳐 브라우저가 한 번에 로드할 수 있도록 정리하는 작업입니다.트랜스파일이란?: 최신 문법으로 작성된 코드를, 더 낮은 버전의 환경에서도 실행할 수 있도록 다른 형태의 코드로 변환하는 과정입니다.ex) 예를 들어, ES6 이상의 문법을 ES5로 변환하여 구형 브라우저에서도 동작하게 만드는 작업입니다.
2) defineConfig를 사용하는 이유
이러한 특성 덕분에 Vite에서는 단순 객체 export 대신 defineConfig를 사용하는 것이 일반적입니다.
import { defineConfig } from "vite";
export default defineConfig({
// config options
});defineConfig를 사용하는 이유는 다음과 같습니다.
TypeScript 기반 타입 추론 지원
IDE 자동완성(Intellisense) 제공
설정 객체의 구조를 명확하게 유지
잘못된 옵션 입력 시 오류 방지
즉, 단순히 문법적인 선택이 아니라, 개발 경험과 안정성을 높이기 위한 표준 방식이라고 볼 수 있습니다.
사실, defineConfig를 사용하지 않아도 Intellisense를 적용할 수 있습니다. 아래와 같이 JS 환경에서는 JSDoc 타입을 명시하는 방법으로도 가능하지만, 이 방식은 다소 번거롭고 가독성이 떨어질 수 있어, defineConfig 사용을 권장합니다.
/** @type {import('vite').UserConfig} */
export default {
// ...
}Intellisense란?: IDE에서 제공하는 코드 보조 기능으로, 자동완성, 타입 힌트, 오류 표시 등을 통해 개발자가 코드를 더 쉽고 정확하게 작성할 수 있도록 도와주는 기능입니다.ex) 예를 들어, Vite 설정을 작성할 때 사용할 수 있는 옵션 목록을 자동으로 보여주거나, 잘못된 옵션을 입력했을 때 미리 경고를 표시해주는 역할을 합니다.
3) config 파일 직접 지정하기
기본적으로 Vite는 vite.config.* 파일을 자동으로 찾지만, 필요에 따라 다른 설정 파일을 직접 지정할 수도 있습니다.
vite --config my-config.js이 옵션은 현재 작업 디렉토리(cwd)를 기준으로 파일을 찾아 실행합니다.
이 방식은 다음과 같은 상황에서 유용하게 사용됩니다.
개발용 / 운영용 설정을 분리할 때
실험적인 번들 설정을 테스트할 때
모노레포 구조에서 프로젝트별 설정을 나눌 때
| 구성 파일 로딩 방식
앞서 살펴본 것처럼 Vite는 config 파일에서 ES Module 문법을 별도의 설정 없이 사용할 수 있습니다. 이러한 동작은 Vite의 구성 파일 로딩 방식과 밀접하게 연결되어 있습니다.
기본적으로 Vite는 config 파일을 그대로 실행하지 않고, 내부적으로 한 번 가공한 뒤 실행합니다.
1) 기본 로딩 방식 (bundle)
Vite는 기본적으로 config 파일을 번들링한 뒤 실행합니다. 즉, 우리가 작성한 config 파일은 아래와 같은 과정을 거칩니다.
작성한 config (TS / ESM)
-> Vite 내부 번들링 (Rolldown 기반)
-> Node가 실행 가능한 형태로 변환
-> 실행이 과정을 통해 다음과 같은 것들이 가능해집니다.
TypeScript config 파일 사용 (vite.config.ts)
import / export 문법 사용
최신 JavaScript 문법 사용
즉, 앞에서 설명했던 “ES Module 제약 없이 동작하는 이유”가 바로 이 로딩 방식 때문입니다.
2) 번들 방식의 한계
이 방식은 대부분의 경우 문제없이 동작하지만, 모노레포 환경이나 복잡한 TypeScript import 구조에서는 문제가 발생할 수 있습니다.
이럴 때는 다른 로딩 방식을 선택할 수 있습니다.
(1) runner 방식
vite --configLoader runner번들링 없이 config 파일을 실시간으로 변환하여 실행
TypeScript import 문제 해결에 유리
모노레포 환경에서 안정적으로 동작
(2) native 방식
vite --configLoader nativeNode의 네이티브 런타임을 그대로 사용
별도의 번들링 과정 없음
하지만, native 방식의 경우, config 파일 변경 시 자동 재시작이 되지 않고, 최신 문법 지원은 Node 환경에 의존한다는 점을 주의해야 합니다.
| 조건부 설정
일반적인 Vite 프로젝트에서는 아래처럼 객체 형태로 설정을 작성하는 경우가 많습니다.
export default defineConfig({
plugins: [react()],
server: {
host: true,
},
build: {
rollupOptions: {
// build 관련 설정
},
},
});이 방식은 정적 설정입니다. 즉, Vite 실행 상황과 관계없이 동일한 설정 객체를 전달하는 방식입니다.
다만 여기서 중요한 점은, 모든 옵션이 항상 같은 시점에 사용되는 것은 아니라는 점입니다.
예를 들어 package.json에서 설정한 command를 보면, server 옵션은 주로 개발 서버를 실행할 때 사용되고, build 옵션은 프로덕션 빌드를 수행할 때 사용됩니다.
{
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview"
}
}위와 같은 script가 있다고 하면, 각각의 실행 흐름은 다음과 같습니다.
script | 실제 실행 명령어 | Vite 내부 상태 |
npm run dev | vite | command: "serve" |
npm run build | vite build | command: "build" |
npm run preview | vite preview | isPreview: true |
여기서 중요한 점은 command 값을 개발자가 직접 넘겨주는 것이 아니라는 점입니다.
vite, vite build, vite preview처럼 어떤 명령어로 Vite를 실행했는지에 따라 Vite가 내부적으로 값을 결정하고, config 함수에 전달해줍니다.
1) 조건부 설정이 필요한 이유
대부분의 경우에는 정적 설정만으로 충분합니다.
하지만 개발 환경과 실제 배포 환경의 요구사항이 달라질 경우, 실행 상황에 따라 다른 설정을 적용해야 할 수 있습니다.
예를 들어 다음과 같은 차이가 있습니다.
환경 | 주요 목적 |
개발 환경 | 빠른 실행, 디버깅, 개발 편의성 |
프로덕션 환경 | 번들 최적화, 압축, 성능 개선 |
preview 환경 | 빌드 결과 확인 |
SSR 빌드 | 서버 사이드 렌더링 환경 대응 |
이런 경우 Vite config를 단순 객체가 아니라 함수 형태로 작성할 수 있습니다.
export default defineConfig(({ command, mode }) => {
if (command === "serve") {
return {
// 개발 환경 설정
};
}
return {
// 빌드 환경 설정
};
});2) 주요 파라미터 설명
Vite는 config 함수에 현재 실행 상태 정보를 전달해줍니다.
값 | 설명 |
command | serve(개발 서버) / build (프로덕션 빌드) |
mode | development / production (또는 커스텀 모드) |
isSsrBuild | SSR(Server Side Rendering) 빌드 여부 |
isPreview | 빌드 결과를 미리보기(vite preview) 하는 경우 |
(1) command 값 이해하기
: 여기서 많이 헷갈리는 부분이 command입니다.
vite, vite dev, vite serve → 모두 serve
vite build → build
즉, 개발 서버를 띄우는 모든 명령어는 serve로 들어온다고 이해하면 됩니다.
(2) SSR / Preview 옵션 주의사항
: isSsrBuild와 isPreview는 추가적인 상태를 구분하기 위한 값입니다.
다만, 일부 환경에서는 이 값이 undefined로 들어올 수 있기 때문에 다음과 같이 명확하게 비교하는 것이 안전합니다.
if (isSsrBuild === true) {
// SSR 전용 설정
}
if (isPreview === true) {
// preview 전용 설정
}3) 실제 사용 예시
(1) if문 예시
import { defineConfig, type UserConfig } from "vite";
export default defineConfig(({ command, mode, isSsrBuild, isPreview }) => {
const config: UserConfig = {};
const isDev = command === "serve";
const isProd = mode === "production";
// 개발 서버 실행 시 적용
if (isDev) {
config.server = {
port: 5173,
open: true,
};
}
// 빌드 시 적용
if (command === "build") {
config.build = {
minify: isProd ? "esbuild" : false,
sourcemap: !isProd,
};
}
// SSR 빌드일 때만 적용
if (isSsrBuild === true) {
config.ssr = {
noExternal: ["some-ssr-only-lib"],
};
}
// vite preview 실행 시 적용
if (isPreview === true) {
config.preview = {
port: 4173,
};
}
// 앱 코드에서 사용할 수 있는 전역 상수 주입
config.define = {
__DEV__: JSON.stringify(isDev),
__MODE__: JSON.stringify(mode),
};
return config;
});(2) spread 패턴 예시
import { defineConfig } from "vite";
export default defineConfig(({ command, mode, isSsrBuild, isPreview }) => {
const isDev = command === "serve";
const isProd = mode === "production";
return {
// 개발 서버 설정
server: isDev
? {
port: 5173,
open: true, // 실행 시 브라우저 자동 오픈
}
: undefined,
// 빌드 설정
build: {
minify: isProd ? "esbuild" : false, // 운영에서는 압축, 개발에서는 비활성화
sourcemap: !isProd, // 개발 시 디버깅을 위해 sourcemap 활성화
},
// SSR 환경일 때만 적용
...(isSsrBuild === true && {
ssr: {
noExternal: ["some-ssr-only-lib"],
},
}),
// preview 환경일 때만 적용
...(isPreview === true && {
preview: {
port: 4173,
},
}),
// 환경별 define 값 주입
define: {
__DEV__: JSON.stringify(isDev),
__MODE__: JSON.stringify(mode),
},
};
});4) 기존 정적 설정과의 차이
현재 프로젝트의 vite.config.ts가 아래와 같은 형태라면 조건부 설정을 꼭 사용할 필요는 없습니다.
export default defineConfig({
plugins: [react()],
server: {
host: true,
},
build: {
rollupOptions: {
output: {
manualChunks() {
// chunk 분리 설정
},
},
},
},
});이 경우 server는 개발 서버에서, build는 빌드 시점에서 자연스럽게 사용됩니다.
즉, Vite가 옵션의 목적에 맞게 필요한 시점에 적용하기 때문에, 단순한 프로젝트에서는 정적 객체 설정만으로도 충분합니다.
다만 아래와 같은 상황이라면 함수형 config를 고려할 수 있습니다.
개발 환경과 운영 환경에서 sourcemap 설정을 다르게 하고 싶을 때
특정 mode에서만 플러그인을 추가하고 싶을 때
SSR 빌드와 일반 빌드를 구분해야 할 때
preview 환경에서만 별도 포트를 사용하고 싶을 때
staging, production 등 커스텀 배포 환경을 나누고 싶을 때
| 비동기 설정 (Async Config)
지금까지는 config를 동기적으로 작성하는 방법을 살펴봤습니다.
하지만 실제 프로젝트에서는 API 호출이나 외부 데이터를 기반으로 설정을 구성해야 하는 경우도 있습니다.
이럴 때 Vite는 비동기 함수 형태의 config를 지원합니다.
import { defineConfig } from "vite";
export default defineConfig(async ({ command, mode }) => {
const data = await asyncFunction();
return {
// vite config
};
});즉, config 파일에서도 async / await를 사용할 수 있습니다.
1) 언제 사용하면 좋을까?
단순히 “가능하다”보다 중요한 건 언제 써야 하냐입니다.
바로, config를 만들기 전에 외부 데이터를 먼저 알아야 할 때가 핵심 기준입니다.
(1) 사례1 : 외부 설정 파일(JSON) 기반 config
import { defineConfig } from "vite";
import fs from "fs/promises";
export default defineConfig(async () => {
const configFile = await fs.readFile("./app.config.json", "utf-8");
const appConfig = JSON.parse(configFile);
return {
server: {
port: appConfig.port,
},
};
});팀에서 공통 config 파일 관리
프로젝트마다 port / baseURL 다름
(2) 사례2 : API 서버에서 설정 받아오기
import { defineConfig } from "vite";
export default defineConfig(async () => {
const res = await fetch("https://api.my-service.com/config");
const remoteConfig = await res.json();
return {
define: {
__API_URL__: JSON.stringify(remoteConfig.apiUrl),
},
};
});서버에서 환경 설정 내려줌
staging / production 환경 동적으로 관리
(3) 사례3 : feature flag 기반 설정
import { defineConfig } from "vite";
export default defineConfig(async () => {
// “어떤 기능을 켤지/끌지 결정하는 설정을 가져오는 함수”
const flags = await fetchFeatureFlags();
return {
plugins: [
flags.useNewPlugin ? newPlugin() : null,
].filter(Boolean),
};
});특정 기능을 켜고 끄는 실험 환경 (A/B 테스트)
배포 없이 config 변경
(4) 사례4 : 환경별로 동적으로 plugin 구성
import { defineConfig } from "vite";
export default defineConfig(async ({ mode }) => {
const plugins = [];
if (mode === "production") {
const { default: analyzer } = await import("rollup-plugin-visualizer");
plugins.push(analyzer());
}
return {
plugins,
};
});production에서만 번들 분석
필요할 때만 라이브러리 로드
(5) 언제 “굳이 안 써도 되는가?”
export default defineConfig({
server: { port: 5173 },
});이미 값이 정해져 있음
외부 의존 없음
| 환경 변수 (Environment Variables)
Vite에서 환경 변수를 사용할 때 가장 많이 헷갈리는 부분이 바로 “config에서 env가 바로 안 읽힌다”는 점입니다.
1) 중요한 포인트
config 파일이 실행되는 시점에는 .env 파일이 자동으로 로드되지 않습니다.
즉, 아래 코드는 기대한 대로 동작하지 않을 수 있습니다.
process.env.APP_PORT // ❌ undefined 가능여기서 중요한 점은 config 실행 시점에 접근 가능한 환경 변수는 이미 현재 프로세스(process.env)에 존재하는 값만 가능하다는 것입니다.
2) 왜 이런 구조일까?
Vite는 .env 파일을 config 실행 이후에 로드합니다.
그 이유는 다음과 같습니다.
어떤 .env 파일을 읽을지는 mode에 따라 달라짐
.env.development, .env.production 등 분기 필요
root, envDir 같은 설정도 config에 의해 결정됨
흐름이 "config 실행 → 어떤 env를 쓸지 결정 → env 로드"와 같기에, config 실행 시점에는 .env가 아직 로드되지 않은 상태입니다.
3) 일반적인 사용 방식
앱 코드와 같이 클라이언트 번들에 포함되는 코드에서는 .env값을 다음과 같이 사용하며, 이건 Vite가 자동으로 주입해줍니다.
import.meta.env.VITE_API_URL;위치 | env 접근 방식 |
config 파일 | ❌ 자동 로드 안됨 |
앱 코드 | ✅ import.meta.env 사용 |
4) config에서 env를 사용해야 하는 경우
하지만 다음과 같은 경우에는 env 값이 config 자체에 영향을 줘야 합니다.
server port 설정
특정 plugin 활성화 여부
define 값 주입
이런 경우에는 env를 직접 불러와야 합니다.
| loadEnv 사용
앞서 살펴본 것처럼, config 파일이 실행되는 시점에는 .env 파일이 자동으로 로드되지 않습니다. 따라서 config 내부에서 환경 변수 값을 사용하려면, Vite가 제공하는 loadEnv 함수를 통해 직접 .env 파일을 불러와야 합니다.
loadEnv는 현재 실행 모드(mode)를 기준으로 적절한 .env 파일을 읽어와 객체 형태로 반환해주며, 이를 통해 config 단계에서도 환경 변수를 활용할 수 있습니다.
import { defineConfig, loadEnv } from "vite";
export default defineConfig(({ mode }) => {
// 현재 mode에 맞는 .env 파일을 직접 로드
const env = loadEnv(mode, process.cwd(), "");
return {
// env 값을 기반으로 전역 상수 주입
define: {
__APP_ENV__: JSON.stringify(env.APP_ENV),
},
// env 값을 기반으로 dev server 설정
server: {
port: env.APP_PORT ? Number(env.APP_PORT) : 5173,
},
};
});위 코드에서 env는 .env, .env.local, .env.[mode], .env.[mode].local 등의 파일을 종합해서 읽어온 결과입니다.
(1) loadEnv 설명
loadEnv(mode, process.cwd(), "");파라미터 | 설명 |
mode | 현재 실행 모드 (development, production) |
process.cwd() | 프로젝트 루트 경로 |
“” | 모든 env 로드 (VITE_ prefix 제한 해제) |
기본적으로 Vite는 보안을 위해 VITE_ prefix가 붙은 환경 변수만 클라이언트 코드에 노출합니다.
하지만 config 단계에서는 더 다양한 환경 변수를 사용할 수 있기 때문에, 세 번째 인자에 ""를 넣어 모든 env를 불러오는 경우가 많습니다.
(2) 언제 필요한가?
loadEnv는 단순히 env 값을 읽는 용도가 아니라, config 자체의 동작을 제어해야 할 때 사용합니다.
다음과 같은 경우에 활용됩니다.
개발/운영 환경에 따라 서버 포트를 다르게 설정할 때
특정 env 값에 따라 plugin을 활성화/비활성화할 때
define 값을 환경별로 다르게 주입할 때
feature flag를 config 단계에서 제어할 때
| VS Code에서 config 파일 디버깅하기
Vite config를 작성하다 보면 breakpoint를 걸어 디버깅을 시도하는 경우가 있습니다.
하지만 기본 설정 상태에서는 breakpoint가 정상적으로 동작하지 않거나, 파일을 찾을 수 없다는 오류가 발생할 수 있습니다.
1) 왜 이런 문제가 발생할까?
앞서 살펴본 것처럼 Vite는 config 파일을 그대로 실행하지 않고, 기본적으로 --configLoader bundle 방식을 사용하여 config 파일을 번들링한 뒤 실행합니다.
이 과정에서 Vite는 내부적으로 임시 설정 파일을 생성하며, 해당 파일은 다음 경로에 위치합니다.
node_modules/.vite-temp/즉, 우리가 작성한 vite.config.ts 파일이 직접 실행되는 것이 아니라, 번들링된 임시 파일이 실제 실행 대상이 되는 구조입니다.
이로 인해 VS Code에서 설정한 breakpoint가 원본 파일과 연결되지 않아, 디버깅이 정상적으로 동작하지 않는 문제가 발생합니다.
2) 해결 방법
이 문제는 VS Code 설정을 통해 해결할 수 있습니다.
프로젝트의 .vscode/settings.json 파일에 아래 설정을 추가합니다.
{
"debug.javascript.terminalOptions": {
"resolveSourceMapLocations": [
"${workspaceFolder}/**",
"!**/node_modules/**",
"**/node_modules/.vite-temp/**"
]
}
}항목 | 설명 |
${workspaceFolder}/** | 프로젝트 전체 파일 포함 |
!**/node_modules/** | node_modules 디렉토리는 제외 |
**/node_modules/.vite-temp/** | Vite가 생성한 임시 config 파일 포함 |
이 설정의 핵심은, Vite가 생성한 .vite-temp 경로를 디버깅 대상에 포함시키는 것입니다.
| 결론
이번 글에서는 Vite 구성 파일을 중심으로 vite.config.ts가 어떤 역할을 하는지 살펴봤습니다.
Vite config는 단순한 설정 파일이 아니라, 개발 서버와 빌드 환경을 제어하는 진입점에 가깝습니다. Vite는 config 파일을 그대로 실행하지 않고 내부적으로 한 번 가공한 뒤 실행하기 때문에, TypeScript 설정 파일이나 ES Module 문법을 자연스럽게 사용할 수 있습니다.
또한 defineConfig를 사용하면 타입 추론과 IDE 자동완성의 도움을 받을 수 있고, 조건부 설정을 활용하면 serve, build, preview, SSR 여부에 따라 다른 설정을 적용할 수 있습니다. 환경 변수의 경우 앱 코드에서는 import.meta.env를 사용하지만, config 내부에서는 .env가 자동으로 로드되지 않기 때문에 loadEnv를 통해 직접 불러와야 한다는 점도 중요합니다.
정리하면, Vite config를 잘 이해한다는 것은 단순히 설정 문법을 아는 것을 넘어, 프로젝트의 개발 경험과 빌드 전략을 직접 설계할 수 있다는 의미라고 생각합니다.
작은 프로젝트에서는 정적 설정만으로 충분하지만, 프로젝트 규모가 커지고 환경이 다양해질수록 config 파일의 중요성은 더욱 커집니다. 이번 정리를 통해 Vite 설정을 더 명확하게 이해하고, 필요한 상황에 맞게 구성할 수 있는 기반을 잡을 수 있으면 좋겠습니다.