Published on

Node.js ESM+TS 실행 시 ERR_MODULE_NOT_FOUND 해결

Authors

서론

Node.js에서 ESM(ECMAScript Modules)로 전환하고 TypeScript까지 얹으면, 개발 중 가장 흔하게 마주치는 런타임 오류가 ERR_MODULE_NOT_FOUND입니다. CommonJS 시절에는 “대충 돌아가던” import가 ESM에서는 훨씬 엄격해지고, TypeScript 컴파일 결과물(.js)과 소스(.ts) 사이의 경로/확장자 불일치가 즉시 드러납니다.

이 글은 “왜 발생하는지”를 원리부터 짚고, 빌드 후 실행(node dist), 런타임 TS 실행(ts-node/tsx), 패키지 exports, tsconfig paths, 상대경로 확장자까지 케이스별로 재현/해결 방법을 제공합니다.


ERR_MODULE_NOT_FOUND의 본질: ESM의 해석 규칙

Node.js ESM 로더는 기본적으로 다음을 강하게 요구합니다.

  1. 상대경로 import는 파일 확장자를 포함해야 함 (./foo.js)
  2. 디렉터리 import는 index.js 자동 탐색이 제한적(특히 exports 사용 시 더 엄격)
  3. TypeScript의 paths/baseUrlNode 런타임이 모름 (tsc만 아는 기능)
  4. 빌드 결과물 경로와 런타임 실행 위치가 어긋나면 모듈을 못 찾음

즉, TypeScript가 컴파일해준다고 해서 Node가 “TS 방식으로” import를 찾아주지 않습니다.


가장 흔한 원인 1: .ts로 작성했는데 런타임은 .js를 찾는다

증상

// src/index.ts
import { hello } from './hello';
console.log(hello());

tsc로 빌드해서 dist/index.js를 실행하면:

Error [ERR_MODULE_NOT_FOUND]: Cannot find module '/.../dist/hello' imported from /.../dist/index.js

왜 이런가?

TypeScript는 소스에서 ./hello를 썼더라도, 빌드 산출물에서 import가 ./hello로 남아있을 수 있습니다. ESM 환경에서 Node는 ./hello를 보고 정확히 어떤 파일인지 결정하지 못하면 실패합니다(특히 확장자 없는 상대경로).

해결책 A: 소스에서 ESM 규칙대로 .js 확장자를 명시

TypeScript 소스에서 미리 .js를 적는 패턴이 ESM+TS 조합에서 가장 보편적인 해법입니다.

// src/index.ts
import { hello } from './hello.js';
console.log(hello());
// src/hello.ts
export const hello = () => 'hello';

처음엔 어색하지만, tsc는 이를 이해하고 빌드 시에도 올바른 경로를 유지합니다.

함께 권장되는 tsconfig

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "outDir": "dist",
    "rootDir": "src",
    "strict": true,
    "esModuleInterop": true
  }
}

module/moduleResolutionNodeNext로 두면 TS가 Node의 ESM 규칙에 더 맞춰 경고/해석을 해줍니다.


가장 흔한 원인 2: package.jsontype: module로 인해 CJS처럼 import가 깨진다

증상

package.json에 다음이 있을 때:

{
  "type": "module"
}

과거 CJS 스타일이 섞여 있으면 예기치 않게 모듈 탐색이 꼬입니다.

예:

// ESM 프로젝트인데 require를 섞거나,
// 혹은 확장자 없는 상대경로를 CJS처럼 기대
import x from './lib';

해결책

  • ESM으로 갈 거면 프로젝트 전체를 ESM 규칙으로 정리
  • CJS를 유지할 거면 type을 제거하고 .mjs/.cjs로 명시적으로 분리

혼합 운영이 필요하면:

  • ESM 파일: .mjs
  • CJS 파일: .cjs

로 분리하는 게 런타임 예측 가능성이 훨씬 높습니다.


원인 3: TypeScript의 paths 별칭을 Node가 모른다

증상

