목차
vite가 .env 파일의 우선 순위를 결정하는 방식을 코드로 알아보기
vite5 버전을 기준으로 작성된 글입니다.
vite는 빌드 할 때 --mode
옵션을 통해 .env.production
파일 대신 특정 환경 변수 파일을 사용할 수 있다. (mode 옵션 없이 빌드할 경우 기본적으로 .env.production
이 적용된다.)
예를 들어, vite build --mode staging
을 실행하면 .env.production
파일 대신 .env.staging
파일이 적용되고, vite build --mode development
를 실행하면 .env.development
파일이 적용된다.(.env
파일은 공통적으로 적용된다.)
이렇게 특정 모드에 해당하는 환경 변수 파일은 .env
파일 보다 우선 순위가 높기 때문에 같은 값이 있을 경우 .env.[mode]
파일에 있는 값이 덮어 쓴다.
// .env
VITE_API_ENDPOINT=http://api-domain.com/
VITE_API_KEY=API_KEY
// .env.staging
VITE_API_ENDPOINT=https://staging-api-domain.com/
// .env.production
VITE_API_ENDPOINT=https://prod-api-domain.com/
// .env
VITE_API_ENDPOINT=http://api-domain.com/
VITE_API_KEY=API_KEY
// .env.staging
VITE_API_ENDPOINT=https://staging-api-domain.com/
// .env.production
VITE_API_ENDPOINT=https://prod-api-domain.com/
예를 들어, 위와 같이 .env
파일이 설정되어 있을 때 vite build --mode staging
를 실행하면 아래 이미지와 같은 결과를 얻을 수 있다.
staging 모드로 실행되었기 때문에 .env
, .env.staging
파일이 사용된다.
이때, .env
파일에 있는 환경 변수 중에서 .env.staging
파일에 동일하게 선언된 환경 변수는 우선 순위가 더 높은 .env.staging
파일에 있는 값으로 덮어씌워지는 것을 확인할 수 있다.
import.meta.env를 콘솔로 확인한 결과
vite 공식 문서는 다음과 같이 각각의 .env 파일에 대해서 설명하고 있다.(아래에 있는 파일일 수록 우선 순위가 높다.)
.env # 모든 상황에서 사용될 환경 변수
.env.local # 모든 상황에서 사용되나, 로컬 개발 환경에서만 사용될(Git에 의해 무시될) 환경 변수
.env.[mode] # 특정 모드에서만 사용될 환경 변수
.env.[mode].local # 특정 모드에서만 사용되나, 로컬 개발 환경에서만 사용될(Git에 의해 무시될) 환경 변수
.env # 모든 상황에서 사용될 환경 변수
.env.local # 모든 상황에서 사용되나, 로컬 개발 환경에서만 사용될(Git에 의해 무시될) 환경 변수
.env.[mode] # 특정 모드에서만 사용될 환경 변수
.env.[mode].local # 특정 모드에서만 사용되나, 로컬 개발 환경에서만 사용될(Git에 의해 무시될) 환경 변수
-> 만약 프로젝트에 4 종류의 파일이 모두 있다면, vite는 이 파일을 모두 읽어들이고 우선 순위에 따라 최종적으로 import.meta.env
에 들어가는 객체를 생성한다.
그렇다면 vite는 도대체 어떻게 .env
파일들을 읽어들이고 환경 변수 파일의 우선 순위를 결정할까?
// load .env files
const envDir = config.envDir
? normalizePath(path.resolve(resolvedRoot, config.envDir))
: resolvedRoot
const userEnv =
inlineConfig.envFile !== false &&
loadEnv(mode, envDir, resolveEnvPrefix(config))
// load .env files
const envDir = config.envDir
? normalizePath(path.resolve(resolvedRoot, config.envDir))
: resolvedRoot
const userEnv =
inlineConfig.envFile !== false &&
loadEnv(mode, envDir, resolveEnvPrefix(config))
먼저 config.ts
파일을 보면 .env
파일들을 불러오는 코드가 있다.(친절하게 주석도 달려있다.)
envFile
이 false가 아니라면 loadEnv
함수를 실행하고, 결과를 userEnv
에 저장한다.
loadEnv
는 mode
(현재 실행중인 모드), envDir
(env 파일이 로드되는 폴더), prefix
(소스 코드에서 접근할 수 있는 환경 변수를 구분하기 위한 prefix(기본 값 = VITE_
))를 인자로 전달받는다.
(loadEnv
함수의 각각의 인자에 대한 설명은 공식 문서에 설명되어 있다.)
// https://github.com/vitejs/vite/blob/main/packages/vite/src/node/env.ts#L17
import { parse } from 'dotenv'
export function loadEnv(
mode: string,
envDir: string,
prefixes: string | string[] = 'VITE_',
): Record<string, string> {
prefixes = arraify(prefixes)
// ...
// 1.
const env: Record<string, string> = {}
// 2.
const envFiles = getEnvFilesForMode(mode, envDir)
// ...
}
// https://github.com/vitejs/vite/blob/main/packages/vite/src/node/env.ts#L17
import { parse } from 'dotenv'
export function loadEnv(
mode: string,
envDir: string,
prefixes: string | string[] = 'VITE_',
): Record<string, string> {
prefixes = arraify(prefixes)
// ...
// 1.
const env: Record<string, string> = {}
// 2.
const envFiles = getEnvFilesForMode(mode, envDir)
// ...
}
loadEnv
함수 내부의 모습이다. 실제 내부 코드는 더 복잡하지만 .env
파일과 우선 순위와 관련된 코드만 가져왔다.
- 먼저 환경 변수를 담을
env
객체를 선언한다. envFiles
에는getEnvFilesForMode
함수가 리턴하는.env
파일들의 경로가 담긴 배열이 저장된다.
function getEnvFilesForMode(mode: string, envDir: string): string[] {
return [
/** default file */ `.env`,
/** local file */ `.env.local`,
/** mode file */ `.env.${mode}`,
/** mode local file */ `.env.${mode}.local`,
].map((file) => normalizePath(path.join(envDir, file)))
}
function getEnvFilesForMode(mode: string, envDir: string): string[] {
return [
/** default file */ `.env`,
/** local file */ `.env.local`,
/** mode file */ `.env.${mode}`,
/** mode local file */ `.env.${mode}.local`,
].map((file) => normalizePath(path.join(envDir, file)))
}
getEnvFilesForMode
함수 내부의 모습이다.
mode
와 envDir
를 인자로 받아서 각각의 .env
파일의 실제 경로가 담긴 배열을 리턴한다.
위 예제처럼 staging 모드로 실행되고 있다면 ['.env', '.env.local', '.env.staging', '.env.staging.local']
파일에 대한 경로가 담긴 배열이 리턴된다.
loadEnv
함수에서 이 배열의 순서대로 환경 변수의 값을 가져오고, 더 나중에 가져온 환경 변수의 값이 최종적으로 사용된다.
따라서, getEnvFilesForMode
함수에 의해서 .env
파일의 우선 순위가 결정된다!
// 3.
const parsed = Object.fromEntries(
// 3.3
envFiles.flatMap((filePath) => {
// 3.1
if (!tryStatSync(filePath)?.isFile()) return []
// 3.2
return Object.entries(parse(fs.readFileSync(filePath)))
}),
)
// 3.
const parsed = Object.fromEntries(
// 3.3
envFiles.flatMap((filePath) => {
// 3.1
if (!tryStatSync(filePath)?.isFile()) return []
// 3.2
return Object.entries(parse(fs.readFileSync(filePath)))
}),
)
- 각
.env
파일에 선언된key=value
값을 파싱해서 객체로 만드는 로직이다.- (3.1) 내부에 구현된
tryStatSync
유틸 함수를 이용해서.env
파일이 존재하는지 확인하고, 존재할 경우 파일인지 확인한다. - (3.2) 각각의
.env
파일로부터 [key, value]로 이루어진 배열을 얻는 로직이다.- 먼저
fs.readFileSync
함수를 이용해서.env
파일의 내용을 읽고, dotenv의parse
함수를 이용해서 값을 객체로 변환한다. .env
파일의VITE_API_KEY=API_KEY
key=value 형태의 값이{ VITE_API_KEY: 'API_KEY' }
로 파싱된다.Object.entries()
를 실행하면 [key, value]로 이루어진 배열을 얻을 수 있다. 위에서 파싱된 객체에서[[ 'VITE_API_KEY', 'API_KEY' ]]
배열을 얻을 수 있다.
- 먼저
- (3.3)
flatMap
메서드를 이용해서Object.entries
를 통해 얻은 이차원 배열을 평탄화한다.envFiles
배열에 우선 순위가 낮은 순으로.env
파일 경로가 담겨있기 때문에 우선 순위가 높은.env
파일의 [key, value]가 최종적으로 만들어진 배열에서 더 나중 순서에 위치하게 된다. - (3.3)에서 만들어진 배열은
Object.fromEntries()
함수의 인자로 전달되고, 객체로 변환된다. 이때, 중복된 [key, value]가 있을 경우 더 나중에 나타난 값으로 덮어 씌워지기 때문에 우선 순위가 높은parsed
객체에는 더 나중에 나타난 값이 들어있게 된다.
- (3.1) 내부에 구현된
// only keys that start with prefix are exposed to client
// 4.
for (const [key, value] of Object.entries(parsed)) {
if (prefixes.some((prefix) => key.startsWith(prefix))) {
env[key] = value
}
}
// check if there are actual env variables starting with VITE_*
// these are typically provided inline and should be prioritized
// 5.
for (const key in process.env) {
if (prefixes.some((prefix) => key.startsWith(prefix))) {
env[key] = process.env[key] as string
}
}
return env
// only keys that start with prefix are exposed to client
// 4.
for (const [key, value] of Object.entries(parsed)) {
if (prefixes.some((prefix) => key.startsWith(prefix))) {
env[key] = value
}
}
// check if there are actual env variables starting with VITE_*
// these are typically provided inline and should be prioritized
// 5.
for (const key in process.env) {
if (prefixes.some((prefix) => key.startsWith(prefix))) {
env[key] = process.env[key] as string
}
}
return env
parsed
객체에서 prefix(기본 값 =VITE_
)로 시작하는 키에 해당하는 값만env
객체에 추가한다.- 공식 문서에 나와있는
VITE_SOME_KEY=123 vite build
처럼 전달한 환경 변수도env
객체에 추가한다. 빌드 명령어를 실행할 때 먼저 전달한 환경 변수가 가장 마지막에env
객체에 추가되므로 가장 높은 우선 순위를 가지게된다.
loadEnv
함수의 로직을 정리해보자
- 먼저 읽어들일
.env
파일의 경로를 배열에 담는다. - 파일 경로가 담긴 배열을 순회하면서 각각의 파일의 내용을 파싱해서 객체로 변환한다.
- 변환한 객체의 key, value를
parsed
객체에 할당한다. parsed
객체를 순회하면서prefix
로 시작하는 키와 키에 해당하는 값을env
객체에 담는다.- 마찬가지로 빌드 명령어와 함께(inline) 전달된 환경 변수 중에서
prefix
로 시작하는 키와 키에 해당하는 값을env
객체에 담는다. env
객체를 리턴한다.- 리턴된
env
객체는import.meta.env
를 통해서 접근할 수 있다.
resolved: {
env: {
...userEnv,
BASE_URL,
MODE: mode,
DEV: !isProduction, // const isProduction = process.env.NODE_ENV === 'production'
PROD: isProduction,
},
}
resolved: {
env: {
...userEnv,
BASE_URL,
MODE: mode,
DEV: !isProduction, // const isProduction = process.env.NODE_ENV === 'production'
PROD: isProduction,
},
}
loadEnv
함수를 통해 생성한 userEnv
가 여기에서 사용된다.
resolved.env
에 구조 분해 할당을 이용해 userEnv
에 있는 값 들이 전달된다.
그리고 vite에서 기본적으로 제공하는 환경 변수가 이곳에서 설정된다.
import.meta.env.SSR
에 해당하는 이곳에서 설정되지 않는데, 어디서 값이 설정되는지 나중에 좀 더 분석해서 확인해봐야 겠다.
여태까지 많은 라이브러리를 사용하면서 처음으로 코드를 분석해봤다.
어려웠지만 생각보다 너무 재미있었고, 좋은 코드도 볼 수 있어서 많은 공부가 됐다. (특히, Object.fromEntries, Object.entries를 사용하는 로직을 알게 되어서 좋았다.)
이제 다른 사람에게 vite .env
파일의 우선 순위가 어떻게 설정되는지 자신있게 설명해 줄 수 있을 것 같다.