- Published on
Node.js ESM에서 __dirname 미정의 해결 5가지
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
Node.js를 ESM으로 전환하면 가장 먼저 부딪히는 에러 중 하나가 __dirname is not defined 입니다. CommonJS에서는 파일 시스템 경로를 다룰 때 __dirname과 __filename이 사실상 표준이었지만, ESM은 모듈 시스템 자체가 다르기 때문에 동일한 전역 변수가 제공되지 않습니다.
이 글에서는 왜 ESM에서 __dirname이 사라졌는지, 그리고 실무에서 안전하게 경로를 계산하는 5가지 방법을 정리합니다. 특히 process.cwd()를 무심코 대체로 쓰다가 배포 환경이나 테스트에서 깨지는 케이스가 많으니, 각각의 장단점과 적용 범위를 함께 보겠습니다.
ESM 전환 과정에서 require 관련 에러도 자주 만나면 아래 글도 함께 참고하세요.
왜 ESM에서는 __dirname이 없을까
CommonJS는 Node.js가 런타임에서 모듈을 감싸는 래퍼 함수에 __filename, __dirname, require, module, exports 등을 주입합니다. 반면 ESM은 표준 자바스크립트 모듈 스펙에 가깝게 동작하고, 모듈 메타데이터는 import.meta로 접근합니다.
즉 ESM에서 파일 위치를 얻는 표준적인 출발점은 import.meta.url이며, 여기서 file: URL을 파일 경로로 변환해 사용합니다.
해결 1) import.meta.url + fileURLToPath로 __dirname 만들기 (정석)
가장 권장되는 방식입니다. Node.js 내장 url 모듈의 fileURLToPath로 URL을 경로로 바꾸고, path.dirname으로 디렉터리를 얻습니다.
// example.mjs (또는 package.json에 "type": "module")
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
console.log({ __filename, __dirname });
언제 쓰나
- 특정 모듈 파일 기준으로 상대 경로를 계산해야 할 때
./templates,./assets처럼 소스 파일 옆의 리소스를 읽어야 할 때
장점
- 모듈 위치 기준이므로 실행 위치가 달라도 안정적
- ESM 스펙에 맞는 가장 표준적인 패턴
주의점
import.meta.url은 URL이므로, 반드시fileURLToPath로 변환 후 사용해야 합니다.
해결 2) new URL()로 파일 경로를 안전하게 조합하기
ESM에서는 URL 기반 조합이 의외로 실수 방지에 좋습니다. 특히 path.join으로 억지로 이어 붙이다가 슬래시 문제, 인코딩 문제를 만들기 쉽습니다.
new URL()은 기준 URL과 상대 URL을 합성해 주며, 마지막에 fileURLToPath로 변환하면 됩니다.
import { readFile } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
const configUrl = new URL('./config/app.json', import.meta.url);
const configPath = fileURLToPath(configUrl);
const json = JSON.parse(await readFile(configPath, 'utf-8'));
console.log(json);
언제 쓰나
- 특정 파일 옆에 있는 리소스 파일을 읽을 때
- 상대 경로 계산을 더 명시적으로 하고 싶을 때
장점
- 경로 결합 실수를 줄임
- URL 기반이라 ESM 문맥에 잘 맞음
주의점
- 결과가 URL이므로, 파일 시스템 API에는
fileURLToPath변환이 필요합니다.
해결 3) process.cwd()를 프로젝트 루트 기준으로 쓰는 방법 (조건부 추천)
많은 코드가 사실 __dirname이 아니라 “프로젝트 루트”가 필요합니다. 예를 들어 ./dist, ./public, .env 같은 파일은 모듈 위치가 아니라 “실행 기준 디렉터리”가 더 적합할 수 있습니다.
그럴 때는 process.cwd()가 간단합니다.
import path from 'node:path';
import { readFile } from 'node:fs/promises';
const projectRoot = process.cwd();
const envPath = path.join(projectRoot, '.env');
console.log('envPath:', envPath);
console.log(await readFile(envPath, 'utf-8'));
언제 쓰나
- CLI 도구처럼 사용자가 임의 경로에서 실행할 수 있고, 실행 위치 자체가 의미 있을 때
- 서버 런타임에서 “프로세스 시작 위치”를 기준으로 리소스를 찾는 설계일 때
장점
- 매우 단순
- “루트 기준 경로”가 필요한 경우 직관적
치명적인 함정
- 실행 위치가 바뀌면 경로가 바뀝니다.
- PM2, systemd, Docker, GitHub Actions, 테스트 러너 등에서
cwd가 달라질 수 있습니다.
- PM2, systemd, Docker, GitHub Actions, 테스트 러너 등에서
- “모듈 파일 기준”이 필요한 상황에서는 잘못된 결과를 냅니다.
실무 팁으로는, process.cwd()를 쓰는 경우 package.json 위치를 탐색해 루트를 고정하거나(아래 해결 5 참고), 실행 스크립트에서 cwd를 강제하는 방식으로 안정성을 보강합니다.
해결 4) CJS와 ESM을 함께 써야 한다면 createRequire로 브리지 만들기
레거시 라이브러리나 특정 환경(예: 일부 테스트 설정) 때문에 CJS 방식의 require가 필요한 경우가 있습니다. 이때 ESM에서 require를 직접 쓰면 ReferenceError가 나거나, 패키지 형태에 따라 ERR_REQUIRE_ESM이 터집니다.
ESM에서 CJS 로더를 쓰는 공식적인 우회로가 createRequire입니다. 다만 이것은 __dirname 자체를 제공하는 해결책이라기보다는 “CJS 생태계와의 접점”을 만들 때 유용합니다.
import { createRequire } from 'node:module';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const require = createRequire(import.meta.url);
// 예: CJS 전용 설정 파일이나 JSON을 require로 로드
const legacyConfig = require('./legacy-config.cjs');
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
console.log({ legacyConfig, __dirname });
언제 쓰나
- ESM 프로젝트인데 CJS 모듈을 불가피하게 로드해야 할 때
require.resolve같은 기능이 필요할 때
주의점
- ESM 전환의 목적이 “완전한 ESM”이라면, 임시 브리지로만 쓰고 점진적으로 제거하는 편이 좋습니다.
해결 5) “루트 경로”를 확정하는 유틸을 만들기 (모노레포·배포 안정화)
__dirname 문제의 본질은 “기준점이 무엇이냐”입니다.
- 모듈 파일 기준이면 해결 1 또는 2
- 실행 기준이면 해결 3
- 프로젝트 루트 기준이면 별도의 루트 탐색이 필요
특히 모노레포, 워크스페이스, 서버리스 배포, 테스트 러너 환경에서는 process.cwd()가 흔들리기 쉽습니다. 이때는 “루트 마커 파일”을 기준으로 상위 디렉터리를 탐색해 루트를 확정하는 유틸이 효과적입니다.
아래 예시는 현재 모듈 위치에서 시작해 상위로 올라가며 package.json을 찾고, 그 디렉터리를 루트로 간주합니다.
import path from 'node:path';
import { access } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
async function findProjectRoot(fromDir) {
let dir = fromDir;
while (true) {
const marker = path.join(dir, 'package.json');
try {
await access(marker);
return dir;
} catch {
const parent = path.dirname(dir);
if (parent === dir) {
throw new Error('project root not found (package.json missing)');
}
dir = parent;
}
}
}
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const projectRoot = await findProjectRoot(__dirname);
console.log('projectRoot:', projectRoot);
언제 쓰나
- 모노레포에서 패키지별 실행 위치가 달라지는 경우
- 테스트, CI, Docker에서 실행 디렉터리가 바뀌어도 동일한 루트 기준 경로가 필요할 때
장점
- 환경 변화에 강함
- “루트 기준 경로”를 일관되게 제공
단점
- 파일 시스템 탐색 비용이 있습니다.
- 보통은 앱 시작 시 1회만 실행하고 캐싱합니다.
실무에서 가장 안전한 선택 가이드
- 모듈 파일 옆 리소스를 읽는다: 해결 1 또는 해결 2
- 실행 위치가 의미 있는 CLI다: 해결 3
- 루트 기준 경로가 필요하고 환경이 흔들린다: 해결 5
- 레거시 CJS와 섞인다: 해결 4를 임시 브리지로 사용
자주 보는 실수 3가지
1) path.resolve('./something')가 항상 루트라고 착각
path.resolve('./something')는 결국 process.cwd() 기준입니다. 로컬에서는 맞아 보이다가, 배포나 테스트에서 어긋나기 쉽습니다.
2) import.meta.url을 문자열로만 다루기
import.meta.url은 file: URL입니다. 운영체제별 경로 규칙과 인코딩을 고려하면 fileURLToPath 변환을 생략하지 않는 편이 안전합니다.
3) 번들러나 트랜스파일 이후 경로가 달라지는 문제
빌드 결과물이 dist로 옮겨지면 “모듈 기준 경로”도 dist 기준으로 바뀝니다. 이건 버그가 아니라 자연스러운 결과입니다.
- 소스 옆 리소스를 런타임에 읽는 구조라면, 리소스도 함께 배포 경로로 복사되게 구성해야 합니다.
- 또는 리소스를 빌드 타임에 번들에 포함시키는 전략을 택해야 합니다.
마무리
ESM에서 __dirname이 없다는 사실 자체는 불편하지만, 기준점을 명확히 잡으면 오히려 경로 처리의 의도가 더 분명해집니다. 대부분의 서버 애플리케이션에서는 해결 1(정석)과 해결 5(루트 고정 유틸) 조합이 가장 사고가 적었습니다.
ESM 전환 중 CJS 의존성 때문에 막히는 경우가 많다면, 위에서 언급한 createRequire와 함께 다음 글도 같이 보면 전환 속도가 빨라집니다.