- Published on
Node.js ESM+TS에서 ERR_MODULE_NOT_FOUND 해결법
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버를 Node.js ESM("type": "module")로 전환하고 TypeScript까지 얹는 순간, 가장 자주 마주치는 런타임 에러가 ERR_MODULE_NOT_FOUND입니다. CommonJS 시절에는 대충 동작하던 import가, ESM에서는 확장자/경로 해석 규칙이 훨씬 엄격해지면서 작은 설정 차이도 바로 실패로 이어집니다.
이 글은 “왜 Node가 모듈을 못 찾는지”를 ESM 관점에서 분해하고, TS 컴파일 산출물 구조와 Node의 ESM resolver를 일치시키는 방식으로 문제를 끝내는 데 집중합니다.
> 운영에서 이런 류의 “설정-런타임 불일치”는 쿠버네티스의 헬스체크 오탐처럼 증상이 엉뚱하게 보이기도 합니다. 비슷한 디버깅 사고방식은 K8s CrashLoopBackOff - liveness probe 오탐 해결도 참고할 만합니다.
ERR_MODULE_NOT_FOUND가 나는 대표 패턴
Node ESM에서 ERR_MODULE_NOT_FOUND는 크게 다음 상황에서 발생합니다.
- 컴파일 전 TS 경로를 그대로 런타임에서 import
src/foo.ts를dist/index.js에서import "./foo"로 기대하지만, 실제 dist에는foo.js가 있고 Node는 확장자를 요구함
- 확장자 누락(import "./foo")
- ESM은 기본적으로 확장자 없는 상대 경로 import를 자동 보정해주지 않습니다(번들러가 해주던 일)
- tsconfig의 module/moduleResolution이 ESM과 불일치
- TS는 통과했지만 Node 런타임 해석 규칙과 다름
- package.json exports/type 설정 불일치
- 패키지 내부/외부에서 바라보는 엔트리포인트가 달라짐
- 경로 별칭(paths)만 믿고 런타임 리라이트가 없음
@/utils는 TS만 이해하고 Node는 모름(별도 변환 필요)
이제 케이스별로 “재현 → 원인 → 해결”을 실전 설정으로 정리합니다.
기본 전제: ESM+TS에서 가장 안정적인 구성
가장 권장되는 접근은 다음 중 하나입니다.
- (A) tsc로 dist를 만들고 Node가 dist를 실행: 가장 표준적, 런타임 단순
- (B) tsx/ts-node로 런타임에서 TS 실행: 개발 편하지만 배포는 보통 A로 수렴
이 글은 배포 친화적인 (A)를 기준으로 설명합니다.
예시 프로젝트 구조
.
├─ package.json
├─ tsconfig.json
├─ src/
│ ├─ index.ts
│ └─ lib/
│ └─ hello.ts
└─ dist/ (빌드 후 생성)
원인 1) ESM은 상대 경로 import에 확장자가 필요하다
재현
src/index.ts
import { hello } from "./lib/hello";
console.log(hello());
src/lib/hello.ts
export const hello = () => "hi";
tsc로 빌드하면 dist/index.js 안에는 보통 아래처럼 남습니다.
import { hello } from "./lib/hello";
console.log(hello());
그리고 실행:
node dist/index.js
결과:
Error [ERR_MODULE_NOT_FOUND]: Cannot find module .../dist/lib/hello imported from .../dist/index.js
원인
Node ESM은 ./lib/hello를 보고 자동으로 ./lib/hello.js를 붙여주지 않습니다. 번들러(Webpack/Vite)가 해주던 “확장자 보정”을 Node는 기본적으로 하지 않습니다.
해결 1) 소스에서 .js 확장자를 명시(가장 확실)
TypeScript 소스에서 미래의 산출물 확장자(.js) 를 명시합니다.
src/index.ts
import { hello } from "./lib/hello.js";
console.log(hello());
처음에는 어색하지만, ESM+tsc 조합에서는 가장 예측 가능하고 배포 안정적입니다.
해결 2) Node 플래그로 보정(권장도 낮음)
node --experimental-specifier-resolution=node dist/index.js
이 플래그는 과거 호환을 위해 존재하지만, 장기적으로는 “명시적 확장자”가 더 안전합니다.
원인 2) tsconfig의 module/moduleResolution이 Node ESM과 불일치
TS는 컴파일 단계에서 “이 import가 유효한가”를 판단합니다. 여기서 TS가 통과시켰다고 해서 Node 런타임이 동일하게 해석한다는 보장은 없습니다.
권장 tsconfig (NodeNext)
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true
},
"include": ["src"]
}
module: NodeNext+moduleResolution: NodeNext는 Node의 ESM/CJS 규칙을 최대한 따라갑니다.- 특히
.js확장자 명시, 패키지exports해석 등에서 TS의 판단이 Node와 가까워집니다.
package.json도 함께 정렬
{
"name": "esm-ts-app",
"type": "module",
"scripts": {
"build": "tsc -p tsconfig.json",
"start": "node dist/index.js"
}
}
여기서 "type": "module"이 빠져 있으면 dist의 .js가 CJS로 해석되어 또 다른 에러(예: require is not defined, Unexpected token 'export')로 번질 수 있습니다.
원인 3) path alias(paths)는 Node가 모른다
TS에서 흔히:
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
}
}
그리고 코드:
import { hello } from "@/lib/hello.js";
TS는 이해하지만, dist 실행 시 Node는 @/lib/hello.js를 해석하지 못해 ERR_MODULE_NOT_FOUND가 납니다.
해결 옵션
별칭을 포기하고 상대 경로로 통일(단순/견고)
번들러 사용(Vite/tsup/esbuild)
- 번들링 과정에서 별칭을 실제 경로로 치환
런타임/빌드 후 리라이트 도구 사용
- 예:
tsc-alias로 dist의 경로를 변환
- 예:
(예시) tsc-alias로 dist 리라이트
npm i -D tsc-alias
package.json
{
"scripts": {
"build": "tsc -p tsconfig.json && tsc-alias -p tsconfig.json",
"start": "node dist/index.js"
}
}
이 방식은 “tsc 기반 배포”를 유지하면서 별칭도 쓸 수 있게 해줍니다. 다만 ESM에서 확장자 이슈까지 동시에 다루려면 규칙을 엄격히(예: 소스에서 .js 명시) 가져가는 게 좋습니다.
원인 4) exports 설정이 import 경로를 막는다
패키지(라이브러리/모노레포 내부 패키지)를 만들 때 exports를 쓰면, 소비자는 exports에 선언된 경로만 import할 수 있습니다.
package.json
{
"name": "@acme/core",
"type": "module",
"exports": {
".": "./dist/index.js"
}
}
이 경우 소비자가 아래처럼 내부 파일을 직접 import하면:
import { x } from "@acme/core/dist/internal.js";
대개 ERR_MODULE_NOT_FOUND 또는 ERR_PACKAGE_PATH_NOT_EXPORTED로 실패합니다.
해결
- 소비 측에서는 공개 API만 import
- 제공 측에서는 필요한 서브패스를 exports에 명시
{
"exports": {
".": "./dist/index.js",
"./internal": "./dist/internal.js"
}
}
ESM 전환 시 “경로가 분명히 존재하는데도 못 찾는다”는 느낌을 주는 대표 원인이라, 패키지 간 의존이 있는 프로젝트라면 꼭 점검해야 합니다.
원인 5) 빌드 산출물 경로(outDir)와 실행 엔트리 불일치
의외로 단순한데 자주 납니다.
tsconfig.outDir는dist인데node build/index.js처럼 다른 경로를 실행
또는 rootDir/include 설정이 꼬여서 dist/src/index.js처럼 한 단계 더 들어가는 경우도 있습니다.
빠른 체크리스트
# 1) 빌드 결과 확인
ls -R dist | head
# 2) 실제 엔트리 파일 존재 확인
node -e "import('node:fs').then(fs=>console.log(fs.existsSync('dist/index.js')))"
해결
rootDir: "src"를 명시해 dist 구조를 예측 가능하게 만들고- start 스크립트를 실제 산출물에 맞춥니다.
실전 추천 조합(복붙용)
1) 소스에서 .js 확장자 명시 + NodeNext
src/index.ts
import { hello } from "./lib/hello.js";
console.log(hello());
tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"rootDir": "src",
"outDir": "dist",
"strict": true
},
"include": ["src"]
}
package.json
{
"type": "module",
"scripts": {
"build": "tsc",
"start": "node dist/index.js"
}
}
이 조합은 “Node가 기대하는 ESM 규칙”과 “tsc가 내보내는 JS”가 가장 잘 맞습니다.
2) 개발 편의: tsx로 개발, 배포는 tsc
npm i -D tsx typescript
package.json
{
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"start": "node dist/index.js"
}
}
개발 중에는 빠르게 돌리고, 배포는 dist를 실행해 런타임을 단순화합니다.
디버깅 팁: “Node가 무엇을 찾다 실패했는지”를 로그로 확인
에러 메시지에 찍히는 경로가 핵심입니다.
- 실패 경로가
dist/...라면: dist에 그 파일이 실제로 있는지, 확장자가.js인지 - 실패 경로가
@/...같은 별칭이라면: 별칭 리라이트가 없는 것 - 실패가 패키지 내부 경로라면: exports로 막힌 것
또, ESM 해석은 배포 환경에 따라 더 엄격해 보일 수 있습니다(컨테이너, CI). 이런 환경 차이 디버깅은 OIDC/권한/엔드포인트처럼 “로컬에선 되는데 배포에서만 실패” 패턴과 유사합니다. 배포 파이프라인 관점은 GitHub Actions OIDC로 AWS 키 없이 배포하기에서 다룬 방식처럼, 실행 환경을 재현 가능하게 만드는 게 중요합니다.
마무리: ERR_MODULE_NOT_FOUND를 끝내는 핵심 원칙
- ESM에서는 import 경로가 ‘정확한 파일명’이어야 한다: 상대 경로는
.js까지 명시하는 습관이 가장 강력합니다. - TS 해석 규칙을 Node와 맞춘다:
module/moduleResolution: NodeNext로 정렬하세요. - TS 전용 기능(paths)은 런타임에서 사라진다: 별칭을 쓰려면 번들링 또는 리라이트가 필요합니다.
- 패키지 exports는 의도적으로 경로를 막는다: 공개 API/서브패스 exports를 명확히 설계하세요.
이 네 가지를 맞추면 ESM+TS 조합에서 가장 흔한 ERR_MODULE_NOT_FOUND는 재발하지 않습니다.