- Published on
Node.js ESM+TS에서 ERR_MODULE_NOT_FOUND 해결법
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버에서 TypeScript를 ESM으로 돌리기 시작하면, 어느 순간 다음과 같은 에러를 만나기 쉽습니다.
Error [ERR_MODULE_NOT_FOUND]: Cannot find module ... imported from ...
CommonJS에서는 대충 돌아가던 import가, ESM으로 바꾸는 순간 엄격한 모듈 해석 규칙 때문에 실패하는 경우가 많습니다. 특히 TypeScript는 개발 시점에는 tsserver가 경로를 잘 찾아주는데, 런타임(Node.js)은 전혀 다른 규칙으로 모듈을 찾기 때문에 "컴파일은 되는데 실행이 안 되는" 상태가 흔합니다.
이 글은 ESM + TS 조합에서 ERR_MODULE_NOT_FOUND를 원인별로 빠르게 분류하고, 각각의 확실한 해결책을 제시합니다.
빌드/런타임 문제는 재현 환경이 중요합니다. CI에서만 터지는 경우라면 캐시/아티팩트 문제도 섞일 수 있으니, Docker 환경을 쓰는 팀이라면 Docker 빌드 캐시가 무효화되는 원인 7가지도 함께 점검해보세요.
1) ESM에서 가장 흔한 원인: 확장자 누락
증상
TypeScript 소스에서는 이렇게 작성합니다.
// src/index.ts
import { hello } from "./hello";
console.log(hello());
컴파일 후 dist/index.js가 되고, Node.js ESM으로 실행하면 다음이 터집니다.
Cannot find module '/.../dist/hello' imported from /.../dist/index.js
원인
Node.js ESM은 상대 경로 import에서 확장자를 생략하는 것을 기본적으로 허용하지 않습니다.
- CommonJS의
require("./hello")는./hello.js,./hello.json등을 추측하지만 - ESM의
import "./hello"는 기본적으로 추측하지 않습니다.
TypeScript는 개발 단계에서 확장자 없이도 잘 연결해주지만, 런타임은 다릅니다.
해결 1: 출력 JS 기준 확장자를 명시
가장 정석은 소스에서 .js 확장자를 명시하는 것입니다.
// src/index.ts
import { hello } from "./hello.js";
console.log(hello());
TypeScript는 ./hello.js를 보고도 src/hello.ts로 매핑해 컴파일합니다(설정에 따라 다름). 그리고 결과물 dist/index.js에도 ./hello.js가 남아 Node 런타임 규칙과 일치합니다.
권장 tsconfig
아래 조합이 ESM + TS에서 가장 무난합니다.
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true
}
}
module과moduleResolution을NodeNext로 맞추면, TypeScript가 Node ESM 규칙(확장자/exports 등)을 더 잘 따라갑니다.
해결 2: 개발 편의용 옵션(비권장): Node의 specifier resolution
Node에는 확장자 추측을 켜는 플래그가 있습니다.
node --experimental-specifier-resolution=node dist/index.js
하지만 이건 표준 ESM 동작을 바꾸는 옵션이라 팀/배포 환경에서 일관성이 깨질 수 있어 권장하지 않습니다.
2) package.json의 type과 파일 확장자 불일치
증상
- 로컬에서는
ts-node로 돌아가는데,node dist/index.js에서만 실패 - 또는 반대로, 어떤 파일은 되고 어떤 파일은 안 됨
원인
ESM 여부는 크게 두 가지로 결정됩니다.
package.json의"type": "module"유무- 파일 확장자:
.mjs(ESM),.cjs(CJS)
예를 들어 "type": "module"이면 .js는 ESM으로 해석됩니다. 이때 CJS 스타일로 작성된 파일(예: module.exports)이 섞이거나, 반대로 ESM으로 기대했는데 CJS로 로드되는 상황이 생기면 모듈 해석이 꼬이면서 ERR_MODULE_NOT_FOUND로 보이는 경우가 있습니다(실제 원인은 다른데 최종적으로 import가 실패).
해결
- ESM 프로젝트라면
package.json에"type": "module"을 명확히 두고 - CJS가 필요한 파일만
.cjs로 분리 - 라이브러리 배포라면
exports로 ESM/CJS 엔트리를 분리
예시:
{
"name": "my-app",
"type": "module",
"scripts": {
"build": "tsc -p tsconfig.json",
"start": "node dist/index.js"
}
}
3) TS path alias(paths)는 Node 런타임이 모른다
증상
TypeScript에서는 통과하지만 실행 시:
Cannot find package '@app/utils' imported from ...
원인
tsconfig.json의 baseUrl, paths는 TypeScript 컴파일러/IDE를 위한 매핑입니다. Node.js 런타임은 이 규칙을 모릅니다.
예:
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@app/*": ["src/*"]
}
}
}
소스에서:
import { sum } from "@app/utils/sum.js";
컴파일은 되지만, 런타임은 @app 패키지를 실제로 찾으려다 실패합니다.
해결 1: Node의 imports(권장, ESM 친화)
Node ESM은 package.json의 imports 필드로 내부 alias를 지원합니다(단, # prefix 사용).
{
"type": "module",
"imports": {
"#app/*": "./dist/*"
}
}
그리고 코드:
import { sum } from "#app/utils/sum.js";
주의할 점:
- 런타임 기준이므로 보통
dist를 가리키게 됩니다. - 개발/테스트 환경에서는 소스(
src)와 빌드 산출물(dist)을 어떻게 일치시킬지 전략이 필요합니다.
해결 2: 번들러/리라이터로 경로 변환
tsc만으로는 alias를 런타임 경로로 바꿔주지 않으므로, 다음 중 하나가 필요합니다.
- 번들러 사용(예:
tsup,esbuild,rollup) - 컴파일 후 import 경로를 rewrite하는 도구 사용
팀 규모가 커질수록 번들러가 운영 안정성을 올려주는 경우가 많습니다.
4) 패키지 exports 때문에 "있는데도" 못 찾는 경우
증상
node_modules에 패키지가 설치되어 있고 파일도 존재하는데import "some-lib/subpath"에서ERR_MODULE_NOT_FOUND
원인
ESM 시대의 많은 패키지는 package.json에 exports를 선언해 외부에 공개하는 진입점만 허용합니다. 파일이 물리적으로 있어도, exports에 없으면 접근이 차단됩니다.
예를 들어 아래처럼 exports가 제한되어 있으면:
{
"name": "some-lib",
"exports": {
".": "./dist/index.js"
}
}
import "some-lib/dist/internal.js" 같은 접근은 실패합니다.
해결
- 패키지가 공식적으로 노출한 경로로만 import
- 필요한 subpath가 없다면 패키지 버전/문서 확인 또는 이슈/PR
- 임시로는
exports를 우회하는 deep import를 피하고, 대체 API를 사용
5) TS 실행 방식별 체크리스트(ts-node, tsx, node)
ESM+TS는 "어떻게 실행하느냐"에 따라 모듈 해석이 크게 달라집니다.
A. tsc로 빌드 후 node 실행(운영 권장)
npm run build
node dist/index.js
- 운영에서 가장 예측 가능
- 확장자
.js명시,NodeNext설정, alias 처리 전략이 핵심
B. tsx로 개발 실행(개발 편의)
npx tsx src/index.ts
- 개발 속도는 좋지만, 프로덕션과 해석 규칙이 미묘하게 다를 수 있음
- 개발에서만 되는 import 패턴을 만들지 않도록 주의
C. ts-node ESM 모드(설정 민감)
node --loader ts-node/esm src/index.ts
- 로더/TS 설정이 조금만 어긋나도
ERR_MODULE_NOT_FOUND가 쉽게 발생 - 팀 공통 스크립트로 고정해두지 않으면 환경 차이가 커짐
6) 재현 가능한 최소 예제로 원인 좁히기
ERR_MODULE_NOT_FOUND는 메시지가 비슷해서 원인 파악이 늦어지기 쉽습니다. 아래 순서로 최소화하면 빠릅니다.
- 상대 경로 import만 남긴 최소 파일로 축소
- import에 확장자
.js를 모두 명시 tsconfig를NodeNext로 맞춤- alias(
paths)를 제거하고 상대 경로로 변경 - 그래도 실패하면 해당 패키지의
exports확인
최소 예제:
// src/hello.ts
export const hello = () => "hi";
// src/index.ts
import { hello } from "./hello.js";
console.log(hello());
// package.json
{
"type": "module",
"scripts": {
"build": "tsc -p tsconfig.json",
"start": "node dist/index.js"
}
}
// tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"rootDir": "src",
"outDir": "dist",
"strict": true
}
}
이 구성이 통과하면, 이후부터는 한 가지 변경(예: alias 도입, 라이브러리 교체)씩만 추가해 어디서 깨지는지 추적하는 것이 가장 빠릅니다.
7) 현업에서 많이 쓰는 결론 조합
- 운영:
tsc로 빌드 후node dist/...실행 - tsconfig:
module: "NodeNext",moduleResolution: "NodeNext" - 상대 경로 import: 반드시 출력 기준 확장자
.js명시 - alias: TS
paths만 믿지 말고 Nodeimports또는 번들러로 런타임까지 일치 - 서드파티: deep import 대신
exports에 공개된 엔트리만 사용
런타임 에러는 종종 "로컬에서는 되는데 CI에서만" 형태로 나타납니다. CI에서만 깨진다면 Node 버전 차이, 빌드 산출물 누락, 캐시 오염이 함께 원인이 될 수 있습니다. 그런 경우 빌드 파이프라인 자체의 신뢰성을 점검하는 관점도 도움이 됩니다. 예를 들어 인프라 이슈로 빌드가 비정상 상태에 빠질 때의 진단법은 Jenkins 빌드가 멈출 때 - 에이전트 오프라인 진단 같은 글의 접근법이 유사하게 적용됩니다.
8) 빠른 자가진단 Q&A
Q1. import "./x"를 import "./x.js"로 바꾸기 싫다
ESM 표준 규칙과 충돌합니다. 장기적으로는 확장자를 명시하는 편이 유지보수 비용이 낮습니다. 임시로는 --experimental-specifier-resolution=node가 있지만, 팀/배포 일관성을 해칩니다.
Q2. paths로 깔끔하게 정리했는데 런타임이 못 찾는다
TypeScript만의 기능입니다. Node imports(예: #app/*)로 옮기거나 번들러로 경로를 확정하세요.
Q3. 특정 라이브러리의 subpath import가 갑자기 깨졌다
대부분 패키지의 exports 변경입니다. 문서에 있는 엔트리로 바꾸거나 버전을 고정하고, 필요하면 upstream에 요청하는 것이 정석입니다.