// tsconfig.json
{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@/*": ["src/*"]
    }
  }
}
// src/index.ts
import { hello } from '@/hello.js';

IDE/tsc는 통과하지만, 런타임에서:

ERR_MODULE_NOT_FOUND: Cannot find package '@/hello.js'

해결책 A: 빌드 후 실행이라면 alias를 실제 상대경로로 바꾸기

가장 단순하고 확실합니다.

import { hello } from './hello.js';

해결책 B: 번들러/빌드 툴로 alias를 실제 경로로 변환

  • tsup / esbuild / vite / webpack 등의 번들링 과정에서 alias를 풀어줌
  • “tsc만으로 dist 만들고 node로 실행”하는 형태에서는 추가 작업이 필요

해결책 C: Node의 imports(package.json) 매핑 사용

Node ESM은 # 프리픽스를 가진 내부 매핑을 지원합니다.

{
  "type": "module",
  "imports": {
    "#/*": "./dist/*"
  }
}

그리고 코드에서는:

import { hello } from '#/hello.js';

단, 이 방식은 빌드 산출물 기준으로 매핑해야 하므로, 개발/빌드 파이프라인을 같이 설계해야 합니다.

TypeScript의 타입 추론/설정 이슈를 다루다 보면 tsconfig 옵션이 런타임 문제로 번지는 경우가 많습니다. 타입 시스템 변화로 인한 대응은 TS 5.6 satisfies로 타입추론 깨짐 해결 7가지도 함께 참고하면 좋습니다.


원인 4: exports를 걸어두고 내부 파일을 직접 import한다

패키지를 라이브러리 형태로 만들거나 모노레포에서 내부 패키지를 참조할 때 자주 터집니다.

증상

// packages/foo/package.json
{
  "name": "foo",
  "type": "module",
  "exports": {
    ".": "./dist/index.js"
  }
}

이 상태에서 소비자가:

import { x } from 'foo/dist/internal.js';

하면 Node는 exports에 없는 경로 접근을 차단하거나 모듈을 못 찾는 형태로 실패합니다.

해결책

  • 외부에 공개할 엔트리만 exports에 명시
  • 내부 파일을 직접 import하지 말고 public API를 통해 접근
  • 정말 필요하면 subpath export를 명시
{
  "exports": {
    ".": "./dist/index.js",
    "./internal": "./dist/internal.js"
  }
}

소비자 코드:

import { x } from 'foo/internal';

원인 5: ts-node로 ESM+TS를 바로 실행할 때 로더 설정 누락

증상

개발 단계에서 빌드 없이 실행:

node src/index.ts

혹은:

ts-node src/index.ts

ESM 프로젝트에서는 로더/옵션이 맞지 않으면 ERR_MODULE_NOT_FOUND 또는 “Unknown file extension .ts” 계열 오류가 섞여 나옵니다.

해결책 A: tsx 사용(가장 간단)

npm i -D tsx
npx tsx src/index.ts

tsx는 ESM/TS 조합에서 설정 부담이 적고, 개발 경험이 좋습니다.

해결책 B: node + ts-node ESM 로더

npm i -D ts-node typescript
node --loader ts-node/esm src/index.ts

그리고 tsconfig는 NodeNext 권장:

{
  "compilerOptions": {
    "module": "NodeNext",
    "moduleResolution": "NodeNext"
  }
}

실전 추천 구성: “빌드 후 실행”을 기준으로 단순화

운영 환경(서버/컨테이너)에서는 결국 JS 산출물을 실행하는 경우가 많습니다. 그 기준으로 가장 예측 가능한 조합은 아래와 같습니다.

1) package.json

{
  "name": "my-app",
  "type": "module",
  "scripts": {
    "build": "tsc -p tsconfig.json",
    "start": "node dist/index.js",
    "dev": "tsx src/index.ts"
  }
}

2) tsconfig.json

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "rootDir": "src",
    "outDir": "dist",
    "strict": true,
    "sourceMap": true
  },
  "include": ["src"]
}

3) 소스 import 규칙

  • 상대경로는 반드시 .js 확장자
  • alias는 최소화(쓰면 번들러로 풀거나 Node imports로 설계)
// src/index.ts
import { hello } from './hello.js';
console.log(hello());

이 조합이면 “개발은 TS로 빠르게, 운영은 dist JS로 안정적으로”를 동시에 만족시키기 쉽습니다.


디버깅 체크리스트: 5분 안에 원인 좁히기

  1. 실행 파일이 dist인지 src인지 확인
    • node dist/index.js인지, tsx src/index.ts인지부터 분리
  2. 문제가 되는 import가 상대경로인지 패키지 경로인지 확인
    • 상대경로면 확장자 누락이 1순위
  3. tsconfigNodeNext인지 확인
  4. paths alias를 썼다면 런타임에서 해석 가능한지 확인
  5. 라이브러리/모노레포면 exports가 막고 있는지 확인

운영 환경에서 이런 문제가 CrashLoopBackOff 같은 형태로 나타나면, 애플리케이션 로그가 곧 정답입니다. 컨테이너 환경에서 재시작/헬스체크까지 엮여 원인 파악이 어려울 땐 Kubernetes CrashLoopBackOff 원인별 로그·Probe·리소스 디버깅 같은 흐름으로 접근하면 진단 시간이 줄어듭니다.


결론

ERR_MODULE_NOT_FOUND는 “파일이 없어서”라기보다, ESM 해석 규칙과 TS 빌드 산출물의 불일치에서 시작되는 경우가 대부분입니다. 특히 ESM에서는 상대경로 확장자, exports 경계, TS paths의 런타임 부재가 핵심 함정입니다.

가장 안정적인 처방은 다음 3가지를 동시에 지키는 것입니다.

  • module/moduleResolution: NodeNext
  • 상대경로 import에 .js 확장자 명시
  • 개발은 tsx, 운영은 tsc + node dist로 분리

이렇게 구성하면 ESM+TS의 장점(명확한 모듈 경계, 최신 문법, 타입 안정성)을 유지하면서도, 런타임 모듈 탐색 문제로 시간을 소모하는 일을 크게 줄일 수 있습니다.