Published on

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

Authors

서버에서 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
  }
}
  • modulemoduleResolutionNodeNext로 맞추면, TypeScript가 Node ESM 규칙(확장자/exports 등)을 더 잘 따라갑니다.

해결 2: 개발 편의용 옵션(비권장): Node의 specifier resolution

Node에는 확장자 추측을 켜는 플래그가 있습니다.

node --experimental-specifier-resolution=node dist/index.js

하지만 이건 표준 ESM 동작을 바꾸는 옵션이라 팀/배포 환경에서 일관성이 깨질 수 있어 권장하지 않습니다.

2) package.jsontype과 파일 확장자 불일치

증상

  • 로컬에서는 ts-node로 돌아가는데, node dist/index.js에서만 실패
  • 또는 반대로, 어떤 파일은 되고 어떤 파일은 안 됨

원인

ESM 여부는 크게 두 가지로 결정됩니다.

  1. package.json"type": "module" 유무
  2. 파일 확장자: .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.jsonbaseUrl, pathsTypeScript 컴파일러/IDE를 위한 매핑입니다. Node.js 런타임은 이 규칙을 모릅니다.

예:

{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@app/*": ["src/*"]
    }
  }
}

소스에서:

import { sum } from "@app/utils/sum.js";

컴파일은 되지만, 런타임은 @app 패키지를 실제로 찾으려다 실패합니다.

해결 1: Node의 imports(권장, ESM 친화)

Node ESM은 package.jsonimports 필드로 내부 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.jsonexports를 선언해 외부에 공개하는 진입점만 허용합니다. 파일이 물리적으로 있어도, 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는 메시지가 비슷해서 원인 파악이 늦어지기 쉽습니다. 아래 순서로 최소화하면 빠릅니다.

  1. 상대 경로 import만 남긴 최소 파일로 축소
  2. import에 확장자 .js를 모두 명시
  3. tsconfigNodeNext로 맞춤
  4. alias(paths)를 제거하고 상대 경로로 변경
  5. 그래도 실패하면 해당 패키지의 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만 믿지 말고 Node imports 또는 번들러로 런타임까지 일치
  • 서드파티: 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에 요청하는 것이 정석입니다.