Published on

Node.js ESM+TS에서 ERR_MODULE_NOT_FOUND 해결법

Authors

서버를 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는 크게 다음 상황에서 발생합니다.

  1. 컴파일 전 TS 경로를 그대로 런타임에서 import
    • src/foo.tsdist/index.js에서 import "./foo"로 기대하지만, 실제 dist에는 foo.js가 있고 Node는 확장자를 요구함
  2. 확장자 누락(import "./foo")
    • ESM은 기본적으로 확장자 없는 상대 경로 import를 자동 보정해주지 않습니다(번들러가 해주던 일)
  3. tsconfig의 module/moduleResolution이 ESM과 불일치
    • TS는 통과했지만 Node 런타임 해석 규칙과 다름
  4. package.json exports/type 설정 불일치
    • 패키지 내부/외부에서 바라보는 엔트리포인트가 달라짐
  5. 경로 별칭(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가 납니다.

해결 옵션

  1. 별칭을 포기하고 상대 경로로 통일(단순/견고)

  2. 번들러 사용(Vite/tsup/esbuild)

    • 번들링 과정에서 별칭을 실제 경로로 치환
  3. 런타임/빌드 후 리라이트 도구 사용

    • 예: 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.outDirdist인데
  • 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는 재발하지 않습니다.