package.json의 진입점 설정을 main 필드 하나로 끝내는 경우가 많습니다. 빌드 결과물 경로만 적어두면 대부분의 도구가 문제없이 가져다 씁니다.
하지만 컴포넌트와 함께 CSS 파일, 지도 데이터(GeoJSON)까지 같이 배포해야 하는 패키지에서는 상황이 달라집니다. 소비하는 쪽에서 CSS를 불러오지 못하거나, 타입이 실제 구현과 어긋난 채로 잡히거나, 어떤 경로는 되는데 어떤 경로는 "그런 건 없다"며 거부당하는 문제가 흔히 생깁니다. 증상은 제각각이지만 원인은 하나입니다. 패키지의 어느 파일까지 공개할지를 설계하지 않은 것입니다.
이 글에서는 exports 필드로 진입점을 설계하는 방법을 정리합니다. 글에 나오는 동작은 작은 패키지를 만들어 Node와 TypeScript로 하나씩 재현해 확인한 결과입니다.
main만 있는 패키지의 문제
package.json의 main은 패키지의 기본 진입점 파일 경로를 지정합니다.
main만 있는 패키지를 하나 만들어 보겠습니다.
{
"name": "my-lib",
"version": "1.0.0",
"main": "./dist/index.cjs"
}이 패키지를 소비하는 쪽에서, 안내한 적 없는 내부 파일을 직접 가져와 봅니다.
// 의도한 사용법이다.
const m = require('my-lib');
console.log(m.flavor); // 'CommonJS 빌드'
// 안내한 적 없는 내부 구현 파일이다.
const internal = require('my-lib/src/internal.js');
console.log(internal.secret); // '내부 구현입니다' ← 그냥 통과된다main에 적지 않은 경로인데도 아무 제한 없이 가져올 수 있습니다. main은 기본 진입점이 무엇인지 알려줄 뿐, 나머지 파일에 대한 접근은 막지 않기 때문입니다. 패키지 안의 모든 파일이 사실상 공개 API가 됩니다.
소비하는 쪽이 my-lib/src/internal.js에 의존하기 시작하면 그 파일이 곧 계약이 됩니다. 내부 구조를 정리하려고 폴더 이름 하나만 바꿔도 그 파일에 의존하는 코드가 깨집니다. 리팩토링할 자유를 잃는 것입니다.
exports 선언과 접근 제한
exports 필드는 정반대의 규칙을 적용합니다. 필드에 적어둔 경로만 열립니다.
{
"name": "my-lib",
"version": "1.0.0",
"main": "./dist/index.cjs",
"exports": {
".": "./dist/index.cjs"
}
}"."은 패키지 이름 자체(my-lib)로 들어오는 기본 진입점을 뜻합니다. main을 지우지 않고 그대로 둔 것은 exports를 모르는 옛 도구를 위한 폴백이기 때문입니다. exports를 이해하는 쪽은 main을 쳐다보지도 않으므로, 둘을 같이 둬도 아래에서 볼 차단은 그대로 걸립니다.
앞과 똑같이 내부 파일을 가져와 보면 결과가 달라집니다.
require('my-lib/src/internal.js');
// Error [ERR_PACKAGE_PATH_NOT_EXPORTED]: Package subpath './src/internal.js'
// is not defined by "exports" in .../node_modules/my-lib/package.jsonexports를 선언하면 거기 적히지 않은 모든 경로는 차단됩니다. 파일이 실제로 존재하는지와 무관합니다. 디스크에 있어도 exports에 없으면 접근할 수 없습니다.
이 방식은 옵트인 방식의 캡슐화입니다. main이 "막지 않으면 다 열림"이었다면, exports는 "열지 않으면 다 막힘"입니다. 기본값이 반대로 뒤집혀서, 내부 구조를 마음 놓고 바꿀 수 있습니다.
이 차단은
package.json자신에게도 적용됩니다.require('my-lib/package.json')도 똑같이ERR_PACKAGE_PATH_NOT_EXPORTED로 막힙니다. 패키지 버전을 읽어가는 도구들이 종종 이 경로를 쓰기 때문에, 필요하다면"./package.json": "./package.json"을 명시적으로 열어줘야 합니다.
모노레포 글에서 "의존성 패키지의 파일이 노출되지 않을 때 main, types, exports 필드를 확인하라"고 설명한 적이 있는데, 그때 말한 상황이 정확히 이 차단입니다.
조건부 exports: 한 패키지, 여러 진입점
exports의 값에는 경로 문자열 대신 객체를 줄 수 있습니다. 이 객체를 쓰면 "누가 어떻게 가져가느냐"에 따라 다른 파일을 내줄 수 있습니다.
{
"name": "my-lib",
"version": "1.0.0",
"exports": {
".": {
"import": "./dist/index.mjs",
"require": "./dist/index.cjs"
}
}
}import로 가져가면 ESM 빌드를, require로 가져가면 CommonJS 빌드를 내줍니다. 실제로 확인해 보면 이렇습니다.
// app.mjs, ESM으로 가져간 경우
import lib from 'my-lib';
console.log(lib.flavor); // 'ESM 빌드'// app.cjs, CommonJS로 가져간 경우
console.log(require('my-lib').flavor); // 'CommonJS 빌드'한 패키지가 두 모듈 시스템을 동시에 지원하는, 이른바 듀얼 패키지가 이렇게 만들어집니다. Jest 설정 글에서 본 것처럼 package.json의 "type": "module" 설정 하나로 .js 파일을 해석하는 방식이 달라집니다. 소비자마다 이 설정과 모듈 형식이 다르기 때문에 이 분기가 필요합니다.
조건 매칭 순서
조건 이름이 객체의 키이기 때문에 순서는 상관없다고 생각하기 쉽지만, 실제로는 그렇지 않습니다. Node는 조건을 적힌 순서대로 훑다가 처음 들어맞는 조건 하나만 씁니다.
default는 "아무 조건에나 들어맞는" 만능 키입니다. 이걸 맨 위에 두면 어떻게 될까요?
{
"exports": {
".": {
"default": "./dist/index.cjs",
"import": "./dist/index.mjs"
}
}
}import lib from 'my-lib';
console.log(lib.flavor); // 'CommonJS 빌드' ← import인데 CJS가 나왔다import로 가져왔는데도 CommonJS 빌드가 나옵니다. 위에 있는 default가 먼저 들어맞아 아래 import는 읽히지 않습니다. default는 항상 맨 마지막에 두어야 하는 이유입니다.
이 규칙을 한 줄로 정리하면 "구체적인 조건일수록 위로, 포괄적인 조건일수록 아래로"입니다.
types 조건을 맨 위에 두는 이유
TypeScript용 types 조건에도 "먼저 들어맞는 하나"라는 규칙은 똑같이 적용됩니다. 다만 types의 자리는 방금 정리한 "구체적인 것부터"로는 설명되지 않습니다. import·require가 "어느 모듈 시스템이냐"를 가르는 조건이라면, types는 "묻는 쪽이 타입 검사기냐"를 가르는 다른 기준이기 때문입니다.
types를 맨 위에 두는 이유는 따로 있습니다. 런타임은 types를 조건 목록에 아예 넣지 않으므로 위로 올려도 잃을 것이 없습니다. 반대로 TypeScript는 import나 require가 먼저 들어맞으면 그 파일 옆에 있는 선언 파일을 사용합니다. 그래서 types가 아래 있으면 types에 적은 선언 파일이 쓰이지 않을 수 있습니다. 이 문제는 겉으로 잘 드러나지 않습니다.
types를 아래에 두고, import가 가리키는 index.mjs 옆에 낡은 선언 파일(index.d.mts)이 남아 있는 상황을 만들어 확인했습니다. 소비하는 쪽은 ESM 모드입니다. CJS 모드에서는 import 조건이 매칭되지 않아 이 문제가 재현되지 않습니다.
{
"exports": {
".": {
"import": "./dist/index.mjs",
"types": "./dist/index.d.ts"
}
}
}import { flavor } from 'my-lib';
const s: string = flavor;
// error TS2322: Type 'number' is not assignable to type 'string'.types에 적어둔 index.d.ts는 flavor를 string으로 선언하고 있는데, 실제로는 위에서 먼저 매칭된 import 조건 옆의 낡은 index.d.mts(number 선언)가 쓰였습니다. 조건의 순서가 어느 선언 파일이 적용되는지를 결정합니다. types를 맨 위로 올리자 의도한 선언이 적용되고 에러도 사라졌습니다.
{
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs",
"require": "./dist/index.cjs"
}
}
}다만 "
types를 위에 두지 않으면 무조건 타입을 못 찾는다"는 설명은 정확하지 않습니다. 실제로 확인해 보니 TypeScript는 먼저 매칭된 조건에서 선언 파일을 찾지 못하면 다음 조건으로 넘어가types를 찾아냅니다. 위 문제는 "엉뚱한 선언 파일을 먼저 찾아서" 생긴 것이지, "타입을 못 찾아서" 생긴 것이 아닙니다. 어느 쪽이든 결론은 같습니다. 폴백에 기대지 말고types를 맨 위에 두는 편이 안전합니다.
서브패스로 만드는 CSS·데이터 진입점
패키지가 내보내는 것이 JavaScript뿐이라면 "." 하나로 충분합니다. 하지만 스타일시트나 GeoJSON 데이터처럼 JS가 아닌 파일도 함께 배포해야 하는 경우가 있습니다. 이런 파일은 **서브패스(subpath)**로 별도의 진입점을 만들어 줍니다.
{
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs",
"require": "./dist/index.cjs"
},
"./styles.css": "./dist/styles.css"
}
}키가 소비자가 쓰는 경로, 값이 실제 파일 위치입니다. 이제 my-lib/styles.css로 스타일을 가져올 수 있습니다.
// 소비하는 쪽에서 쓰는 경로
import 'my-lib/styles.css';바깥 경로와 내부 경로를 분리할 수 있다는 점이 중요합니다. 소비자는 my-lib/styles.css라는 경로만 알면 되고, 내부에서 dist/를 build/로 바꾸더라도 exports의 값만 고치면 소비자 코드는 그대로입니다.
패턴 서브패스의 확장자 처리
데이터 파일이 수십 개라면 하나씩 다 적을 수는 없습니다. 이럴 때 *를 쓰는 패턴 서브패스를 씁니다.
{
"exports": {
"./geojson/*": "./dist/geojson/*.json"
}
}* 자리에 들어온 문자열이 그대로 값 쪽 *에 대입됩니다. 그래서 이렇게 동작합니다.
// 'korea'가 *에 대입되어 ./dist/geojson/korea.json으로 해석된다.
// (JSON을 가져올 때 붙이는 with 구문은 Node 22 기준입니다.)
import data from 'my-lib/geojson/korea' with { type: 'json' };
console.log(data.type); // 'FeatureCollection'값 쪽에 이미 .json이 붙어 있는데, 소비하는 쪽에서 습관대로 확장자를 붙여 적으면 문제가 생깁니다.
require.resolve('my-lib/geojson/korea.json');
// Error: Cannot find module '.../dist/geojson/korea.json.json'*에 korea.json이 그대로 대입되면서 korea.json.json이라는 경로가 만들어집니다. 에러 메시지의 .json.json이 원인을 보여줍니다.
이 현상은 설계 선택의 문제입니다. 문법 오류는 아닙니다. 두 방식 중 하나로 일관되게 정하면 됩니다.
| 매핑 | 소비자가 쓰는 경로 | 반대로 썼을 때 |
|---|---|---|
"./geojson/*": "./dist/geojson/*.json" | my-lib/geojson/korea | korea.json으로 쓰면 .../korea.json.json을 찾다 실패 |
"./geojson/*": "./dist/geojson/*" | my-lib/geojson/korea.json | korea로 쓰면 .../korea를 찾다 실패 |
표에서 보듯 두 방식의 안전성은 같습니다. 둘 다 소비자가 반대 습관으로 쓰면 똑같이 깨지고, 차이는 "어느 습관과 맞는지"뿐입니다.
이 글에서는 두 번째 방식을 권합니다. 소비자가 쓰는 경로와 실제 파일명이 같아서, dist/geojson/korea.json을 보면 my-lib/geojson/korea.json이라는 것이 바로 보이기 때문입니다. 확장자를 감추는 첫 번째 방식은 경로가 짧지만, 소비자가 파일명에서 경로를 유추할 수 없어 문서에 의존하게 됩니다.
자동완성은 어느 쪽을 택하든 도움이 됩니다. 직접 확인한 결과, TypeScript는 exports 패턴을 거꾸로 읽어 첫 번째 매핑에서는 korea를, 두 번째에서는 korea.json을 제안합니다. 둘 다 실제로 동작하는 경로이고, 자동완성이 깨진 경로를 만들어 주지는 않으므로 이는 선택의 근거가 되지 않습니다.
정리
이 글에서는 exports 필드로 패키지의 진입점을 설계하는 방법을 정리했습니다.
| 개념 | 한 줄 요약 |
|---|---|
main | 기본 진입점만 안내한다. 나머지 파일은 막지 않는다 |
exports | 적어둔 경로만 열리고, 나머지는 전부 차단된다 |
| 조건부 진입점 | import·require·types로 소비 방식에 따라 다른 파일을 내준다 |
| 조건 순서 | 위에서부터 처음 들어맞는 하나만 쓰인다. default는 맨 아래 |
| 서브패스 | 바깥에 보여줄 경로와 내부 실제 경로를 분리해 매핑한다 |
| 패턴 서브패스 | *에 대입되는 방식이라, 확장자를 어디에 둘지 일관되게 정해야 한다 |
exports는 진입점 설정이면서 동시에 캡슐화 도구입니다. 내부 파일을 잠가야 내부 구조를 바꿀 자유가 생깁니다.- 조건은 적힌 순서대로 훑어 처음 들어맞는 하나만 쓰입니다. 이름이 맞다고 해서 반드시 쓰이는 것은 아닙니다.
types는 맨 위,default는 맨 아래가 안전합니다. - CSS나 데이터처럼 JS가 아닌 파일도 서브패스로 정식 진입점을 만들어 줄 수 있습니다.
- 패턴 서브패스는
*에 문자열이 그대로 대입되는 방식이라, 확장자를 값 쪽에 두든 소비자 쪽에 두든 반대로 쓰면 깨집니다. 한쪽으로 정하고 문서에 박아두는 수밖에 없습니다.
마치며
exports는 진입점 설정이자 캡슐화 도구입니다. 공개할 파일을 정하는 일이 패키지의 API를 설계하는 일과 같습니다.
"types를 맨 위에 둬라" 같은 규칙은 이유를 모른 채 외워 쓰기 쉽습니다. 이 글에서 재현해 보니, 흔히 알려진 이유(타입을 못 찾는다)와 실제 이유(엉뚱한 선언 파일을 먼저 찾는다)가 달랐습니다. 결론이 같더라도 이유를 직접 확인해 두면 비슷한 문제를 만났을 때 원인을 더 빨리 찾을 수 있습니다. 긴 글 읽어주셔서 감사합니다.