package.json이 계약이다
exports, sideEffects, dependencies 세 필드가 소비자 번들을 어떻게 바꾸는지 측정하고, 부작용 코드가 제외되는 경우와 서로 다른 React 버전이 함께 설치되는 경우를 재현한다.
package.json이라고 하면 무엇이 떠오르는가? name, version, scripts, 그리고 의존성 목록이 먼저 떠오른다. 패키지의 기본 정보와 설치할 의존성을 기록하는 파일로 여기기 쉽다.
라이브러리를 배포해 본 사람이라면 한 번쯤 이런 이슈를 받아봤을 것이다. "함수 하나 썼는데 번들이 200KB 늘었어요." "lib/utils를 import하면 모듈을 못 찾는대요." 라이브러리 코드는 바뀌지 않았지만 소비자 애플리케이션에서만 발생하는 문제들이다.
보통 이런 문제는 소비자의 번들러 설정 때문이라고 생각한다. webpack의 동작을 의심하거나 Vite가 CJS를 제대로 처리하지 못한다고 판단하기도 한다.
그러나 원인은 라이브러리의 package.json에 있는 경우가 많다. 소비자의 번들러와 Node의 모듈 로더는 라이브러리 코드를 읽기 전에 이 파일을 먼저 읽는다. package.json의 필드는 진입 파일, 번들에서 제거할 수 있는 모듈, 소비자 애플리케이션과 동일한 인스턴스를 공유할 의존성을 지정한다.
package.json은 단순한 메타데이터 저장소가 아니라 소비자의 모듈 로더와 번들러 동작을 제어한다. 이 글에서는 exports, sideEffects, dependencies 값을 하나씩 바꾸며 소비자 번들이 어떻게 달라지는지 측정한다. 모든 수치는 예시 저장소 library-bundling-examples의 episodes/02-package-json/에서 재현할 수 있다.
exports 필드
이번 편에서 사용할 라이브러리의 package.json은 다음과 같다. 1편의 라이브러리에 서브패스 하나를 추가했다.
{
"name": "@ep2/lib-exports-ok",
"type": "module",
"exports": {
".": {
"import": { "types": "./dist/index.d.ts", "default": "./dist/index.js" },
"require": {
"types": "./dist/index.d.cts",
"default": "./dist/index.cjs"
}
},
"./utils": {
"import": { "types": "./dist/utils.d.ts", "default": "./dist/utils.js" },
"require": {
"types": "./dist/utils.d.cts",
"default": "./dist/utils.cjs"
}
},
"./package.json": "./package.json"
}
}exports는 소비자가 import 또는 require로 접근할 수 있는 패키지 서브패스와 조건별 대상 파일을 정의한다. 등록되지 않은 서브패스를 요청하면 Node의 모듈 로더가 ERR_PACKAGE_PATH_NOT_EXPORTED 오류를 던진다. Node의 모듈 로더가 경로와 조건을 어떻게 해석하는지 다섯 가지 경우로 확인했다.
$ node app/scripts/exports.mjs
1) subpath : …/lib-exports-ok/dist/utils.cjs
2) 미등록 경로 : ERR_PACKAGE_PATH_NOT_EXPORTED
3) require, ok : …/lib-exports-ok/dist/index.cjs
4) require, broken: …/lib-exports-broken/dist/index.js
5) import, ok : file:///…/lib-exports-ok/dist/index.js결과를 하나씩 확인해보자.
1) 서브패스. Node는 @ep2/lib-exports-ok/utils를 "./utils" 항목에 따라 dist/utils.cjs로 해석한다. 소비자는 실제 파일 위치를 알 필요가 없으며, 라이브러리 저자는 exports 매핑을 유지한 채 산출물 위치를 변경할 수 있다.
2) 캡슐화. dist/internal.js는 배포물에 포함되지만 exports에 등록되지 않았으므로 Node의 모듈 로더가 ERR_PACKAGE_PATH_NOT_EXPORTED 오류를 던진다. exports가 없으면 소비자가 lib/dist/internal/whatever.js 같은 내부 경로에 의존할 수 있고, 이후 내부 파일의 위치를 바꿀 때 호환성 문제가 발생한다. exports는 소비자가 접근할 수 있는 서브패스를 제한한다.
3)~5) 조건 분기. require()로 가져오면 require 조건이, import로 가져오면 import 조건이 선택된다. 문제는 4번 결과다.
lib-exports-broken은 lib-exports-ok와 소스 및 산출물이 같다. 차이는 exports 객체에 있는 키의 순서뿐이다.
{
"exports": {
".": {
"default": "./dist/index.js",
"require": "./dist/index.cjs",
"types": "./dist/index.d.ts"
}
}
}require()로 가져왔지만 dist/index.js, 즉 ESM 파일이 선택됐다. Node는 조건을 위에서부터 검사해 처음 일치하는 조건을 선택한다. default는 모든 요청에 일치하므로 맨 위에 있으면 이후의 require 조건은 선택되지 않는다. 이 예제가 오류 없이 실행되는 이유는 Node 24가 require(esm)을 지원하기 때문이다. CJS 산출물은 진입 파일로 사용되지 않는다.
같은 규칙이 타입에도 적용된다. TypeScript도 위에서부터 조건을 검사하므로 types는 각 분기 안에서 default보다 먼저 와야 한다. 위 예시에서는 import 분기와 require 분기가 각각 해당 모듈 형식의 타입 선언 파일을 지정한다. types 조건 하나를 exports 객체의 맨 위에 두면 require를 사용하는 소비자에게도 ESM용 index.d.ts가 선택되어 TypeScript가 해당 타입 선언을 ESM으로 해석한다. 3편에서는 도구를 사용해 이 설정 오류를 검출한다.
"./package.json": "./package.json" 항목도 추가한다. 번들러 플러그인과 관련 도구가 패키지 버전을 읽기 위해 이 경로를 자주 요청한다.
sideEffects
1편에서 ESM은 tree-shaking에 필요한 정적 구조를 제공하지만 코드 제거 결과까지 보장하지는 않는다고 설명했다. 코드 제거를 방해하는 조건을 확인해보자.
부작용(side effect)은 모듈 평가가 모듈 외부의 상태나 실행 환경을 변경하는 동작이다. 전역 변수 등록, 폴리필 설치, CSS 주입이 대표적인 예다. 이런 코드는 모듈에서 내보낸 값을 사용하지 않아도 모듈을 평가하는 것 자체가 목적이다.
// lib-side-effects/src/register.ts
type Registry = { plugins: string[] };
const g = globalThis as unknown as { __registry?: Registry };
g.__registry ??= { plugins: [] };
g.__registry.plugins.push("side-effects-lib");
export const registry: Registry = g.__registry;문제는 번들러가 모든 코드의 부작용 여부를 증명할 수 없다는 데 있다. 위 코드는 전역 상태를 변경하지만 아래 코드는 판단하기가 더 어렵다.
// lib-side-effects/src/b.ts
function buildTable(): number[] {
return Array.from({ length: 256 }, (_, i) => (i * 31) % 257);
}
const TABLE: number[] = buildTable();
export function b(x: number): number {
return TABLE[x & 255];
}buildTable()은 순수하지만 번들러는 함수 본문 전체를 분석해 호출의 순수성을 증명하지 않는다. 따라서 최상위 함수 호출에 부작용이 있을 가능성이 있다고 판단하고 해당 코드를 번들에 포함한다.
sideEffects 필드는 라이브러리 저자가 각 파일의 부작용 여부를 번들러에 알려주는 설정이다. false는 이 패키지의 모든 파일에 부작용이 없으므로, 내보낸 값이 사용되지 않는 파일을 번들에서 제외할 수 있다는 선언이다. 배열을 지정하면 나열한 파일에만 부작용이 있다고 선언한다.
라이브러리는 a, b, c, register 네 파일을 배럴 파일 index.ts에서 다시 내보내며, 소비자는 a만 가져온다. sideEffects 값을 네 가지로 바꾸어 소비자 번들의 크기와 포함된 코드를 측정했다.
// app/src/side-effects-*.ts
import { a } from "@ep2/lib-side-effects-…";
console.log(a(1));lib의 sideEffects | 앱 번들 | __registry 등록 코드 | b, c의 최상위 호출 |
|---|---|---|---|
| 미설정 | 646 B | 포함됨 | 포함됨 |
false | 160 B | 제외됨 | 제외됨 |
["./dist/register.js"] | 164 B | 제외됨 | 제외됨 |
["./dist/register.js", "./dist/index.js"] | 340 B | 포함됨 | 제외됨 |
네 설정의 결과를 그래프로 비교해보자.
lib 의 sideEffects 값별 소비자 번들 (import { a } 하나)
미설정일 때 번들 크기가 가장 크다. 번들러는 모든 파일에 부작용이 있을 수 있다고 가정해 b와 c의 buildTable(), compile() 호출까지 모두 포함한다. a만 가져왔지만 번들 크기는 646 B다.
false일 때 번들 크기는 가장 작지만 필요한 부작용 코드도 제외된다. 소비자가 import { a }만 사용하므로 번들러는 registry 내보내기가 사용되지 않는다고 판단해 register.js를 번들에 포함하지 않는다. 그 결과 전역 등록이 실행되지 않으며 플러그인 등록이나 CSS 주입이 누락될 수 있다. 파일 크기만 확인하면 이 문제를 발견하기 어렵다.
["./dist/register.js"]로 부작용 파일을 지정해도 등록 코드는 번들에 포함되지 않았다. 소비자는 index.js를 가져오지만 index.js는 부작용 파일로 표시되지 않았다. 번들러는 사용되지 않는 registry 재내보내기를 제거하고 register.js를 의존성 그래프에 포함하지 않는다. 따라서 register.js가 sideEffects 배열에 있어도 등록 코드는 번들에 포함되지 않는다.
["./dist/register.js", "./dist/index.js"]로 배럴 파일까지 목록에 추가하면 등록 코드가 번들에 포함된다. 반면 사용되지 않는 b와 c의 최상위 호출은 제외되어 번들 크기는 340 B가 된다. 등록 코드는 유지하고 사용하지 않는 호출은 제거한 상태다.
실전에서는 세 가지를 지키면 된다.
- 최상위에서는 선언만 하고 실행은 함수 내부에서 수행한다. 그러면
sideEffects: false를 안전하게 사용할 수 있다. - 부작용이 꼭 필요하면(CSS, 폴리필, 플러그인 등록) 부작용 모듈을 배럴에서 다시 내보내지 않고 별도 서브패스 진입점으로 제공한다. 소비자가
import "lib/register"처럼 직접 가져오면 번들러가 해당 모듈을 명시적인 의존성으로 처리한다. CSS에는"sideEffects": ["**/*.css"]설정을 주로 사용한다. - 최상위 호출이 순수하다고 확신하면
/* @__PURE__ */주석을 추가한다. 번들러는 이 주석이 붙은 호출의 반환값이 사용되지 않을 때 해당 호출을 제거한다. 1편의 CJS 산출물에서 tsdown이__commonJSMin(...)앞에 추가한 주석이 그 예다.
의존성은 어디에 두는가
package.json에는 의존성을 분류하는 필드가 세 개 있다.
| 필드 | 뜻 | 소비자가 설치하나 | 라이브러리 번들에 포함하나 |
|---|---|---|---|
dependencies | 런타임에 필요하다 | 그렇다, 자동으로 | 기본적으로 제외한다 (external) |
peerDependencies | 소비자 환경에 있어야 한다 | 소비자 책임 | 기본적으로 제외한다 (external) |
devDependencies | 빌드·테스트에만 쓴다 | 아니다 | 산출물에서 참조하지 않아야 한다 |
"라이브러리 번들에 포함하나" 열은 의존성 코드의 번들 포함 여부를 나타낸다. tsdown은 dependencies와 peerDependencies에 지정된 패키지를 기본적으로 external로 처리한다. 산출물에는 해당 패키지의 코드 대신 import 문만 유지된다.
작은 이벤트 이미터 @ep2/tiny-dep에 의존하는 라이브러리를 두 가지 설정으로 빌드했다. 하나는 기본 설정에 따라 tiny-dep을 외부 의존성으로 유지했고(external), 다른 하나는 tiny-dep 코드를 라이브러리 산출물에 포함했다(inline).
// lib-deps-inline/tsdown.config.ts
export default defineConfig({
entry: ["src/index.ts"],
format: "esm",
deps: { alwaysBundle: ["@ep2/tiny-dep"] },
});두 산출물의 첫 줄부터 다르다.
// lib-deps-external/dist/index.js (410 B)
import { Emitter } from "@ep2/tiny-dep";
// …
// lib-deps-inline/dist/index.js (934 B)
var Emitter = class {
// tiny-dep 전체가 여기 복사된다
};
// …라이브러리 파일만 비교하면 external 방식이 더 작다. 소비자 앱에서 발생하는 차이를 확인하기 위해 앱이 tiny-dep에 의존하는 라이브러리만 사용하는 경우와 앱도 tiny-dep을 직접 사용하는 경우를 나누어 측정했다.
| 소비자 앱 | inline 라이브러리 | external 라이브러리 |
|---|---|---|
| 앱은 라이브러리만 쓴다 | 1,007 B | 1,060 B |
| 앱도 tiny-dep 을 직접 쓴다 | 1,670 B | 1,154 B |
첫 번째 경우의 크기는 거의 같다. tiny-dep 코드가 라이브러리에 포함되거나 외부 의존성으로 유지되어도 앱 번들에는 한 번만 포함된다. 앱도 tiny-dep을 직접 사용하는 두 번째 경우에는 번들 크기가 달라진다. inline 라이브러리에 포함된 tiny-dep 코드와 앱이 직접 가져온 tiny-dep 코드가 소비자 번들에 각각 포함된다. 산출물에는 Emitter와 Emitter$1이 별도로 존재한다. external 방식에서는 소비자 번들러가 두 참조를 하나의 의존성으로 통합한다.
따라서 기본값은 external이다. 의존성을 인라인하면 소비자 번들러가 동일한 의존성을 하나로 통합할 수 없다. 또한 소비자는 라이브러리가 새 버전을 배포하기 전까지 인라인된 의존성의 보안 패치를 독립적으로 적용할 수 없다. 크기가 매우 작고 소비자가 버전을 독립적으로 관리하거나 중복을 제거할 필요가 없는 헬퍼는 예외로 인라인할 수 있다.
서로 다른 React 버전이 설치되는 이유
React 같은 프레임워크 의존성은 소비자 애플리케이션과 같은 인스턴스를 사용해야 한다. React 19를 사용하는 앱에 React를 사용하는 라이브러리 두 개를 설치했다. 하나는 React를 dependencies에, 다른 하나는 peerDependencies에 지정했다.
// lib-react-dep/package.json
{ "dependencies": { "react": "18.3.1" } }
// lib-react-peer/package.json
{ "peerDependencies": { "react": ">=18" } }$ node duplicate-react/app/run.mjs
app react : 19.2.4
lib (dependencies) : 18.3.1 same instance? false
lib (peerDependencies): 19.2.4 same instance? true두 설정에서 설치되는 React 인스턴스를 데모로 비교해보자.
앱(react 19)이 react 를 쓰는 라이브러리를 설치했을 때
왼쪽은 dependencies로 지정한 경우다. 패키지 매니저는 라이브러리가 요구한 React 18을 라이브러리 전용 의존성으로 설치한다. node_modules/.pnpm에 react@18.3.1과 react@19.2.4가 각각 설치되고, 라이브러리 컴포넌트는 앱의 렌더러가 사용하는 React와 다른 React 인스턴스의 훅을 호출한다.
오른쪽은 peerDependencies로 지정한 경우다. 라이브러리 전용 React가 설치되지 않고 앱에 설치된 React가 해석된다. same instance? true는 앱과 라이브러리가 동일한 React 인스턴스를 사용한다는 뜻이다. 따라서 훅과 컨텍스트가 같은 React 인스턴스를 기준으로 동작한다.
규칙은 단순하다. 소비자와 반드시 같은 인스턴스를 공유해야 하는 패키지는 peerDependencies에 둔다. React, Vue, 상태 관리 라이브러리, ORM처럼 전역 상태를 갖거나 instanceof로 인스턴스를 확인하는 패키지가 여기에 해당한다. 선택적 통합에만 필요한 패키지라면 peerDependenciesMeta에 optional: true를 함께 지정한다.
files 필드
files 필드는 npm 배포 tarball에 포함할 파일과 디렉터리 경로의 목록이다.
{ "files": ["dist"] }files 필드가 없으면 npm은 기본 제외 규칙과 .npmignore에 지정되지 않은 파일을 배포 tarball에 포함한다. .npmignore가 없으면 .gitignore를 제외 규칙으로 사용한다. 그 결과 소스, 테스트, 예시 파일이 의도와 다르게 포함될 수 있다. 배포 전에 npm pack --dry-run이 출력하는 파일 목록을 확인하자. 이 검증을 자동화하는 도구는 3편에서 다룬다.
정리
- exports는 소비자가 접근할 수 있는 서브패스의 전체 목록이다. Node는 처음 일치하는 조건을 선택하므로 default를 맨 아래에 두고, types는 모듈 형식별 분기 안에서 맨 앞에 둔다.
sideEffects: false는 사용하지 않는 코드뿐 아니라 필요한 부작용 코드도 번들에서 제외할 수 있다. 배럴의 재내보내기가 제거되면 부작용 모듈이 의존성 그래프에 포함되지 않을 수 있으므로 별도 서브패스 진입점으로 제공한다.- dependencies는 external 처리가 기본이다. 인라인하면 동일한 의존성 코드가 소비자 번들에 중복으로 포함될 수 있다(1,154 B → 1,670 B).
- 인스턴스를 공유해야 하는 패키지는 peerDependencies에 지정한다. React를 dependencies에 지정하면 서로 다른 React 인스턴스가 설치되어 훅 호출 오류가 발생할 수 있다.
- files 필드로 배포할 파일의 범위를 제한한다.
이 글의 결과를 재현하려면 예시 저장소 루트에서 다음 명령을 실행하면 된다. 편별 실행 방법과 확인 포인트는 episodes/02-package-json의 README에 정리했다.
pnpm install && pnpm -r --filter "@ep2/*" build
node scripts/size.mjs episodes/02-package-json/app/dist/*/*.js
grep -c __registry episodes/02-package-json/app/dist/side-effects-*/*.js
(cd episodes/02-package-json/app && node scripts/exports.mjs)
(cd episodes/02-package-json/duplicate-react/app && node run.mjs)다음 편에서는 타입 선언 파일을 생성하고, 배포 전에 모듈 형식과 package.json 설정의 일관성을 자동으로 검증하는 방법을 다룬다. tsdown이 처리하는 작업과 별도로 tsc를 실행해야 하는 작업도 구분한다.