Vite + Node .env 치트 시트 2026: import.meta.env가 undefined가 되는 이유와 실제로 작동하는 6가지 해결책
요약
Vite와 Node.js 환경에서 환경 변수가 다르게 동작하는 근본적인 원인을 분석하고 해결책을 제시합니다. Vite의 빌드 타임 문자열 치환 방식과 Node의 런타임 조회 방식의 차이를 이해하여 설정 오류를 방지하는 방법을 다룹니다.
핵심 포인트
- Vite는 빌드 타임에 환경 변수를 텍\text{스트}로 치환하며, Node는 런타임에 조회함
- Vite 클라이언트 코드에는 VITE_ 접두사가 붙은 변수만 노출됨
- loadEnv를 사용하여 프론트엔드와 백엔드 간 .env 파일을 공유 가능
- 배포 후 환경 변수 변경 시 빌드를 다시 수행해야 적용됨
이 글을 읽고 나면 여러분은 다음을 할 수 있게 됩니다: 두 개의 복사본 없이 Vite 프론트엔드와 Node/Express 백엔드 모두에 동일한 .env 파일을 로드하고, 프로덕션 환경에서 import.meta.env.VITE_API_URL이 조용히 undefined가 되는 것을 방지하며, 5줄짜리 빌드 체크를 사용하여 어떤 변수가 실제로 브라우저로 전달되었는지 증명할 수 있습니다. 아래의 모든 코드 스니펫은 Vite 5/6 및 Node 20+에서 그대로 실행됩니다.
.env 설정 때문에 반나절을 허비하는 이유는 그것이 어렵기 때문이 아닙니다. Vite와 Node가 코드상으로는 동일해 보이지만 완전히 다른 두 가지 메커니즘을 통해 환경 변수(environment variables)를 읽기 때문입니다. 이 차이를 이해하고 나면 버그는 명확해집니다.
아무도 말해주지 않는 차이점: Vite의 import.meta.env는 빌드 타임 문자열 치환이며, Node의 process.env는 런타임 조회입니다
이 문제의 80%를 해결할 수 있는 단 하나의 사실은 다음과 같습니다:
- Node는 프로세스가 시작될 때
process.env.FOO를 읽습니다. 값을 변경하고 재시작하면 끝입니다. - Vite는 런타임(runtime)에 환경 변수를 읽지 않습니다.
vite build과정 중에 Vite는 문자열 치환(find-and-replace)을 수행합니다. 즉, 소스 코드에 있는 모든import.meta.env.VITE_FOO토큰은 텍스트 값으로 치환된 후 미니파이(minified)됩니다. 배포된 번들에는process도 없고 조회(lookup)도 없습니다. 값은 JS에 이미 구워져(baked) 있습니다.
이러한 두 가지 실패 사례가 매우 흔한 이유는 다음과 같습니다:
API_URL=...(접두사 없음)로 설정하여import.meta.env.API_URL이undefined가 되는 경우 — Vite는 데이터베이스 비밀번호가 공개 번들에 유출되는 것을 방지하기 위해, 의도적으로VITE_접두사가 붙은 변수만 클라이언트 코드에 노출합니다.- 배포 대시보드(Vercel/Render)에서
VITE_API_URL을 설정했지만 이미 빌드가 완료된 경우 — 치환은 빌드 타임(build time)에 발생했으므로, 그 이후에 설정된 런타임 환경 변수는 아무것도 바꾸지 못합니다.
단 한 문장만 기억해야 한다면: 브라우저의 경우 환경 변수는 vite build 시점에 고정되며, Node의 경우 부팅 시점에 활성화됩니다.
하나의 .env, 두 개의 리더: loadEnv를 사용하여 Vite와 Express 간에 변수 공유하기
중복의 함정은 frontend/.env와 backend/.env를 수동으로 동기화하는 것입니다. 그럴 필요가 없습니다. Vite는 개발 서버와 동일한 방식으로 .env 파일을 읽는 loadEnv를 제공하며, 이를 vite.config.js에서 호출하여 선택된 변수를 Node 환경으로 전달하거나 접두사가 없는 값을 명시적으로 정의할 수 있습니다.
다음은 공유 루트 .env를 로드하고, 필수 키가 존재하는지 검증하며, undefined를 배포하는 대신 빌드 실패를 명확하게 알리는 작동 가능한 vite.config.js 예시입니다:
// vite.config.js (Vite 5/6, Node 20+)
import { defineConfig, loadEnv } from 'vite'
...
JSON.stringify에 관한 세부 사항은 전형적인 define 사용 시의 실수(footgun)입니다. define은 값을 원시 소스(raw source)로 붙여넣습니다. 만약 __BUILD_MODE__: mode라고 작성하면, 번들에는 const x = production이 들어가게 되어—선언되지 않은 식별자(undeclared identifier)가 됩니다—빌드 시점이 아닌 런타임(runtime)에 ReferenceError: production is not defined 오류가 발생합니다. 모든 define 값은 JSON.stringify로 감싸야 합니다.
프로덕션 전용 undefined: 개발 환경에서는 작동하고 Docker에서는 죽는 import.meta.env.VITE_API_URL
이것은 오후 시간을 통째로 잡아먹는 버그입니다. 왜냐하면 로컬 환경에서는 절대 재현되지 않기 때문입니다. 이 문제를 유발하는 시퀀스는 다음과 같습니다:
- 로컬에서
npm run dev를 실행하면.env를 실시간으로 읽으므로 모든 것이 정상 작동합니다. - CI(지속적 통합) 환경에서 배포 단계가 환경 변수를 주입하기 전에
vite build를 실행합니다. 이로 인해VITE_API_URL토큰이 빈 문자열로 대체됩니다. - 컨테이너가 정적
dist/파일을 서빙하는node server.js를 실행하고, 실행 중인 컨테이너에VITE_API_URL을 설정합니다. 하지만 값은 이미 빌드 시점에 고정(baked)되었기 때문에 아무런 효과가 없습니다.
징후: 네트워크 탭을 보면 요청이 https:///api/users로 전송되는 것을 볼 수 있습니다. undefined 또는 ''가 URL에 결합되었기 때문에 호스트(host)가 누락된 점에 주목하세요. 사용자가 발견하기 전에 이를 잡아내려면, 빌드된 번들을 grep으로 검색하십시오. 포함된 변수는 리터럴 문자열(literal strings)로 나타나지만, 포함되지 않은 변수는 그렇지 않습니다.
다음은 실행 가능한 빌드 후 검증기(post-build verifier)입니다. 이를 postbuild 스크립트로 추가하여 사용하세요:
// scripts/check-env-baked.mjs
// `vite build` 실행 {
// AFTER}에 실행합니다. 필수적인 VITE_ 변수들이
// 빈 문자열로 남지 않고 번들(bundle)에 실제로
// 치환되었는지 검증합니다.
...
// package.json
{
"scripts": {
...
이제 환경 변수를 사용할 수 없는 상태에서 실행된 빌드는 고장 난 호스트를 배포하는 대신, CI에서 0이 아닌 종료 코드(non-zero exit code)를 반환하며 종료됩니다. 근본적인 순서 문제를 해결하는 방법은 한 줄입니다: CI가 vite build와 동일한 단계(또는 그 이전 단계)에서 VITE_*를 설정하도록 하세요. 절대로 그 이후에 설정해서는 안 됩니다.
Node의 .env 로드 순서 함정: .env.local이 .env.production을 조용히 덮어쓰는 현상
Vite(및 dotenv 스타일의 로더)는 고정된 우선순위에 따라 환경 파일(env files)을 결정하며, 사람들은 이 순위를 몰라 몇 시간씩 허비하곤 합니다. Vite에서 높은 순위부터 낮은 순위까지의 우선순위는 다음과 같습니다:
.env.[mode].local— 예:.env.production.local.env.[mode]— 예:.env.production.env.local—test모드를 제외한 모든 모드에서 로드됨.env
주의할 점: .env.local(순위 3)이 .env.production보다 우선순위가 높을까요? 아닙니다. 다시 읽어보세요: .env.production(순위 2)이 .env.local(순위 3)을 이깁니다. 하지만 .env.production.local은 모든 것을 이깁니다. 실제 발생하는 실패 사례는, 개발자가 편의를 위해 한 번 .env.local에 VITE_API_URL=http://localhost:3000을 넣어두었는데, 몇 달 후 프로덕션(production) 빌드가 계속 localhost를 가리키는 경우입니다. 이는 스크립트에 의해 생성된 .env.production.local이 오래된 값을 상속받았기 때문입니다. 경험 법칙: .env와 .env.[mode]는 커밋(commit)하세요. 모든 *.local 파일은 gitignore 하세요. .env.local에 환경별 호스트(host)를 절대 넣지 마세요.
가장 위험한 유출, 즉 데이터가 채워진 .env.local을 커밋하는 것을 방지하는 적절한 .gitignore 설정은 다음과 같습니다:
# 이것들은 커밋하세요 (기본값 / 비기밀 정보):
# .env
# .env.production
...
Node 전용 비밀 정보: VITE_ 경계로 DATABASE_URL을 브라우저로부터 보호하기
VITE_ 접두사는 단순한 스타일 관례가 아니라 보안 경계 (security boundary)입니다. 접두사가 없는 모든 항목은 클라이언트 코드에서 보이지 않으며, 이는 DATABASE_URL, STRIPE_SECRET_KEY, 또는 OpenAI/Anthropic API 키와 같은 값에 정확히 요구되는 특성입니다. Express 백엔드에서는 이러한 값들을 일반적인 process.env로 읽어오며, 이들은 번들 (bundle)에 절대 포함되지 않습니다.
// server.js — Node에서 실행되며, 실시간 process.env를 읽음
import 'dotenv/config' // 부팅 시 .env를 process.env로 로드
import express from 'express'
...
여기서 발생하는 실패 사례는 프론트엔드 사례와 반대입니다. 팀원이 "작동하게 만들려고" 비밀 키에 VITE_STRIPE_SECRET_KEY와 같은 접두사를 붙이는 경우입니다. 이렇게 되면 Stripe 비밀 키는 공개 JS 번들 내에 일반 문자열로 남게 되어, DevTools를 여는 누구라도 grep 명령어로 찾아낼 수 있게 됩니다. 위에서 언급한 check-env-baked.mjs 스크립트는 감사 (audit) 용도로도 사용됩니다. 이 스크립트를 실행하여 비밀 키 이름의 접두사를 찾고, 만약 비밀 키가 dist/ 폴더 내에 나타나면 빌드를 실패시키도록 설정하세요.
어떤 파일에 어떤 변수를 넣을지에 대한 30초 결정 테이블
변수를 보며 어디에 배치해야 할지 고민될 때는 다음 두 가지 질문에 답하세요.
- 브라우저가 이 값을 필요로 하는가? 그렇다면 → 반드시
VITE_로 시작해야 하며, 해당 값이 공개된다는 점을 수용해야 합니다. 아니라면 → 접두사를 붙이지 말고, Node의process.env를 통해서만 읽으세요. - 언제 읽히는가? 브라우저 값은
vite build시점에 고정됩니다 (빌드 단계의 환경 변수로 설정하세요). Node 값은 프로세스 시작 시점에 읽힙니다 (서버가 부팅되는 곳 어디든 설정하세요).
누가 무엇을 읽는지에 대한 맵 (Map):
| 작성 내용 | 브라우저가 볼 수 있는가? | 읽는 방법 |
|---|---|---|
VITE_API_URL | 예 (빌드 시 고정됨) | import.meta.env.VITE_API_URL |
| ... |
빌드 시점 고정 (build-time-freeze)과 런타임 조회 (runtime-lookup)의 차이를 내재화하고, vite.config.js에서 필수 키 검증 (validation)을 설정하며, 번들 grep 체크를 통해 CI (지속적 통합)를 제어한다면, 오후 시간을 낭비하게 만들던 .env 문제는 1분 내외의 설정 작업으로 바뀔 것입니다. 각 변수가 정확히 언제 읽히고 누가 볼 수 있는지 알게 되므로, 변수들은 더 이상 미스터리한 존재가 아닙니다.
6가지 해결책 요약: (1) 브라우저 변수 앞에 VITE_ 접두사 붙이기; (2) 모든 define 값을 JSON.stringify로 처리하기; (3) VITE_* 변수를 vite build 실행 전에 설정하기 (절대 실행 후에 설정하지 말 것); (4) 빌드 후 번들에서 grep을 사용하여 비정상 종료(non-zero exit)를 확인하며 검증하기; (5) *.local 파일을 .gitignore에 추가하고 호스트 정보를 .env.local에 포함하지 않기; (6) 비밀 정보는 접두사를 붙이지 않은 상태로 유지하여 클라이언트에 절대 도달하지 않도록 하기.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기