- Published on
Node.js ESM에서 __dirname 없는 에러 해결법
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 사이드 Node.js 프로젝트를 CommonJS에서 ESM으로 전환하면 가장 먼저 마주치는 에러 중 하나가 __dirname is not defined 입니다. 기존 코드에서 파일 경로를 만들 때 __dirname을 기준으로 path.join(__dirname, '...') 같은 패턴을 많이 쓰기 때문입니다.
ESM으로 넘어오면 전역 변수로 제공되던 __dirname, __filename, require, module.exports 등이 기본적으로 사라집니다. 이 글에서는 왜 사라지는지(원리), 무엇으로 대체해야 하는지(정석), 그리고 실무에서 자주 터지는 케이스(번들링, 테스트, 워킹 디렉터리 혼동)까지 한 번에 정리합니다.
왜 ESM에서는 __dirname이 없을까
CommonJS(CJS)는 Node.js가 모듈을 로드할 때 각 파일을 함수로 감싸 실행합니다. 그 과정에서 Node가 __filename/__dirname 같은 “현재 모듈 파일 기반” 정보를 주입해 줍니다.
반면 ESM은 브라우저 표준 모듈 시스템과 호환되는 설계를 따릅니다. ESM에서 모듈의 위치 정보는 전역 변수 대신 import.meta로 제공되고, 특히 Node.js에서는 import.meta.url이 대표적입니다.
즉, ESM에서 경로의 기준점은 문자열 경로가 아니라 file: URL입니다.
가장 표준적인 해결: import.meta.url + fileURLToPath
Node.js ESM에서 파일 시스템 경로를 얻는 정석은 다음 조합입니다.
import.meta.url: 현재 모듈의 URL(대개file:스킴)fileURLToPath():file:URL을 OS 경로 문자열로 변환path.dirname(): 파일 경로에서 디렉터리 경로 추출
// ESM: package.json에 "type": "module" 이거나 .mjs
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 });
이제 CJS에서 쓰던 것처럼 __dirname을 사용할 수 있습니다.
실제 사용 예: 정적 파일/템플릿/설정 파일 읽기
import fs from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const configPath = path.join(__dirname, 'config', 'app.json');
const raw = await fs.readFile(configPath, 'utf-8');
const config = JSON.parse(raw);
console.log(config);
여기서 핵심은 “프로세스의 현재 작업 디렉터리”가 아니라 “현재 모듈 파일이 위치한 디렉터리”를 기준으로 잡는다는 점입니다.
더 간결한 패턴: new URL()로 상대 경로 안전하게 만들기
ESM에서는 상대 경로를 URL 기반으로 조립하는 편이 오히려 더 안전한 경우가 많습니다. 특히 Windows 경로, 공백, URL 인코딩 이슈를 피하는 데 도움이 됩니다.
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 raw = await readFile(configPath, 'utf-8');
console.log(JSON.parse(raw));
new URL('./config/app.json', import.meta.url)은 “현재 모듈 기준 상대 위치”를 정확히 가리킵니다.- 파일을 읽어야 한다면 마지막에
fileURLToPath()로 변환해서fsAPI에 넘깁니다.
흔한 오해: process.cwd()로 대충 때우면 안 되는 이유
ESM 전환 후 __dirname이 없으니 process.cwd()를 쓰는 경우가 많습니다.
// 위험할 수 있는 예
import path from 'node:path';
const configPath = path.join(process.cwd(), 'config', 'app.json');
이 코드는 “실행한 위치”에 따라 깨질 수 있습니다.
- 로컬에서는 프로젝트 루트에서 실행해서 잘 됨
- CI/CD에서는 다른 작업 디렉터리에서 실행되어 실패
- PM2/systemd/Docker에서
WORKDIR또는 서비스 설정에 따라 달라짐 - 테스트 러너가 워킹 디렉터리를 바꿔치기하는 경우
반면 import.meta.url은 “모듈 파일 위치” 기준이라 실행 위치가 바뀌어도 안정적입니다.
패키지/라이브러리 코드라면: 경로 기준점을 명확히
애플리케이션 코드(단일 서비스)에서는 보통 “현재 모듈 기준”이 정답입니다. 하지만 라이브러리/패키지에서는 어떤 기준이 필요한지 먼저 결정해야 합니다.
- 라이브러리 내부 리소스(템플릿, 기본 설정 등)를 읽는다:
import.meta.url기준 - 사용자가 지정한 경로를 읽는다: 입력을 절대경로로 받거나, 문서로 기준점을 명시
예: 라이브러리 내부 리소스 로드
import { readFile } from 'node:fs/promises';
export async function loadDefaultTemplate() {
const url = new URL('./assets/default.hbs', import.meta.url);
return readFile(url, 'utf-8');
}
Node의 fs는 URL도 받을 수 있습니다(현대 Node 버전 기준). 다만 팀/런타임 버전 정책에 따라 URL 지원 여부가 애매하면 fileURLToPath() 변환을 추가하는 편이 안전합니다.
TypeScript에서 ESM __dirname 패턴
TS로 작성하고 ESM으로 컴파일할 때도 동일합니다.
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
export function resolveFromHere(...segments: string[]) {
return path.join(__dirname, ...segments);
}
추가로, TS 프로젝트에서 타입 안정성과 추론을 강화하고 싶다면 satisfies 같은 기능이 도움 됩니다. 설정 객체를 다룰 일이 많다면 다음 글도 함께 참고할 만합니다.
번들러/런타임에 따라 달라지는 함정들
1) 번들링하면 import.meta.url 의미가 바뀔 수 있음
Webpack/Rollup/esbuild로 번들링하면 “원본 파일의 위치” 개념이 사라지거나 합쳐집니다. 이때 import.meta.url이 번들 파일 기준으로 바뀌고, 상대 경로로 찾던 리소스가 누락될 수 있습니다.
대응 전략은 보통 다음 중 하나입니다.
- 리소스를 번들에 포함시키는 방식으로 설계(예: JSON을 import)
- 런타임에 필요한 파일은 배포 산출물 구조에 포함시키고, 기준 경로를 명시적으로 설정(환경변수/설정)
- Node 실행 환경에서는 번들링을 피하고, 그대로 배포
2) 테스트 환경에서의 워킹 디렉터리 변화
Jest/Vitest는 실행 컨텍스트에 따라 워킹 디렉터리가 달라질 수 있어 process.cwd() 기반 경로가 흔들립니다. 테스트 대상 모듈이 내부 리소스를 읽는다면 import.meta.url 기반으로 바꾸는 게 안정적입니다.
3) require가 없어서 JSON을 못 읽는다고 착각
CJS에서는 require('./x.json')로 JSON을 쉽게 로드했지만, ESM에서는 기본적으로 동작 방식이 다릅니다(버전/옵션에 따라 JSON import가 필요).
실무적으로는 파일로 읽어 파싱하는 방식이 가장 이식성이 좋습니다.
import { readFile } from 'node:fs/promises';
const url = new URL('./config/app.json', import.meta.url);
const raw = await readFile(url, 'utf-8');
const config = JSON.parse(raw);
자주 묻는 질문(실무 관점)
Q1. 매 파일마다 __dirname 선언을 반복해야 하나
반복이 싫다면 유틸로 빼는 방법이 있습니다.
// utils/paths.js
import path from 'node:path';
import { fileURLToPath } from 'node:url';
export function dirFromMeta(metaUrl) {
return path.dirname(fileURLToPath(metaUrl));
}
// some-module.js
import path from 'node:path';
import { dirFromMeta } from './utils/paths.js';
const __dirname = dirFromMeta(import.meta.url);
const filePath = path.join(__dirname, 'data.txt');
핵심은 import.meta.url을 호출자 모듈에서 넘겨야 “그 모듈 기준”이 된다는 점입니다.
Q2. ESM으로 바꿨더니 Next.js/SSR에서 경로가 꼬인다
SSR/런타임 환경은 로컬 Node 실행과 달리 번들링/배포 구조의 영향을 크게 받습니다. Next.js처럼 빌드 산출물이 별도 디렉터리로 이동하는 프레임워크에서는 “파일 시스템 접근” 자체를 최소화하거나, 접근해야 한다면 배포 산출물 기준으로 경로 전략을 다시 잡아야 합니다.
SSR에서 흔한 오류 패턴은 하이드레이션/런타임 환경 차이와도 엮입니다. 관련해서는 다음 글도 참고할 수 있습니다.
결론: ESM에서는 모듈 위치를 URL로 다룬다
- ESM에서
__dirname이 없는 건 정상 동작이며, CJS 전역 변수에 기대던 습관을 바꿔야 합니다. - 가장 안전한 해결은
fileURLToPath(import.meta.url)로__filename을 만들고,path.dirname()으로 디렉터리를 얻는 패턴입니다. - 상대 경로 리소스는
new URL('./...', import.meta.url)로 구성하면 OS/인코딩 이슈에 강합니다. process.cwd()는 실행 위치에 따라 흔들리므로 “모듈 기준 경로”가 필요할 때는 피하는 것이 좋습니다.
위 패턴으로 바꾸면 ESM 전환 시 가장 흔한 경로 관련 에러(__dirname is not defined, 리소스 파일 못 찾음)를 안정적으로 정리할 수 있습니다.