빌드노트
환경값을 import 위에서 설정했는데 Node 모듈은 undefined를 읽는다
Node ESM의 정적 의존 모듈은 엔트리 본문의 환경값 대입보다 먼저 평가될 수 있다. 같은 두 파일을 새 프로세스에서 비교해 읽는 시점을 확인하고, 시작 환경 주입과 초기화 뒤 동적 import 중 적용할 방법을 고른다.
Node ESM의 엔트리 파일에서 process.env.TASK_LABEL을 첫 줄에 설정했는데, import한 설정 모듈은 undefined를 읽을 수 있다. 정적 의존 모듈의 최상위 코드는 엔트리 본문의 대입문보다 먼저 평가되기 때문이다. 값을 Node 시작 전에 주거나, 초기화를 마친 뒤 동적 import()로 모듈을 불러와야 한다.
먼저 자기 코드에서 환경값을 읽는 위치를 확인한다. 함수가 호출될 때 읽는지, 모듈 최상위의 const를 초기화할 때 읽는지가 다르다. 최상위에서 값을 보관하는 모듈이라면 엔트리의 대입 줄을 import 위로 옮기는 것만으로 읽는 시점을 늦출 수 없다.
설정 모듈의 출력이 먼저 나온다
아래는 실제 장애 기록이 아니라 Windows 11, PowerShell 7.6.6, Node.js v22.23.2에서 합성 키 TASK_LABEL로 실행한 재현이다. 직접 비교하려면 새 빈 폴더에 다음 두 파일을 UTF-8로 저장한다. 별도 패키지는 필요 없다. .mjs는 Node가 파일을 ESM으로 해석하도록 명시하는 확장자다. Node v22.23.2의 ESM 활성화 문서 (새 탭에서 열림)
config.mjs에는 모듈이 평가될 때 읽은 값을 보관하고 출력하는 코드만 둔다. 자기 코드에서도 이런 최상위 읽기가 있는지 확인한다.
export const labelAtImport = process.env.TASK_LABEL;
console.log('config:', labelAtImport);
entry.mjs는 값을 먼저 대입한 것처럼 보이도록 작성한다. 마지막 출력으로 설정 모듈이 보관한 값도 비교한다.
process.env.TASK_LABEL = 'from-entry';
console.log('entry:', process.env.TASK_LABEL);
import { labelAtImport } from './config.mjs';
console.log('saved:', labelAtImport);
두 파일이 있는 폴더에서 실습 전용 PowerShell 7 콘솔을 연다. node --version으로 버전을 확인하고, 시작 환경에서 합성 키를 제거한 뒤 실행한다. 아래의 $env: 문법은 PowerShell용이며, $null 대입은 이 키를 현재 셸에서 제거한다. 변경은 현재 셸과 이후 시작하는 자식 프로세스에 적용된다. PowerShell 환경변수 문서 (새 탭에서 열림)
$env:TASK_LABEL = $null
node entry.mjs
실제 출력은 다음과 같았고 종료 코드는 0이었다.
config: undefined
entry: from-entry
saved: undefined
첫 줄이 config라는 점을 본다. entry.mjs의 첫 대입문이 실행되기도 전에 config.mjs가 환경값을 읽었다. 이후 엔트리가 값을 바꿔서 entry에는 from-entry가 나오지만, labelAtImport에는 앞서 읽은 undefined가 그대로 남았다.
정적 import 선언은 그 줄에 도달했을 때 호출하는 함수가 아니다. 엔트리를 평가하려면 먼저 정적 의존 관계를 따라 모듈을 평가한다. ECMAScript의 InnerModuleEvaluation 절차 (새 탭에서 열림)도 의존 모듈을 재귀적으로 평가한 뒤 해당 모듈의 코드를 실행하도록 규정한다. 이 예제처럼 순환 참조와 의존 모듈의 최상위 await가 없는 경우에는 config.mjs 평가 다음에 엔트리 본문이 실행된다.
Node의 process.env 문서 (새 탭에서 열림)는 이 객체를 수정해 해당 Node 프로세스 안의 환경값을 바꿀 수 있다고 설명한다. 다만 이미 실행된 const labelAtImport = process.env.TASK_LABEL을 다시 실행해 주지는 않는다. 위 출력은 환경값의 대입이 성공했어도 설정 모듈이 보관한 값은 다를 수 있음을 보여 준다.
시작 전에 값을 주면 정적 import로도 읽힌다
파일은 그대로 두고, 같은 실습 콘솔에서 Node를 시작하기 전에 값을 준다. 이 명령은 새 Node 프로세스를 만든다.
$env:TASK_LABEL = 'from-parent'
node entry.mjs
이번 출력은 다음과 같았고 종료 코드는 0이었다.
config: from-parent
entry: from-entry
saved: from-parent
config는 처음부터 존재하던 from-parent를 읽었다. 엔트리가 나중에 from-entry로 덮어도 saved는 from-parent다. 따라서 증상은 항상 undefined로 나타나지는 않는다. 설정 모듈이 읽는 것은 엔트리의 초기화 이후 값이 아니라 자신의 평가 시점에 있던 값이다.
시작할 때 확정할 수 있는 설정이라면 앱 실행기나 서비스의 시작 환경에서 값을 주는 방식을 선택할 수 있다. 그러면 설정 모듈의 첫 평가 전에 값이 준비되므로 정적 import를 유지할 수 있다. 실제 코드에 같은 키를 다시 덮는 초기화가 있다면 함께 정리해야 한다. 위 예제도 대입문을 남겨 두었기 때문에 현재 환경값과 보관한 값이 갈라졌다.
코드에서 초기화해야 한다면 그 뒤에 import()를 호출한다
환경값을 프로그램 안에서 준비해야 한다면 모듈을 불러오는 시점을 늦춘다. config.mjs는 유지하고 entry.mjs만 다음 내용으로 교체한다. 정적 import 선언을 대입 뒤의 await import() 호출로 바꾼 것이다.
process.env.TASK_LABEL = 'from-entry';
console.log('entry:', process.env.TASK_LABEL);
const { labelAtImport } = await import('./config.mjs');
console.log('saved:', labelAtImport);
앞 조건의 시작 환경값을 제거하고 새 Node로 실행해, 이번에도 엔트리가 직접 값을 준비하는지 확인한다.
$env:TASK_LABEL = $null
node entry.mjs
출력 순서와 보관한 값이 함께 바뀌었고 종료 코드는 0이었다.
entry: from-entry
config: from-entry
saved: from-entry
대입과 entry 출력이 끝난 다음 config.mjs가 평가됐다. Node는 동적 import() (새 탭에서 열림)를 비동기 모듈 로딩 방식으로 지원하며, 이 .mjs에서는 최상위 await를 사용할 수 있다. 여기서는 import 결과를 기다린 뒤 labelAtImport를 사용한다.
이 결과를 앱에 적용할 때는 작은 시작 파일에서 초기화를 마친 뒤 앱 모듈을 동적으로 불러오는 경계를 둘 수 있다. 초기화 자체가 비동기라면 그 작업의 완료도 기다려야 한다. 그리고 환경값을 먼저 읽는 앱이나 설정 모듈이 시작 파일의 다른 정적 import를 통해 이미 평가되지 않는지 확인한다. 단순히 기존 정적 import를 남기고 아래에 동적 import를 추가하는 것으로는 첫 평가 시점이 바뀌지 않는다.
같은 프로세스에서 이미 평가한 모듈을 import()한다고 다시 평가하는 것은 아니다. ECMAScript의 Evaluate 절차 (새 탭에서 열림)는 이미 진행하거나 마친 평가의 Promise를 재사용하며, Node의 ESM 문서 (새 탭에서 열림)도 ESM 로더가 별도 캐시를 사용한다고 설명한다. 이 비교에서는 조건마다 새 Node를 실행해 앞 조건의 모듈 평가 결과가 섞이지 않게 했다.
자기 코드에서 동적으로 바꿨는데도 이전 값이 남는다면 초기화 완료 시점과 해당 모듈로 이어지는 다른 import 경로부터 확인한다. 재현의 정적 버전에서 출력 순서부터 다르다면 실행 중인 파일과 환경값을 읽는 코드의 위치를 다시 확인한다. 실행 오류가 나면 해당 명령 직후 PowerShell의 $LASTEXITCODE와 오류 메시지를 보고, Node 버전·파일 이름·현재 폴더부터 확인한다. 실습을 마치면 전용 콘솔을 닫는다.
검증한 범위는 Node v22.23.2에서 이 두 .mjs 파일의 첫 평가 순서다. 실제 환경·인증·설정 파일은 읽지 않았다. 번들러가 변환한 코드, 사용자 정의 로더, 순환 의존 관계, 다른 Node 버전과 실제 서비스 시작 설정은 검증하지 않았다.