seungwoo.dev

목차

vite가 .env 파일의 우선 순위를 결정하는 방식을 코드로 알아보기

avatar image
Seungwoo Kim

11 min read

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 파일들을 읽어들이고 환경 변수 파일의 우선 순위를 결정할까?

vite/packages/vite/src/node/config.ts
// load .env files
const envDir = config.envDir
  ? normalizePath(path.resolve(resolvedRoot, config.envDir))
  : resolvedRoot
const userEnv =
  inlineConfig.envFile !== false &&
  loadEnv(mode, envDir, resolveEnvPrefix(config))
vite/packages/vite/src/node/config.ts
// 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에 저장한다.

loadEnvmode(현재 실행중인 모드), envDir(env 파일이 로드되는 폴더), prefix(소스 코드에서 접근할 수 있는 환경 변수를 구분하기 위한 prefix(기본 값 = VITE_))를 인자로 전달받는다.
(loadEnv 함수의 각각의 인자에 대한 설명은 공식 문서에 설명되어 있다.)

vite/packages/vite/src/node/env.ts
// 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)
 
	// ...
}
vite/packages/vite/src/node/env.ts
// 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 파일과 우선 순위와 관련된 코드만 가져왔다.

  1. 먼저 환경 변수를 담을 env 객체를 선언한다.
  2. 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 함수 내부의 모습이다.

modeenvDir를 인자로 받아서 각각의 .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)))
  }),
)
  1. .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 객체에는 더 나중에 나타난 값이 들어있게 된다.
// 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
  1. parsed 객체에서 prefix(기본 값 = VITE_)로 시작하는 키에 해당하는 값만 env 객체에 추가한다.
  2. 공식 문서에 나와있는 VITE_SOME_KEY=123 vite build처럼 전달한 환경 변수도 env 객체에 추가한다. 빌드 명령어를 실행할 때 먼저 전달한 환경 변수가 가장 마지막에 env 객체에 추가되므로 가장 높은 우선 순위를 가지게된다.

loadEnv 함수의 로직을 정리해보자

  1. 먼저 읽어들일 .env 파일의 경로를 배열에 담는다.
  2. 파일 경로가 담긴 배열을 순회하면서 각각의 파일의 내용을 파싱해서 객체로 변환한다.
  3. 변환한 객체의 key, value를 parsed 객체에 할당한다.
  4. parsed 객체를 순회하면서 prefix로 시작하는 키와 키에 해당하는 값을 env 객체에 담는다.
  5. 마찬가지로 빌드 명령어와 함께(inline) 전달된 환경 변수 중에서 prefix로 시작하는 키와 키에 해당하는 값을 env 객체에 담는다.
  6. env 객체를 리턴한다.
  7. 리턴된 env 객체는 import.meta.env를 통해서 접근할 수 있다.
vite/packages/vite/src/node/config.ts
resolved: {
	env: {
	  ...userEnv,
	  BASE_URL,
	  MODE: mode,
	  DEV: !isProduction, // const isProduction = process.env.NODE_ENV === 'production'
	  PROD: isProduction,
	},
}
vite/packages/vite/src/node/config.ts
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 파일의 우선 순위가 어떻게 설정되는지 자신있게 설명해 줄 수 있을 것 같다.