junyeokk
Blog
DevOps·2024. 11. 06

cross-env

Node.js 프로젝트에서 환경 변수를 설정하는 건 일상적인 작업이다. 개발 환경에서는 NODE_ENV=development, 프로덕션에서는 NODE_ENV=production을 주입해서 동작을 분기하는 패턴이 가장 흔하다.

json
{
  "scripts": {
    "start:dev": "NODE_ENV=development node app.js",
    "start:prod": "NODE_ENV=production node app.js"
  }
}

Linux나 macOS에서는 이 방식이 잘 동작한다. 그런데 Windows에서 실행하면 에러가 난다.

'NODE_ENV'은(는) 내부 또는 외부 명령, 실행할 수 있는 프로그램, 또는 배치 파일이 아닙니다.

이유는 간단하다. 환경 변수를 설정하는 문법이 OS마다 다르기 때문이다.

OS문법
Linux / macOS (bash)NODE_ENV=production node app.js
Windows (cmd)set NODE_ENV=production && node app.js
Windows (PowerShell)$env:NODE_ENV="production"; node app.js

팀원 중 누군가 Windows를 쓰고 있다면, 혹은 CI 환경이 Linux인데 로컬은 Windows라면, 하나의 package.json 스크립트로 모든 환경을 커버할 수 없다. 이걸 해결하는 게 cross-env다.


cross-env가 하는 일

cross-env는 OS에 관계없이 동일한 문법으로 환경 변수를 설정해주는 CLI 도구다. 내부적으로 현재 OS를 감지하고, 해당 OS에 맞는 방식으로 환경 변수를 주입한 뒤 자식 프로세스를 실행한다.

json
'NODE_ENV'은(는) 내부 또는 외부 명령, 실행할 수 있는 프로그램, 또는 배치 파일이 아닙니다.

이렇게 하면 Linux, macOS, Windows 어디서든 동일하게 동작한다.


설치

개발 의존성으로 설치한다. 런타임에는 필요 없고 npm 스크립트 실행 시에만 사용되기 때문이다.

bash
{
  "scripts": {
    "start:dev": "cross-env NODE_ENV=development node app.js",
    "start:prod": "cross-env NODE_ENV=production node app.js"
  }
}

동작 원리

cross-env의 내부 동작은 생각보다 단순하다.

  1. 커맨드라인 인자에서 KEY=VALUE 형태의 환경 변수를 파싱한다
  2. 나머지 부분을 실행할 명령어로 분리한다
  3. Node.js의 child_process.spawn()으로 자식 프로세스를 생성한다
  4. 이때 env 옵션에 파싱한 환경 변수를 병합해서 전달한다

핵심은 spawn()env 옵션이다. 이 옵션으로 전달된 환경 변수는 OS 문법과 무관하게 자식 프로세스에 주입된다. OS별 문법 차이를 Node.js API 레벨에서 우회하는 셈이다.

javascript
npm install --save-dev cross-env

stdio: 'inherit'로 부모 프로세스의 stdin/stdout/stderr를 그대로 상속하기 때문에, 자식 프로세스의 출력이 터미널에 자연스럽게 표시된다. shell: true는 Windows에서 .cmd 확장자 파일(npm 바이너리 등)을 실행하기 위해 필요하다.


여러 환경 변수 설정

공백으로 구분해서 여러 환경 변수를 한 번에 설정할 수 있다.

json
// cross-env 내부 동작의 단순화된 버전
const { spawn } = require('child_process');

function crossEnv(args) {
  const envVars = {};
  let commandStart = 0;

  // KEY=VALUE 파싱
  for (let i = 0; i < args.length; i++) {
    const match = args[i].match(/^([^=]+)=(.*)$/);
    if (match) {
      envVars[match[1]] = match[2];
      commandStart = i + 1;
    } else {
      break;
    }
  }

  const command = args[commandStart];
  const commandArgs = args.slice(commandStart + 1);

  // 현재 환경 변수에 새 변수를 병합
  const env = { ...process.env, ...envVars };

  // OS에 맞는 방식으로 자식 프로세스 실행
  spawn(command, commandArgs, {
    env,
    stdio: 'inherit',
    shell: true
  });
}

값에 공백이 포함된 경우

환경 변수 값에 공백이 있으면 따옴표로 감싸야 한다. 단, OS별 따옴표 처리 차이도 있기 때문에 cross-env가 이 부분도 처리해준다.

json
{
  "scripts": {
    "test": "cross-env NODE_ENV=test DB_HOST=localhost DB_PORT=3306 jest",
    "build": "cross-env NODE_ENV=production API_URL=https://api.example.com webpack"
  }
}

cross-env-shell

cross-env는 기본적으로 spawn()으로 명령어를 실행한다. 그런데 셸 내장 기능(파이프, 리다이렉트, && 체이닝 등)을 사용해야 할 때는 cross-env-shell을 대신 사용한다.

json
{
  "scripts": {
    "greet": "cross-env GREETING=\"Hello World\" node app.js"
  }
}

차이점은 이렇다:

