pnpm Workspace 내부 패키지 exports 설정 — CJS/ESM 듀얼 패키지와 publint 실전 가이드
모노레포를 구축하다 보면 어느 순간 이런 상황을 맞닥뜨리게 됩니다. 공들여 만든 내부 패키지를 앱에서 import했더니 타입이 전혀 잡히지 않거나, Jest에서만 모듈을 못 찾겠다는 에러가 나거나, 심지어 싱글턴이 두 개로 분열되는 황당한 현상이 생기는 것이죠. 저도 처음 pnpm Workspace 환경에서 공유 유틸리티 패키지를 만들 때 package.json의 exports 필드를 제대로 이해하지 못해서 며칠을 허비한 기억이 있습니다.
이 글에서는 그 삽질의 핵심 원인, 즉 CJS와 ESM이 한 패키지 안에 공존할 때 발생하는 듀얼 패키지 문제를 exports 조건부 필드와 publint로 체계적으로 진단하고 해결하는 방법을 정리합니다. 이미 pnpm 모노레포를 운영 중이거나 막 시작하려는 분이라면 실무에서 바로 쓸 수 있는 내용이 될 것입니다.
CJS(CommonJS)는 Node.js가 처음부터 써온 모듈 시스템으로 require()와 module.exports를 씁니다. ESM(ES Modules)은 JavaScript 공식 표준(ES2015)으로 import/export 문법을 씁니다. Node.js 12+부터 ESM을 공식 지원하면서 두 시스템이 한 생태계 안에 공존하게 됐고, 이 과도기가 바로 오늘 다룰 문제의 배경입니다.
핵심 개념
exports 필드 — main/module의 진화형
오래된 패키지를 보면 main과 module 필드가 함께 있는 경우가 많습니다. main은 CJS 진입점, module은 번들러가 ESM으로 읽을 때 쓰는 비공식 필드였습니다. 문제는 이 두 필드가 Node.js 공식 스펙이 아니었고, 도구마다 해석 방식이 달랐다는 점입니다.
Node.js 12+부터 이를 대체하는 exports 필드가 공식 지원됩니다. 환경 조건에 따라 서로 다른 파일을 내보낼 수 있는 조건부 exports(Conditional Exports) 를 지원하며, 2025년 현재 사실상 표준이 됐습니다.
{
"name": "@myorg/utils",
"exports": {
".": {
"import": {
"types": "./dist/index.d.mts",
"default": "./dist/index.mjs"
},
"require": {
"types": "./dist/index.d.cts",
"default": "./dist/index.cjs"
}
}
}
}exports를 선언하는 순간 main/module 필드보다 우선 적용됩니다. 명시적으로 열어두지 않은 내부 경로(예: ./src/internals)는 외부에서 접근이 차단되어 패키지 API 경계가 명확해지는 효과도 생깁니다.
조건 키 정리
import: ESMimport문으로 불러올 때require: CJSrequire()로 불러올 때node: Node.js 런타임 환경에서 실행될 때browser: 브라우저 환경에서 실행될 때 (SSR, 범용 라이브러리에서 자주 사용)types: TypeScript 타입 해석 시development/production: 커스텀 조건 (번들러·tsconfig에서 별도 활성화 필요)default: 위 조건 중 어느 것도 해당하지 않을 때의 폴백
조건 키는 선언된 순서대로 위에서 아래로 매칭됩니다. default가 먼저 나오면 나머지 조건이 무시되므로, default는 반드시 마지막에 위치해야 합니다.
CJS/ESM 듀얼 패키지 위험(Dual Package Hazard)
솔직히 이 문제는 처음 맞닥뜨리면 원인 파악이 정말 어렵습니다. 증상만 보면 "왜 이 값이 초기화가 안 돼 있지?"처럼 완전히 다른 버그처럼 보이기 때문입니다.
원인은 이렇습니다. CJS와 ESM 진입점을 모두 갖춘 패키지를 한 앱 안에서 양쪽 방식으로 불러오면, Node.js의 CJS 로더와 ESM 로더가 각각 별도의 모듈 인스턴스를 생성합니다. 같은 패키지인데 메모리에 두 개가 존재하게 되는 것이죠.
앱
├── CJS로 require('@myorg/utils') → 인스턴스 A 생성
└── ESM으로 import '@myorg/utils' → 인스턴스 B 생성 (A와 다름!)캐시, 싱글턴, 이벤트 이미터처럼 모듈 수준 상태를 가지는 코드에서 가장 큰 문제가 됩니다. 인스턴스 A에서 등록한 상태가 인스턴스 B에는 없으니까요.
핵심 인사이트: 상태를 가지는 모듈(싱글턴, 글로벌 캐시, 이벤트 버스)은 듀얼 패키지 대신 ESM 전용으로 전환하는 것을 권장합니다. 상태가 없는 순수 유틸리티 함수들은 듀얼 패키지로 가도 비교적 안전합니다.
pnpm Workspace와 workspace:* 프로토콜
pnpm Workspace는 pnpm-workspace.yaml로 관리 범위를 선언하고, 내부 패키지끼리는 workspace:* 프로토콜로 참조합니다.
# pnpm-workspace.yaml
packages:
- 'apps/*'
- 'packages/*'// apps/web/package.json
{
"dependencies": {
"@myorg/shared-utils": "workspace:*"
}
}이 구조에서 내부 패키지의 exports 설정이 잘못되어 있으면, 로컬 개발 중에도 타입 해석 실패나 모듈 로드 오류가 발생합니다.
그렇다면 이 개념들을 실제 코드에서 어떻게 조합하는지, 빌드부터 검증까지 순서대로 살펴보겠습니다.
실전 적용
Step 1: tsup으로 CJS/ESM 동시 빌드하기
저는 처음에 Rollup과 tsc --declaration 조합을 써보다가 설정이 너무 복잡해져서 결국 tsup으로 돌아왔습니다. esbuild 기반이라 빌드가 매우 빠르고, 듀얼 패키지에 필요한 설정이 단 몇 줄로 끝나기 때문입니다.
// tsup.config.ts
import { defineConfig } from 'tsup';
export default defineConfig({
entry: ['src/index.ts'],
format: ['cjs', 'esm'],
dts: true,
clean: true,
splitting: false,
sourcemap: true,
outExtension: ({ format }) => ({
js: format === 'cjs' ? '.cjs' : '.mjs',
}),
});splitting: false는 CJS 빌드 시 코드 스플리팅이 동적 require() 호출로 변환되어 예상치 못한 문제를 일으킬 수 있기 때문에 끄는 편이 안전합니다. outExtension 설정도 빠뜨리기 쉬운데, 이게 없으면 CJS 결과물이 .cjs 대신 .js로 출력됩니다. 나중에 package.json의 exports에서 .cjs 파일을 참조할 때 파일을 찾지 못하는 오류가 나기 때문에 명시적으로 지정해 두는 것이 좋습니다.
빌드 결과물로는 아래 파일들이 생성됩니다.
| 파일 | 용도 |
|---|---|
dist/index.cjs |
CJS 번들 |
dist/index.mjs |
ESM 번들 |
dist/index.d.cts |
CJS용 타입 선언 |
dist/index.d.mts |
ESM용 타입 선언 |
왜
.d.mts가 필요한가? TypeScript는moduleResolution: node16또는bundler모드에서.mjs파일의 타입을.d.mts파일에서 찾습니다..d.ts하나로 공유하면 타입 해석 맥락이 달라져 오류가 생길 수 있어, CJS와 ESM 각각에 맞는 선언 파일을 분리하는 것이 2025년 기준 권장 방식입니다.
Step 2: publishConfig 패턴으로 개발·배포 환경 분리하기
개발 중에는 매번 빌드하지 않고 TypeScript 소스를 바로 참조하고, pnpm publish 시에는 빌드 결과물로 자동 전환되는 패턴입니다. 실무에서 정말 자주 쓰이는 방식인데, 처음 접하면 설정이 좀 길어 보일 수 있습니다.
{
"name": "@myorg/shared-utils",
"version": "1.0.0",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": {
"development": "./src/index.ts",
"import": "./dist/index.mjs",
"require": "./dist/index.cjs",
"types": "./dist/index.d.ts"
}
},
"publishConfig": {
"main": "./dist/index.cjs",
"types": "./dist/index.d.ts",
"exports": {
".": {
"import": {
"types": "./dist/index.d.mts",
"default": "./dist/index.mjs"
},
"require": {
"types": "./dist/index.d.cts",
"default": "./dist/index.cjs"
}
}
}
}
}| 필드 | 개발 환경 | 배포 후 |
|---|---|---|
main |
./src/index.ts |
./dist/index.cjs |
types |
./src/index.ts |
./dist/index.d.ts |
exports["."] (ESM import, 개발) |
./src/index.ts (development 조건 우선 매칭) |
./dist/index.mjs |
exports["."] (CJS require, 개발) |
./src/index.ts (development 조건 우선 매칭) |
./dist/index.cjs |
exports["."]:development |
./src/index.ts (소스 직접 참조) |
(제거됨) |
여기서 핵심 포인트가 있습니다. development 조건이 import보다 먼저 선언되어 있기 때문에, 개발 환경에서는 ESM import든 CJS require()든 모두 development 조건이 먼저 매칭됩니다. 개발 중에는 항상 ./src/index.ts를 직접 참조하게 되고, import/require 조건은 배포 이후에만 의미가 생깁니다.
development 조건이 활성화되려면 소비하는 쪽의 tsconfig.json에 다음 설정이 필요합니다.
{
"compilerOptions": {
"moduleResolution": "bundler",
"customConditions": ["development"]
}
}moduleResolution: bundler를 권장하는 이유도 있습니다. node16이나 nodeNext 모드는 import 경로에 반드시 .js, .mjs 같은 확장자를 명시해야 합니다. 모노레포처럼 내부 패키지를 자주 참조하는 환경에서는 이게 꽤 번거롭습니다. bundler 모드는 확장자 없이도 해석이 가능하고, Vite·webpack 같은 번들러가 실제로 동작하는 방식과 잘 맞습니다.
단, development 조건의 도구별 지원 방식이 다르다는 점은 미리 알아두면 좋습니다. Vite는 tsconfig.json의 customConditions를 별도 설정 없이 인식하지만, webpack은 resolve.conditionNames에 명시적으로 "development"를 추가해야 합니다. 내 환경에서 development 조건이 작동하지 않는다면 도구별 설정을 먼저 확인해 보시는 것이 좋습니다.
Step 3: publint로 exports 설정 검증하기
설정을 다 했다고 생각했는데 실제 배포해보니 오류가 나는 경우가 있습니다. publint는 npm이 배포 후 실제로 어떻게 해석하는지를 배포 전에 시뮬레이션해서 문제를 잡아줍니다. 저도 처음엔 "이 정도 설정이면 됐겠지" 하고 넘어갔다가 publint를 돌려보고 나서야 숨어 있던 오류들을 발견했습니다.
# 현재 패키지 검증
npx publint
# 특정 node_modules 패키지 검증
npx publint ./node_modules/some-lib
# 의존성 전체 일괄 검증
npx publint deps실무에서 가장 유용한 연동 방법은 prepublishOnly에 넣어두는 것입니다. 빌드 후 자동으로 검증이 실행되니 잘못된 설정으로 배포되는 사고를 방지할 수 있습니다.
{
"scripts": {
"build": "tsup",
"prepublishOnly": "pnpm build && npx publint"
}
}publint가 잡아주는 대표적인 오류들은 다음과 같습니다.
| 오류 유형 | 설명 |
|---|---|
FILE_DOES_NOT_EXIST |
exports에 선언된 파일이 실제로 없음 |
FILE_FORMAT_INVALID |
.mjs 파일인데 CJS 형식으로 작성됨 (또는 반대) |
EXPORTS_TYPES_INVALID |
import 또는 require 블록 안에서 types 서브조건이 default보다 뒤에 위치함 |
EXPORTS_DEFAULT_SHOULD_BE_LAST |
default 조건이 마지막에 위치하지 않음 |
EXPORTS_TYPES_INVALID는 처음 보면 헷갈리기 쉬운 오류입니다. 최상위 exports 순서 문제가 아니라, import나 require 블록 안에서 types가 default보다 앞에 있어야 한다는 의미입니다. 예를 들어 import 블록을 { "default": "...", "types": "..." } 순서로 쓰면 이 오류가 납니다. 올바른 순서는 { "types": "...", "default": "..." }입니다.
Step 4: are-the-types-wrong으로 타입 선언 검증하기
publint가 파일 구조와 형식을 검증한다면, are-the-types-wrong(attw)은 TypeScript 타입 선언이 런타임 모듈 해석과 실제로 일치하는지를 검증합니다. 두 도구를 함께 쓰면 배포 품질을 훨씬 높일 수 있습니다.
npx @arethetypeswrong/cli --pack .CI에서 두 도구를 함께 연동하면 좋습니다.
{
"scripts": {
"check:exports": "npx publint && npx @arethetypeswrong/cli --pack ."
}
}장단점 분석
장점
| 항목 | 내용 |
|---|---|
| 범용 호환성 | Jest나 오래된 Node.js 앱(CJS)과 Vite, 최신 Node.js(ESM) 환경을 모두 지원할 수 있습니다 |
| 트리 쉐이킹 | ESM 진입점을 통해 번들러가 사용하지 않는 코드를 제거할 수 있습니다 |
| 명확한 API 경계 | exports에 명시된 경로만 외부 접근 허용 — 내부 구현을 실수로 노출하는 일이 줄어듭니다 |
| 개발 편의성 | publishConfig + development 조건으로 빌드 없이 소스를 직접 참조하는 빠른 개발 사이클이 가능합니다 |
| 검증 자동화 | publint, attw를 CI에 연동하면 잘못된 설정이 배포되는 사고를 방지할 수 있습니다 |
단점 및 주의사항
제가 실제로 겪었던 케이스를 하나 꼽자면, 기존에 @myorg/utils/internals 같은 하위 경로를 앱 곳곳에서 직접 import해서 쓰던 코드가 여럿 있었습니다. exports를 추가하는 순간 명시되지 않은 그 경로들이 전부 차단되어 빌드가 한꺼번에 터졌습니다. 기존 패키지에 exports를 도입할 때는 반드시 외부에서 참조 중인 경로를 먼저 파악해 두는 것이 좋습니다.
| 항목 | 내용 | 대응 방안 |
|---|---|---|
| Dual Package Hazard | CJS·ESM 양쪽에서 동일 패키지가 각각 인스턴스화되어 싱글턴 상태가 분리됨 | 상태를 가지는 모듈은 ESM 전용으로 전환 고려 |
| 타입 파일 이중화 | .d.cts와 .d.mts를 각각 유지해야 함 |
tsup의 dts: true 옵션으로 자동 생성 |
| 설정 복잡도 | exports, publishConfig, tsconfig, 빌드 도구 설정이 맞물려 디버깅이 어려움 |
publint + attw로 단계별 검증 |
| 도구별 해석 차이 | webpack, Rollup, Node.js, TypeScript가 exports 조건을 각각 다르게 해석하는 경우 존재 |
moduleResolution: bundler 통일 권장 |
| 하위 경로 차단 | exports 선언 후 명시하지 않은 내부 경로는 외부 접근 불가 — 기존 코드에 추가 시 파괴적 변경 가능성 |
마이그레이션 전 외부에서 참조하는 경로 목록 확인 필요 |
Dual Package Hazard — 동일한 패키지가 CJS 로더와 ESM 로더에 의해 각각 별도로 초기화되어 두 개의 독립된 모듈 인스턴스가 메모리에 공존하는 현상입니다. 싱글턴, 전역 상태, 이벤트 이미터 등에서 예측 불가능한 버그로 이어질 수 있습니다.
실무에서 가장 흔한 실수
types조건을import/require블록 안이 아닌 최상위에 선언:exports최상위에"types": "./dist/index.d.ts"를 쓰면 TypeScript가 CJS/ESM 구분 없이 동일 타입을 사용하게 됩니다.import와require각각의 블록 안에types를 지정하는 것이 권장됩니다.dist/폴더를.gitignore에 추가했는데exports에선dist/를 가리킴: 처음 클론하거나 CI 환경에서 빌드 전에 참조하면 파일이 없어서 실패합니다.prepare또는prepublishOnly스크립트로 빌드 단계를 보장하는 것이 좋습니다.default조건을import보다 먼저 선언: 조건부 exports는 선언 순서대로 매칭됩니다.default가 먼저 오면import/require조건이 무시됩니다.default는 반드시 마지막에 위치해야 합니다.
마치며
exports 필드를 올바르게 구성하고 publint로 검증하는 것이 2025년 pnpm Workspace 환경에서 내부 패키지 품질을 보장하는 가장 확실한 방법입니다.
tsup이 빌드 결과물을 자동으로 맞춰주고 publint가 잘못된 점을 짚어주니, 한 번 구조를 잡으면 publint가 대부분의 회귀를 잡아줍니다. 다만 exports 경로가 늘어날수록 관리 비용도 선형으로 증가한다는 점은 미리 알아두면 좋습니다. 지금 운영 중인 모노레포가 있다면 아래 순서로 바로 점검해 보실 수 있습니다.
- 현재 내부 패키지에서
npx publint를 실행해 보세요.exports설정의 문제점을 바로 확인할 수 있습니다. 기존 패키지라면 예상보다 많은 경고가 나올 수 있습니다. tsup.config.ts에format: ['cjs', 'esm'], dts: true, outExtension을 추가해 빌드해 보세요..mjs,.cjs,.d.mts,.d.cts파일이 한 번에 생성됩니다. 이후package.json의exports필드를 위 예시처럼 맞춰 보시면 됩니다."prepublishOnly": "pnpm build && npx publint"를package.jsonscripts에 추가해 두세요. 이후 배포 시마다 자동으로 검증이 실행되어 잘못된 설정이 npm에 올라가는 상황을 방지할 수 있습니다.
다음 글: pnpm Workspace + Changesets로 모노레포 내 패키지 버전 관리와 changelog 자동화 실전 가이드
참고 자료
- pnpm 공식 문서 - Workspaces
- pnpm 공식 문서 - package.json
- publint 공식 사이트
- publint 규칙 문서
- publint 시작하기
- How to solve package validation pain with Publint | LogRocket
- Ship ESM & CJS in one Package | Anthony Fu
- Publishing dual ESM+CJS packages | Mayank
- Dual Publishing ESM and CJS Modules with tsup and Are the Types Wrong? | John Reilly
- Guide to the package.json exports field | Hiroki Osame
- TypeScript in 2025 with ESM and CJS npm publishing is still a mess | Liran Tal
- Tutorial: publishing ESM-based npm packages with TypeScript | 2ality
- Live types in a TypeScript monorepo | Colin McDonnell
- Dual Packages in Node.js with Conditional Exports | Alexander O'Mara
- Node.js 공식 문서 - Publishing a package
- TypeScript 공식 문서 - ESM/CJS Interoperability
- Complete Monorepo Guide: pnpm + Workspace + Changesets (2025)