개발 가이드
package.json exports에서 자주 하는 실수 6가지
작성: Stella 게시:
라이브러리를 배포하면서 package.json에 exports 필드를 추가하는 순간, 그동안 잘 동작하던 코드가 갑자기 깨지는 경험을 하는 개발자가 많습니다. exports는 main과 달리 규칙이 엄격하고, 잘못 설정했을 때 Node.js가 내는 에러 메시지도 친절한 편이 아닙니다.
이 글은 exports 필드에서 실제로 자주 발생하는 실수 6가지를 사례와 함께 정리하고, 각 실수를 어떻게 확인하고 고치는지 설명합니다. 글에 나오는 에러 출력은 전부 Node.js v22.14.0에서 실수 상황을 그대로 만든 테스트 패키지로 직접 재현한 것입니다. 에러 메시지의 경로 앞부분(테스트 폴더 위치)만 ...으로 줄였습니다.
상단 요약
exports를 정의하는 순간, 명시하지 않은 모든 경로는 차단된다. 기존 deep import가 깨지는 가장 흔한 원인이다.- export target 경로는 반드시
./로 시작해야 한다.dist/index.js는 유효하지 않다. - 같은 레벨에서
"."같은 subpath key와"import"같은 condition key를 섞으면 설정 전체가 무효가 된다. - 조건은 선언 순서대로 매칭되므로
default는 항상 마지막에 둔다. exports가 있으면main은 exports를 지원하는 환경에서 무시된다. 한쪽만 고치면 안 된다.
exports의 기본 동작: 봉인(encapsulation)
실수를 이해하려면 exports의 핵심 동작 하나만 기억하면 됩니다.
exports를 정의하는 순간, 거기에 명시하지 않은 패키지 내부 경로는 전부 외부에서 접근할 수 없게 된다.
main 필드만 있던 시절에는 사용자가 require('my-lib/lib/utils.js')처럼 패키지 내부 어디든 마음대로 접근할 수 있었습니다. exports는 이 관행을 끊고 “패키지가 공식적으로 공개하는 경로”를 계약으로 만드는 필드입니다. 강력한 기능이지만, 이 봉인 효과를 모르고 추가하면 아래 실수 1번이 바로 발생합니다.
실수 1. exports를 추가했더니 기존 import가 전부 깨짐
가장 흔하고, 가장 파급이 큰 실수입니다.
{
"name": "my-lib",
"main": "./dist/index.js",
"exports": {
".": "./dist/index.js"
}
}
이렇게 exports를 추가하는 순간, 기존 사용자의 이런 코드가 전부 깨집니다.
const utils = require('my-lib/dist/utils.js');
const pkg = require('my-lib/package.json');
실제로 재현해 보면 이런 에러가 나옵니다. dist/utils.js 파일이 패키지 안에 실제로 존재하는데도 발생하는 에러입니다.
Error [ERR_PACKAGE_PATH_NOT_EXPORTED]: Package subpath './dist/utils.js'
is not defined by "exports" in ...\node_modules\my-lib\package.json
my-lib/package.json을 읽던 코드까지 차단된다는 점에 주의해야 합니다. 번들러나 빌드 도구가 패키지 메타데이터를 읽는 경우가 있어서, 이것 때문에 생태계 전체에서 이슈가 자주 발생했습니다.
해결 방법: 외부에서 접근이 필요한 경로를 명시적으로 공개합니다.
{
"exports": {
".": "./dist/index.js",
"./utils": "./dist/utils.js",
"./package.json": "./package.json"
}
}
경로가 많다면 subpath pattern으로 한 번에 공개할 수 있습니다.
{
"exports": {
".": "./dist/index.js",
"./dist/*": "./dist/*"
}
}
다만 패턴으로 전부 여는 것은 봉인의 장점을 포기하는 것이므로, 신규 패키지라면 공개할 경로만 명시하는 편이 좋습니다. 이미 배포된 패키지에 exports를 추가하는 경우라면, 기존 사용자가 어떤 경로로 import하고 있었는지 확인한 뒤 그 경로들을 유지하는 것이 안전합니다. exports 추가는 사실상 breaking change이므로 semver 기준 major 버전을 올리는 것이 원칙입니다.
실수 2. target 경로가 ./로 시작하지 않음
패키지 내부 파일을 가리키는 export target에는 반드시 ./로 시작하는 상대 경로를 써야 합니다.
{
"exports": {
".": "dist/index.js"
}
}
main 필드는 dist/index.js처럼 ./ 없이 써도 관대하게 처리해 주기 때문에, 그 습관대로 exports를 작성하면 이 실수가 나옵니다. exports에서는 유효하지 않은 target으로 처리됩니다. 위 설정으로 require('my-lib')를 실행한 결과입니다.
Error [ERR_INVALID_PACKAGE_TARGET]: Invalid "exports" main target "dist/index.js"
defined in the package config ...\node_modules\my-lib\package.json;
targets must start with "./"
이 에러는 그나마 친절한 편입니다. 메시지 끝에 targets must start with "./"라고 고치는 방법까지 알려줍니다.
| 표기 | 결과 |
|---|---|
./dist/index.js | 정상 |
dist/index.js | 오류 — ./ 누락 |
/dist/index.js | 오류 — 절대 경로 불가 |
../other/index.js | 오류 — 패키지 밖으로 나갈 수 없음 |
경로 중간에 ..이나 node_modules가 들어가는 것도 유효하지 않습니다. ./dist/../index.js처럼 정규화하면 문제없어 보이는 경로도 Node.js는 거부합니다.
실수 3. subpath와 condition을 같은 레벨에 섞음
exports object의 key는 레벨마다 역할이 정해져 있습니다. .으로 시작하는 key는 subpath(공개 경로)이고, 그렇지 않은 key는 condition(조건)입니다. 이 둘을 같은 레벨에 섞으면 설정 전체가 유효하지 않게 됩니다.
{
"exports": {
".": "./index.js",
"import": "./index.mjs"
}
}
위 설정의 의도는 아마 “기본은 index.js, ESM import면 index.mjs”였을 것입니다. 하지만 이 상태로는 패키지를 아예 불러올 수 없습니다. 재현 결과, Node.js가 규칙을 통째로 설명해 주는 에러가 나옵니다.
Error [ERR_INVALID_PACKAGE_CONFIG]: Invalid package config
...\node_modules\my-lib\package.json. "exports" cannot contain some keys
starting with '.' and some not. The exports object must either be an object
of package subpath keys or an object of main entry condition name keys only.
올바르게 쓰면 이렇게 됩니다.
{
"exports": {
".": {
"import": "./index.mjs",
"default": "./index.js"
}
}
}
규칙은 단순합니다. 한 object 안의 key는 전부 subpath이거나 전부 condition이어야 합니다. subpath 밑에 condition object가 오는 중첩은 얼마든지 가능하지만, 같은 레벨에서의 혼합은 불가능합니다.
실수 4. default 조건을 앞에 둠
conditional exports에서 조건은 object에 선언된 순서대로 매칭되고, 첫 번째로 매칭된 조건이 선택됩니다. default는 모든 상황에 매칭되는 조건이므로, 앞에 두면 그 뒤의 조건들은 영원히 선택되지 않습니다.
{
"exports": {
".": {
"default": "./dist/default.js",
"node": "./dist/node.js"
}
}
}
위 설정에서 "node" 조건은 죽은 코드입니다. Node.js 환경에서도 default가 먼저 매칭되기 때문입니다. 이건 에러가 나지 않아서 더 위험한 실수인데, 직접 확인해 보면 명확합니다. default.js와 node.js가 각각 자기 이름을 반환하도록 만들어 두고 Node.js에서 불러왔습니다.
> console.log(require('my-lib'))
default.js was chosen
Node.js에서 실행했는데도 node 조건이 아니라 default가 선택됩니다. JSON은 key 순서에 의미를 두지 않는 것이 보통이라 이 동작을 예상하지 못하는 경우가 많습니다.
일반적으로 권장되는 순서는 다음과 같습니다.
types— TypeScript가 타입 선언을 찾는 조건. 다른 조건보다 먼저 와야 합니다.node,browser,import,require등 구체적인 조건default— 항상 마지막
실수 5. main만 고치고 exports를 잊음 (또는 그 반대)
main과 exports가 함께 있으면, exports를 지원하는 환경(Node.js 12.7+ 및 최신 번들러)에서는 exports가 우선합니다. main은 exports를 모르는 오래된 도구를 위한 fallback으로만 동작합니다.
문제는 빌드 구조를 바꿀 때입니다. 출력 파일 이름을 바꾸면서 main만 수정하면, 최신 환경에서는 여전히 exports에 적힌 옛 경로를 찾다가 파일이 없다는 에러가 납니다. 반대로 exports만 고치면 오래된 도구에서 깨집니다.
해결 방법: 두 필드가 같은 파일을 가리키는지 배포 전에 확인합니다. entry 관련 필드(main, module, types, exports)는 한 곳에서 관리한다고 생각하고, 하나를 수정할 때 나머지도 함께 검토하는 습관이 필요합니다.
실수 6. 디렉터리 export 문법을 계속 사용
과거에는 뒤에 /를 붙여 디렉터리 전체를 여는 문법이 있었습니다.
{
"exports": {
"./lib/": "./lib/"
}
}
이 문법은 deprecated를 거쳐 Node.js 17에서 제거되었습니다. 까다로운 점은 에러 메시지입니다. 재현해 보면 “이 문법은 제거되었다”는 안내가 아니라, 해당 매핑이 없는 것처럼 취급하는 일반적인 에러가 나옵니다.
Error [ERR_PACKAGE_PATH_NOT_EXPORTED]: Package subpath './lib/a.js'
is not defined by "exports" in ...\node_modules\my-lib\package.json
설정에는 "./lib/"가 분명히 적혀 있는데 “defined되지 않았다”고 하니, 원인을 문법 제거에서 찾기가 어렵습니다. 오래된 블로그 글이나 예전 프로젝트 설정을 복사했을 때 이 함정에 빠지기 쉽습니다. subpath pattern으로 바꿔야 합니다.
{
"exports": {
"./lib/*": "./lib/*"
}
}
*는 glob이 아니라 단순 문자열 치환입니다. my-lib/lib/a/b.js를 요청하면 *에 a/b.js가 그대로 들어갑니다. 특정 하위 경로만 막고 싶다면 null target을 함께 사용할 수 있습니다.
{
"exports": {
"./lib/*": "./lib/*",
"./lib/internal/*": null
}
}
ERR_PACKAGE_PATH_NOT_EXPORTED 에러 읽는 법
위 재현에서 봤듯 exports 관련 문제를 만나면 대부분 이 에러를 보게 됩니다.
Error [ERR_PACKAGE_PATH_NOT_EXPORTED]: Package subpath './utils' is not
defined by "exports" in ...\node_modules\my-lib\package.json
읽는 법은 다음과 같습니다.
- Package subpath ’./utils’ — 사용자가 접근하려던 경로입니다.
import 'my-lib/utils'를 시도했다는 뜻입니다. - is not defined by “exports” — 그 경로가
exports에 공개되어 있지 않다는 뜻입니다. 파일이 없다는 뜻이 아닙니다. 파일은 존재하지만 봉인되어 있는 경우가 대부분입니다. - in …/package.json — 어느 패키지의 설정이 문제인지 알려줍니다.
비슷한 에러로 ERR_INVALID_PACKAGE_TARGET(target 경로 자체가 유효하지 않음, 실수 2)과 ERR_INVALID_PACKAGE_CONFIG(exports 구조 자체가 무효, 실수 3)가 있습니다. 에러 이름만 봐도 실수의 종류를 좁힐 수 있습니다.
배포 전 확인 체크리스트
| 확인 항목 | 관련 실수 |
|---|---|
| 기존 사용자가 쓰던 deep import 경로가 exports에 포함되어 있는가 | 실수 1 |
./package.json 접근이 필요한 도구를 쓰는 사용자가 있는가 | 실수 1 |
모든 target이 ./로 시작하는가 | 실수 2 |
| 같은 object 레벨에 subpath와 condition이 섞여 있지 않은가 | 실수 3 |
default가 각 조건 object의 마지막에 있는가 | 실수 4 |
main·module·types와 exports가 같은 파일을 가리키는가 | 실수 5 |
"./dir/" 형태의 디렉터리 export가 남아 있지 않은가 | 실수 6 |
브라우저에서 바로 확인하기
이 체크리스트 중 구조와 경로에 관한 항목은 Stellar Brief의 package.json Exports Inspector로 바로 확인할 수 있습니다. package.json을 붙여넣으면 공개 entry와 조건별 연결 대상을 트리로 보여주고, ./ 누락·subpath와 condition 혼합·default 순서 문제 같은 구조 오류를 표시합니다. 입력한 내용은 브라우저에서만 분석되며 서버로 전송되지 않습니다.
다만 도구가 확인해 주는 것은 설정의 구조입니다. target 파일이 실제로 빌드 결과물에 존재하는지, 어떤 deep import를 공개 계약에 포함할지는 패키지를 만드는 사람이 판단해야 합니다.
정리
exports는 패키지의 공개 API를 경로 수준에서 계약으로 만드는 강력한 필드입니다. 그 대가로 main 시절의 관대함이 사라졌습니다.
이번에 6가지 실수를 전부 재현해 보면서 확인한 것은, 실수마다 실패하는 방식이 다르다는 점입니다. 실수 2와 3은 Node.js가 고치는 방법까지 에러에 적어주지만, 실수 1과 6은 “defined되지 않았다”는 말만 하고 파일이 실제로 존재한다는 사실은 알려주지 않으며, 실수 4는 아예 에러 없이 조용히 잘못된 파일을 선택합니다. 에러가 안 난다고 설정이 맞는 것이 아니라는 점이, exports를 다룰 때 가장 기억해 둘 부분입니다. 그리고 이미 배포된 패키지에 exports를 추가하는 것은 breaking change라는 점도 잊지 않아야 합니다.
이 글에 대하여
작성: Stella
Stellar Brief는 가능한 경우 공식 자료와 확인 가능한 출처를 우선 참고해 콘텐츠를 작성합니다.