cross-envcross-env-shell
실행 방식spawn() (직접 실행)spawn() + shell: true (셸 경유)
환경 변수 확장지원 안 함$NODE_ENV / %NODE_ENV% 자동 변환
셸 기능제한적파이프, 리다이렉트, && 모두 사용 가능
성능셸 프로세스 없이 직접 실행셸을 한 번 거침

cross-env-shell은 환경 변수 참조도 처리해준다. $NODE_ENV(Unix)와 %NODE_ENV%(Windows) 문법을 알아서 변환한다.

json
{
  "scripts": {
    "log-build": "cross-env-shell NODE_ENV=production \"webpack --config webpack.prod.js > build.log 2>&1\"",
    "check-and-build": "cross-env-shell NODE_ENV=production \"eslint src && webpack\""
  }
}

이 스크립트는 어떤 OS에서든 production을 출력한다.


실전 사용 패턴

테스트 환경 분리

테스트를 실행할 때 개발 DB와 분리된 테스트 DB를 사용하는 건 기본이다. cross-env로 NODE_ENV=test를 주입하면 설정 파일에서 이를 읽어 DB 연결 정보를 분기할 수 있다.

json
{
  "scripts": {
    "echo-env": "cross-env-shell NODE_ENV=production \"echo $NODE_ENV\""
  }
}
typescript
{
  "scripts": {
    "test": "cross-env NODE_ENV=test jest --config jest.config.ts",
    "test:e2e": "cross-env NODE_ENV=test jest --config jest-e2e.config.ts",
    "test:watch": "cross-env NODE_ENV=test jest --watch"
  }
}

빌드 모드 분기

프론트엔드 빌드에서 번들러의 mode를 환경 변수로 제어하는 패턴이다.

json
// config.ts
const config = {
  database: {
    host: process.env.NODE_ENV === 'test' ? 'localhost' : process.env.DB_HOST,
    port: process.env.NODE_ENV === 'test' ? 3307 : Number(process.env.DB_PORT),
    database: process.env.NODE_ENV === 'test' ? 'my_app_test' : process.env.DB_NAME,
  }
};

ANALYZE=true처럼 커스텀 환경 변수를 추가로 넘겨서 번들 분석 플러그인을 조건부로 활성화하는 것도 가능하다.

로그 레벨 제어

json
{
  "scripts": {
    "build:dev": "cross-env NODE_ENV=development webpack --config webpack.dev.js",
    "build:prod": "cross-env NODE_ENV=production webpack --config webpack.prod.js",
    "build:analyze": "cross-env NODE_ENV=production ANALYZE=true webpack --config webpack.prod.js"
  }
}

대안과 비교

dotenv

dotenv는 .env 파일에서 환경 변수를 로드하는 라이브러리다. cross-env와 목적이 다르다.

cross-envdotenv
동작 시점npm 스크립트 실행 시 (CLI)런타임 (코드 내부)
설정 위치package.json scripts.env 파일
주요 용도OS 간 호환성환경별 설정 파일 관리
민감 정보부적합 (package.json에 노출)적합 (.gitignore로 제외)

실제로는 둘을 함께 쓰는 경우가 많다. cross-env로 NODE_ENV를 설정하고, dotenv로 해당 환경에 맞는 .env 파일을 로드하는 방식이다.

json
{
  "scripts": {
    "start:debug": "cross-env NODE_ENV=development LOG_LEVEL=debug node app.js",
    "start:prod": "cross-env NODE_ENV=production LOG_LEVEL=warn node app.js"
  }
}

env-cmd

env-cmd는 파일 기반으로 환경 변수를 로드하는 CLI 도구다. cross-env처럼 크로스 플랫폼을 지원하면서도, 환경 변수가 많을 때 파일로 관리할 수 있다는 장점이 있다.

json
{
  "scripts": {
    "start:dev": "cross-env NODE_ENV=development node -r dotenv/config app.js"
  }
}

환경 변수가 1~3개 정도면 cross-env가 간편하고, 10개 이상이면 env-cmd나 dotenv를 쓰는 게 가독성이 좋다.


cross-env가 필요 없는 경우

Node.js 자체 기능으로 해결할 수 있는 상황도 있다. --env-file 플래그가 Node.js 20.6.0부터 추가되었다.

bash
{
  "scripts": {
    "start:dev": "env-cmd -f .env.development node app.js"
  }
}

또한 팀원 전원이 Unix 계열 OS를 사용하고 CI도 Linux라면 굳이 cross-env를 쓸 필요가 없다. 하지만 오픈소스 프로젝트처럼 기여자의 OS를 예측할 수 없는 경우에는 cross-env를 쓰는 게 안전하다.


정리

cross-env는 "환경 변수 설정 문법이 OS마다 다르다"는 단순한 문제를 해결하는 도구다. 내부적으로는 child_process.spawn()env 옵션을 활용해서 OS 문법 차이를 우회한다. 코드량도 적고 원리도 간단하지만, 크로스 플랫폼 호환성이 필요한 모든 Node.js 프로젝트에서 사실상 표준처럼 사용되고 있다.


관련 문서