- Published on
Node.js ESM 전환 후 exports import 오류 해결
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
Node.js 프로젝트를 ESM으로 전환하면 가장 먼저 터지는 문제 중 하나가 package.json의 "exports"로 인해 import가 막히는 케이스입니다. CommonJS 시절에는 관성적으로 pkg/some/internal/file 같은 딥 임포트(deep import)를 하거나, 번들러가 알아서 경로를 풀어주던 코드가 많았습니다. 그런데 ESM 전환 후 Node 런타임은 exports 맵을 엄격하게 적용하면서, 패키지가 허용한 엔트리 포인트 외 경로 접근을 차단합니다.
이 글에서는 ESM 전환 이후 흔히 보는 오류 메시지를 분류하고, exports를 기준으로 올바른 import로 바꾸는 방법, 라이브러리 제작자/소비자 입장에서의 해결책, 그리고 타입스크립트/테스트 환경까지 포함한 실전 체크리스트를 정리합니다.
ESM 전환 자체에서 발생하는 ERR_REQUIRE_ESM 류의 문제는 아래 글에서 먼저 정리해 두었습니다.
증상: ESM 전환 후 "exports" 관련 import 오류
대표적으로 아래 같은 메시지를 보게 됩니다.
ERR_PACKAGE_PATH_NOT_EXPORTED: Package subpath './lib/foo' is not defined by "exports"Package subpath './package.json' is not defined by "exports"Cannot find module 'some-pkg/lib/index.js' imported from ...- 타입스크립트에서
Cannot find module 'some-pkg/some-path' or its corresponding type declarations
핵심 원인은 간단합니다.
- 패키지가
package.json에"exports"를 선언하면, 그 패키지의 공개 API는 exports에 정의된 경로로만 제한됩니다. - 예전처럼 내부 파일 경로로 바로 들어가는 딥 임포트는 대부분 막힙니다.
왜 ESM에서 특히 더 자주 터질까
CommonJS 시절에도 exports는 존재했지만, 많은 프로젝트가 다음 중 하나에 의존해 왔습니다.
- 번들러(Webpack/Vite)가 딥 임포트를 관대하게 처리
- Node 해석 규칙(확장자 생략, index 파일 자동 탐색 등)에 대한 암묵적 기대
- 라이브러리가 CJS 우선으로 설계되어 ESM 경로가 덜 정리됨
ESM으로 전환하면 Node의 해석 규칙이 더 엄격해지고(특히 확장자, 조건부 exports), 런타임에서 바로 exports 제약이 드러나면서 문제가 표면화됩니다.
빠른 진단: 지금 import가 왜 막히는지 확인하는 법
1) 해당 패키지의 package.json에서 exports 확인
node_modules/패키지명/package.json을 열어 "exports"를 확인합니다. 예를 들어:
{
"name": "some-pkg",
"type": "module",
"exports": {
".": {
"import": "./dist/index.js",
"require": "./dist/index.cjs"
},
"./feature": {
"import": "./dist/feature.js"
}
}
}
위처럼 되어 있으면 소비자는 아래만 쓸 수 있습니다.
import ... from 'some-pkg'import ... from 'some-pkg/feature'
반대로 아래는 금지됩니다.
import ... from 'some-pkg/dist/feature.js'import ... from 'some-pkg/package.json'
2) 에러에 나온 subpath가 exports에 있는지 대조
에러가 ./dist/feature.js 같은 형태를 내뱉으면, 그 경로가 exports에 정의되어 있는지 확인합니다. 없다면 해결책은 둘 중 하나입니다.
- 소비자 코드에서 공개된 엔트리 포인트로 import를 변경
- (내가 라이브러리 작성자라면) exports에 해당 subpath를 추가
소비자(사용자) 관점 해결: 딥 임포트를 공개 경로로 바꾸기
케이스 A: 예전 코드가 내부 파일을 직접 import
문제 코드:
// ESM 전환 후 터지기 쉬운 딥 임포트
import { parse } from 'some-pkg/dist/parser.js'
해결 1: 패키지가 제공하는 공개 경로 사용
import { parse } from 'some-pkg'
해결 2: 패키지가 별도 서브패스를 제공한다면 그걸 사용
import { parse } from 'some-pkg/parser'
여기서 핵심은 “내가 원하는 심볼이 어디에 export 되어 있는가”입니다. 딥 임포트로 당장 동작하게 만들기보다, 패키지가 의도한 공개 API를 따르는 게 장기적으로 안전합니다.
케이스 B: package.json을 읽으려다 막힘
ESM 전환 전에는 버전 체크 등을 위해 아래 같은 코드를 쓰기도 합니다.
import pkg from 'some-pkg/package.json'
하지만 많은 패키지가 ./package.json을 exports에 열어두지 않아 막힙니다.
해결 1: 런타임에서 패키지 버전이 꼭 필요 없다면 제거
해결 2: Node의 createRequire로 우회(권장도 낮음)
import { createRequire } from 'node:module'
const require = createRequire(import.meta.url)
const pkg = require('some-pkg/package.json')
console.log(pkg.version)
주의: 이 우회는 패키지 설계 의도(비공개)를 깨는 방식일 수 있고, 앞으로도 계속 동작한다는 보장이 약합니다. 가능하면 패키지가 공식적으로 제공하는 API(예: version을 노출하거나, exports에 ./package.json을 추가)를 쓰는 편이 낫습니다.
케이스 C: 확장자 생략이 섞여 ESM에서 깨짐
특히 로컬 파일 import에서 흔합니다.
// CJS 습관: 확장자 생략
import { foo } from './utils'
ESM(Node 런타임)에서는 보통 확장자가 필요합니다.
import { foo } from './utils.js'
이 문제는 exports와 직접 관련이 없어 보이지만, 실제로는 exports로 경로가 엄격해진 환경에서 함께 터져 “ESM 전환 후 import가 다 깨졌다”로 체감되는 경우가 많습니다.
라이브러리(패키지) 작성자 관점 해결: exports 맵을 올바르게 설계
내가 관리하는 라이브러리를 ESM으로 전환했는데 사용자들이 ERR_PACKAGE_PATH_NOT_EXPORTED를 겪는다면, exports 설계가 부족한 경우가 많습니다.
1) 최소한의 안전한 exports 템플릿
{
"name": "my-lib",
"type": "module",
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
}
}
}
포인트:
exports에types조건을 함께 두면 TS가 더 잘 따라옵니다.import/require를 분리하면 ESM/CJS 동시 지원이 쉬워집니다.
2) 서브패스 exports로 공개 API를 명확히
사용자가 my-lib/feature로 가져오길 원한다면 exports에 명시합니다.
{
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
},
"./feature": {
"types": "./dist/feature.d.ts",
"import": "./dist/feature.js",
"require": "./dist/feature.cjs"
}
}
}
이렇게 하면 사용자는 내부 폴더 구조를 몰라도 됩니다.
import { feature } from 'my-lib/feature'
3) exports를 쓰면 "막는 것"이 기본값임을 받아들이기
exports는 보안 기능이라기보단 API 경계선입니다.
- 장점: 내부 파일 구조 변경이 사용자에게 덜 영향을 줌
- 단점: 과거에 딥 임포트하던 사용자 코드는 깨짐
따라서 마이그레이션 시에는 릴리즈 노트에 “이제 딥 임포트는 지원하지 않는다” 또는 “아래 서브패스로 대체하라”를 명확히 적는 게 중요합니다.
TypeScript에서의 추가 함정: 타입 해석과 exports 조건
TS는 Node 런타임과 해석 규칙이 100% 같지 않아서, 런타임은 되는데 TS가 깨지거나 그 반대가 종종 발생합니다.
권장 tsconfig.json (NodeNext 계열)
{
"compilerOptions": {
"module": "NodeNext",
"moduleResolution": "NodeNext",
"target": "ES2022",
"verbatimModuleSyntax": true,
"resolveJsonModule": true
}
}
포인트:
moduleResolution을NodeNext로 맞추면exports조건을 더 현실적으로 따라갑니다.- 패키지 작성자라면
exports에types를 넣는 것이 TS 호환성에 유리합니다.
테스트/런타임 환경에서의 함정: Jest, ts-node, bundler
ESM 전환 후 exports 문제는 런타임이 Node인지, Jest인지, ts-node인지에 따라 증상이 달라질 수 있습니다.
- Jest는 ESM 지원이 개선됐지만 설정에 따라 CJS로 돌면서
require조건을 타기도 합니다. - ts-node도 ESM 모드 설정이 미흡하면
import를require처럼 처리하려고 하며, 이때exports의require경로가 없으면 실패합니다.
실무 팁:
- 패키지 작성자라면
exports에import만 두지 말고 가능하면require도 같이 제공 - 소비자라면 테스트 러너가 어떤 조건(
import/require)으로 패키지를 로딩하는지 확인
실전 디버깅 레시피
1) 어떤 엔트리로 해석되는지 확인
Node에서 조건부 exports가 어떻게 선택되는지 확인하려면, 문제를 최소 재현으로 줄여 다음을 실행합니다.
node -e "import('some-pkg').then(m=>console.log(Object.keys(m)))"
또는 CJS 컨텍스트에서:
node -e "console.log(Object.keys(require('some-pkg')))"
두 결과가 다르면 exports의 import/require 조건 분기가 원인일 가능성이 큽니다.
2) 에러에 나온 subpath를 exports에 추가할지, import를 바꿀지 결정
- 외부 사용자 코드라면: 대체 가능한 공개 경로가 있는지 먼저 찾기
- 내부 사내 패키지라면: 마이그레이션 비용을 줄이기 위해 서브패스 exports를 추가하는 편이 빠를 수 있음
마이그레이션 체크리스트
- 딥 임포트(
pkg/dist/...,pkg/lib/...)를 전수 검색해 공개 import로 치환 package.json접근이 필요한지 재검토(필요하면 공식 API/서브패스 제공)- 라이브러리라면
exports에types,import,require를 함께 고려 - TS는
NodeNext계열로 맞추고, 테스트 러너가 어느 조건을 사용하는지 확인 - 로컬 파일 import는 확장자 누락 여부 점검
마무리
ESM 전환 후 "exports"로 인한 import 오류는 “갑자기 Node가 까다로워졌다”기보다, 그동안 암묵적으로 의존하던 딥 임포트와 해석 규칙이 exports라는 명시적 API 경계에 의해 드러난 결과입니다. 해결의 정답은 대개 하나입니다. 패키지가 공개한 경로로 가져오고, 공개할 필요가 있다면 exports에 정식으로 열어라.
ESM 전환 과정에서 함께 자주 등장하는 ERR_REQUIRE_ESM 문제는 아래 글에서 추가로 확인할 수 있습니다.