Vite loadEnv
Vite는 .env 파일에 정의된 환경 변수를 자동으로 로딩해서 클라이언트 코드에 import.meta.env로 노출한다. 그런데 이 자동 로딩은 앱 코드 안에서만 동작한다. vite.config.ts는 앱이 실행되기 전에 먼저 평가되는 설정 파일이기 때문에, 이 시점에서는 .env 파일이 아직 로딩되지 않은 상태다.
그래서 설정 파일 안에서 환경 변수를 써야 할 때 — 예를 들어 프록시 대상 URL을 .env에서 읽어오거나, 포트 번호를 환경별로 바꾸거나 — process.env에 접근해도 값이 없다. 이때 필요한 게 loadEnv 함수다.
import { defineConfig, loadEnv } from "vite";
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), "");
return {
server: {
port: parseInt(env.PORT) || 3000,
proxy: {
"/api": {
target: env.API_TARGET || "https://api.example.com",
changeOrigin: true,
},
},
},
};
});
핵심은 defineConfig에 객체 대신 함수를 전달하는 것이다. 함수 형태를 사용해야 mode 파라미터를 받을 수 있고, 이 mode를 loadEnv에 넘겨서 올바른 .env 파일을 로딩할 수 있다.
loadEnv 함수 시그니처
function loadEnv(
mode: string,
envDir: string,
prefixes?: string | string[]
): Record<string, string>
세 개의 파라미터를 받는다.
mode: 현재 실행 모드. vite(dev) 실행 시 "development", vite build 실행 시 "production", --mode staging처럼 커스텀 모드를 지정하면 그 값이 들어온다. 이 값에 따라 어떤 .env.[mode] 파일을 로딩할지 결정된다.
envDir: .env 파일들이 위치한 디렉토리 경로. 보통 process.cwd()를 넣지만, 모노레포에서는 프로젝트 루트와 패키지 루트가 다를 수 있어서 경로를 명시적으로 지정해야 할 때가 있다.
prefixes: 여기가 가장 중요한 부분이다. 이 파라미터가 로딩할 변수의 범위를 결정한다.
prefix 파라미터의 동작
prefixes의 기본값은 "VITE_"다. 즉, 아무것도 넘기지 않으면 VITE_ 접두사가 붙은 변수만 반환한다.
// VITE_ 접두사 변수만 반환
const env = loadEnv(mode, process.cwd());
// env.VITE_API_URL → "https://..."
// env.DB_PASSWORD → undefined (VITE_ 접두사 아님)
설정 파일에서는 VITE_ 접두사가 없는 변수도 읽어야 할 때가 많다. API 서버 주소, DB 접속 정보, 포트 번호 같은 것들은 클라이언트에 노출하면 안 되니까 VITE_ 접두사를 붙이지 않는 게 맞다. 이때 빈 문자열 ""을 넘기면 모든 변수를 로딩한다.
// 모든 환경 변수 로딩 (접두사 필터링 없음)
const env = loadEnv(mode, process.cwd(), "");
// env.API_TARGET → "https://..."
// env.DB_PASSWORD → "secret"
// env.VITE_APP_TITLE → "My App"
배열로 여러 접두사를 지정할 수도 있다.
// VITE_와 APP_ 접두사 변수만
const env = loadEnv(mode, process.cwd(), ["VITE_", "APP_"]);
이 접두사 필터링은 보안과 직접 연결된다. Vite가 클라이언트 코드에서 import.meta.env로 노출하는 변수에 VITE_ 접두사 제한을 거는 이유는, .env에 적어둔 DB 비밀번호 같은 값이 번들에 포함되는 걸 방지하기 위해서다. loadEnv에서 ""를 쓰는 건 설정 파일 안에서만 쓸 것이기 때문에 괜찮지만, 이 값을 define 옵션으로 클라이언트에 주입하면 안 된다.
.env 파일 로딩 우선순위
Vite는 내부적으로 dotenv와 dotenv-expand를 사용해서 .env 파일을 파싱한다. loadEnv도 동일한 로직을 따른다.
mode가 "production"일 때 로딩되는 파일과 우선순위:
.env ← 항상 로딩
.env.local ← 항상 로딩, .gitignore 대상
.env.production ← production 모드에서만 로딩
.env.production.local ← production 모드에서만 로딩, .gitignore 대상
우선순위는 아래로 갈수록 높다. 즉, .env.production.local에 정의된 값이 .env에 정의된 같은 키를 덮어쓴다. 그리고 이 모든 것보다 시스템 환경 변수(이미 process.env에 존재하는 값)가 가장 높은 우선순위를 가진다.
정리하면:
| 우선순위 | 소스 |
|---|---|
| 1 (최고) | 시스템 환경 변수 (VITE_KEY=123 vite build) |
| 2 | .env.[mode].local |
| 3 | .env.[mode] |
| 4 | .env.local |
| 5 (최저) | .env |
이 계층 구조가 존재하는 이유는 역할 분리 때문이다.
.env: 모든 환경에서 공통으로 쓰는 기본값. 팀원 전체가 공유하니까 git에 커밋한다..env.local: 개발자 개인의 로컬 오버라이드. 각자 다른 API 서버를 가리키거나 디버그 플래그를 켤 때 사용..gitignore에 넣어서 개인 설정이 레포에 들어가지 않게 한다..env.production: 프로덕션 빌드 전용 설정. API 엔드포인트, 로깅 레벨 등..env.production.local: 프로덕션 빌드인데 로컬에서만 다르게 하고 싶은 설정. CI에서는 시스템 환경 변수를 쓰니까 이 파일은 보통 개발자 로컬 프로덕션 빌드 테스트 용도.
커스텀 모드 활용
Vite의 기본 모드는 development(dev 서버)과 production(빌드) 두 가지다. 하지만 --mode 플래그로 원하는 모드를 만들 수 있다.
vite build --mode staging
이렇게 하면 .env.staging 파일이 로딩된다. loadEnv("staging", process.cwd())와 동일한 효과다.
# .env.staging
VITE_API_URL=https://staging-api.example.com
VITE_ENABLE_DEBUG=true
실제로 개발/스테이징/프로덕션 세 환경을 운영하는 프로젝트에서 이 패턴이 유용하다. package.json의 스크립트를 이렇게 구성할 수 있다.
{
"scripts": {
"dev": "vite",
"build": "vite build",
"build:staging": "vite build --mode staging",
"build:prod": "vite build --mode production"
}
}
주의할 점: --mode를 지정하면 NODE_ENV가 자동으로 설정되지 않는다. vite build는 기본적으로 NODE_ENV=production을 설정하지만, vite build --mode staging을 실행하면 NODE_ENV가 staging이 된다. 프로덕션 최적화(minification, tree-shaking 등)를 유지하면서 커스텀 모드를 쓰려면 .env.staging에 명시해야 한다.
# .env.staging
NODE_ENV=production
VITE_API_URL=https://staging-api.example.com
dotenv-expand: 변수 참조
.env 파일 안에서 다른 변수를 참조할 수 있다. dotenv-expand가 ${VAR} 구문을 해석한다.
BASE_URL=https://example.com
VITE_API_URL=${BASE_URL}/api/v1
VITE_CDN_URL=${BASE_URL}/assets
이렇게 하면 VITE_API_URL은 "https://example.com/api/v1"로 확장된다. 기본 URL이 바뀌면 BASE_URL만 수정하면 되니까 DRY 원칙을 지킬 수 있다.
$ 문자 자체를 값에 포함하고 싶으면 백슬래시로 이스케이프한다.
PRICE=\$100 # 결과: "$100"
Vite는 역순 참조(뒤에 정의된 변수를 앞에서 참조)도 지원한다. 다만 이건 dotenv-expand의 레거시 동작이고, 셸 스크립트나 Docker Compose 같은 다른 도구에서는 동작하지 않으니 의존하지 않는 게 좋다.
import.meta.env와 클라이언트 노출
loadEnv와 import.meta.env는 다른 맥락에서 동작한다는 걸 명확히 구분해야 한다.
loadEnv: 설정 파일(vite.config.ts)에서 사용. Node.js 런타임에서 실행. 원하는 접두사로 필터링 가능. 서버 사이드 전용.
import.meta.env: 앱 소스 코드에서 사용. 빌드 시점에 정적 치환(static replacement). VITE_ 접두사 변수만 노출. 클라이언트 번들에 포함.
Vite의 빌드 과정에서 import.meta.env.VITE_API_URL은 문자열 리터럴 "https://api.example.com"으로 교체된다. 런타임에 환경 변수를 읽는 게 아니라 빌드 타임에 값이 코드에 박히는 것이다. 그래서 빌드 후에 환경 변수를 바꿔도 효과가 없다.
// 소스 코드
const url = import.meta.env.VITE_API_URL;
// 빌드 결과물
const url = "https://api.example.com";
이 정적 치환 덕분에 tree-shaking이 가능하다. import.meta.env.PROD가 true로 치환되면, if (!import.meta.env.PROD) 블록 안의 코드는 dead code가 되어 번들에서 제거된다.
TypeScript에서 타입 안전하게 사용하기
환경 변수를 import.meta.env.VITE_XXX로 접근하면 타입이 string | undefined다. 매번 존재 여부를 체크하거나 타입 단언을 하면 번거롭다. vite-env.d.ts 파일로 타입을 선언해두면 자동 완성과 타입 체크를 받을 수 있다.
// src/vite-env.d.ts
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_URL: string;
readonly VITE_APP_TITLE: string;
readonly VITE_ENABLE_DEBUG: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}
strictImportMetaEnv를 활성화하면 선언하지 않은 환경 변수에 접근할 때 타입 에러가 발생한다.
interface ViteTypeOptions {
strictImportMetaEnv: unknown;
}
HTML에서의 환경 변수 치환
Vite는 HTML 파일에서도 %CONST_NAME% 구문으로 환경 변수를 치환한다.
<!DOCTYPE html>
<html>
<head>
<title>%VITE_APP_TITLE%</title>
</head>
<body>
<div id="root"></div>
<script>
// 빌드 모드: %MODE%
</script>
</body>
</html>
import.meta.env에 존재하는 값만 치환되고, 존재하지 않는 키는 그대로 %NON_EXISTENT% 텍스트로 남는다. JS에서 import.meta.env.NON_EXISTENT가 undefined로 치환되는 것과 다른 동작이니 주의.
envPrefix 옵션
기본 접두사인 VITE_가 맘에 들지 않으면 envPrefix 옵션으로 바꿀 수 있다.
export default defineConfig({
envPrefix: "APP_",
});
이렇게 하면 APP_ 접두사 변수가 import.meta.env에 노출되고, VITE_ 접두사 변수는 더 이상 노출되지 않는다. 배열로 여러 접두사를 지정할 수도 있다.
export default defineConfig({
envPrefix: ["VITE_", "PUBLIC_"],
});
CRA(Create React App)에서 마이그레이션할 때 envPrefix: "REACT_APP_"으로 설정하면 기존 .env 파일을 수정하지 않고 그대로 쓸 수 있다.
절대로 envPrefix: ""로 설정하면 안 된다. 모든 환경 변수가 클라이언트에 노출되어 DB_PASSWORD 같은 민감한 값이 번들에 포함될 수 있다.
실전 패턴: loadEnv로 설정 파일 제어
프록시 대상 동적 변경
// .env.development
API_TARGET=http://localhost:4000
// .env.staging
API_TARGET=https://staging-api.example.com
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), "");
return {
server: {
proxy: {
"/api": {
target: env.API_TARGET,
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, ""),
},
},
},
};
});
조건부 플러그인
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), "VITE_");
const plugins = [react()];
if (env.VITE_ENABLE_MOCK === "true") {
plugins.push(mockPlugin());
}
return { plugins };
});
define으로 상수 주입
loadEnv로 읽은 값을 define 옵션으로 전역 상수로 주입할 수 있다. 다만 VITE_ 접두사 없는 변수를 이 방식으로 주입하면 접두사 보호를 우회하는 것이니 주의.
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), "");
return {
define: {
__APP_VERSION__: JSON.stringify(env.npm_package_version),
__BUILD_TIME__: JSON.stringify(new Date().toISOString()),
},
};
});
define의 값은 JSON.stringify로 감싸야 한다. 그렇지 않으면 값이 코드 표현식으로 해석된다. "1.0.0"이 아니라 1.0.0이라는 식별자로 치환되어 에러가 난다.
process.env를 직접 수정하면 안 되는 이유
StackOverflow 등에서 자주 보이는 안티패턴:
// ❌ 하지 마라
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), "");
Object.assign(process.env, env);
// ...
});
process.env를 직접 수정하면 HMR(Hot Module Replacement)에서 문제가 생긴다. .env 파일을 수정하고 서버가 재시작될 때, 이전에 process.env에 주입한 값이 남아있어서 새 값이 반영되지 않는다. loadEnv가 반환하는 객체를 그대로 사용하는 게 올바른 방법이다.
CRA/webpack과의 비교
CRA에서의 환경 변수 처리:
// CRA: process.env로 접근, REACT_APP_ 접두사
const url = process.env.REACT_APP_API_URL;
Vite에서의 환경 변수 처리:
// Vite: import.meta.env로 접근, VITE_ 접두사
const url = import.meta.env.VITE_API_URL;
| CRA (webpack) | Vite | |
|---|---|---|
| 접근 방식 | process.env.XXX | import.meta.env.XXX |
| 기본 접두사 | REACT_APP_ | VITE_ |
| 접두사 변경 | 불가 | envPrefix 옵션 |
| 치환 방식 | DefinePlugin (정적) | esbuild/rollup (정적) |
| 설정 파일에서 | process.env 직접 사용 가능 | loadEnv() 필요 |
| 타입 지원 | react-app-env.d.ts | vite-env.d.ts |
CRA에서는 설정 파일(webpack.config.js)에서 process.env를 바로 쓸 수 있었는데, 그건 CRA가 내부적으로 dotenv를 미리 로딩하기 때문이다. Vite는 설정 파일 평가 시점에 .env를 아직 로딩하지 않으므로 명시적으로 loadEnv를 호출해야 한다. 이건 설계 철학의 차이다 — Vite는 암묵적 동작보다 명시적 호출을 선호한다.
정리
- loadEnv는 vite.config.ts에서 .env를 읽기 위한 명시적 호출이며, prefix 파라미터로 노출 범위를 제어한다 (빈 문자열이면 전체, 기본값은 VITE_)
- .env → .env.local → .env.[mode] → .env.[mode].local → 시스템 환경 변수 순으로 우선순위가 높아지고, 커스텀 모드로 staging 등 환경을 분리할 수 있다
- import.meta.env는 빌드 타임 정적 치환이므로 빌드 후 변경 불가하고, VITE_ 접두사 없는 민감 변수가 번들에 포함되지 않도록 envPrefix를 절대 빈 문자열로 두면 안 된다