핵심 표면
{
"name": "my-lib",
"exports": {
".": {
"import": "./dist/index.mjs",
"require": "./dist/index.cjs",
"types": "./dist/index.d.ts"
},
"./utils": {
"import": "./dist/utils.mjs",
"require": "./dist/utils.cjs"
}
},
"imports": {
"#internal/*": "./src/internal/*.js"
}
}공개 경계
먼저 눈에 들어와야 하는 기본형은 아래입니다.
- 루트 공개 엔트리:
"exports": { ".": "./dist/index.js" } - 하위 경로 공개:
"./utils": "./dist/utils.js" - 이중 배포:
"import"/"require" - 타입 경로 함께 노출:
"types" - 내부 전용 별칭:
"imports": { "#internal/*": ... }
exports는 경로 매핑이 아니라 공개 계약이다
이 필드를 넣는 순간 "이 패키지에서 외부가 접근해도 되는 경로"를 직접 선언하는 셈입니다. 즉, 내부 파일 접근을 막는 대신 기존 사용자 경로를 끊을 수도 있습니다.
루트만 열지 말고 실제 공개 경로를 먼저 목록화한다
기존 패키지에 exports를 도입할 때 제일 위험한 순간은 .만 열고 끝내는 경우입니다. 이미 사용자들이 my-lib/utils, my-lib/cli 같은 경로를 쓰고 있으면 바로 깨집니다.
이중 배포면 조건부 export로 명시한다
ESM/CJS를 둘 다 지원해야 한다면 "import"와 "require"를 분리하는 게 가장 분명합니다. "번들러가 알아서 해주겠지"보다 패키지 수준에서 분명히 말해두는 편이 낫습니다.
imports는 내부 별칭이다
외부 사용자 경로를 여는 게 아니라, 패키지 내부에서 #internal/... 같은 별칭을 쓰고 싶을 때 imports가 맞습니다.
즉 exports는 외부 계약, imports는 패키지 내부 구조 정리라는 차이로 읽는 편이 덜 헷갈립니다.
exports를 넣는 순간 package boundary가 더 단단해진다
기존에는 우연히 접근 가능했던 내부 파일 경로가 exports 도입 뒤에는 막힐 수 있습니다.
핵심은 문법 자체보다 "무엇을 공개 API로 볼 것인가"를 패키지 수준에서 확정하는 데 있습니다.
언제 exports를 쓸까
체크포인트
- 공개 루트 엔트리:
exports["."] - 하위 공개 경로:
exports["./utils"] - 이중 배포:
"import"/"require" - 타입 배포도 같이 맞춘다:
"types" - 내부 별칭:
imports - 기존 패키지 도입: 공개 경로 먼저 인벤토리
주의할 점
기존 패키지에 exports를 넣는 건 종종 breaking change다. 실제 사용 중인 경로를 먼저 모으지 않으면 정상 사용자 경로까지 같이 막아버린다.
참고 링크
1 sources