배포 전 출국 심사 — Publint로 npm 패키지 실수를 원천 차단하는 법
여권에 이름을 틀리게 적고 공항에 갔다고 상상해보자. 체크인까지는 문제없지만, 출국 심사에서 걸린다. npm 패키지 배포도 비슷하다. package.json의 exports 필드 순서가 잘못되었거나, main과 module이 엇갈리거나, types 조건이 뒤에 빠져 TypeScript가 타입을 못 찾거나. 문제는 이런 실수가 로컬에서는 절대 드러나지 않는다는 것이다. 빌드도 되고 테스트도 통과하는데, npm publish 후에 사용자가 "import가 안 돼요"라고 이슈를 올린다. Publint는 출국 심사대처럼 배포 직전에 이런 실수를 잡아주는 도구다.
Publint — 패키지 메타데이터 린터
Publint은 package.json을 기반으로 선언된 엔트리 포인트와 실제 배포 파일이 일치하는지 검증하는 린터다. main, module, exports, types 필드를 순회하면서 Vite, Webpack, Rollup, Node.js 등 다양한 환경에서의 호환성을 확인한다.
npx publint@latest이 한 줄이면 현재 프로젝트의 패키징 문제를 즉시 확인할 수 있다. 다른 디렉토리의 패키지를 검사하고 싶으면 경로를 인자로 전달한다.
npx publint ./packages/my-lib[💡 잠깐! 이 용어는?]
exports 필드: package.json에서 패키지의 진입점(entry points)을 정의하는 최신 방식이다. 조건부 내보내기(import, require, types)를 지원해서 ESM/CJS/TypeScript 환경별로 다른 파일을 제공할 수 있다.
Publint이 잡아내는 네 가지 함정
1. types 조건의 순서 함정
exports 필드의 조건은 순서가 의미를 가진다. TypeScript가 타입을 올바르게 찾으려면 types가 반드시 첫 번째여야 한다. 뷔페에서 디저트를 먼저 집으면 메인 요리를 놓치는 것처럼, 잘못된 순서는 TypeScript가 타입 정보를 놓치게 만든다.
{
"exports": {
".": {
"import": "./dist/index.mjs",
"types": "./dist/index.d.ts"
}
}
}{
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs"
}
}
}Publint은 types 조건이 첫 번째가 아니면 경고를 표시한다.
2. module 필드만 있고 exports가 없는 경우
module 필드는 번들러 사이의 비공식 규약이다. Node.js는 이 필드를 인식하지 않는다. Publint은 module은 있는데 exports가 없으면, exports 사용을 권장한다.
{
"main": "./dist/index.cjs",
"module": "./dist/index.mjs"
}{
"main": "./dist/index.cjs",
"module": "./dist/index.mjs",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs",
"require": "./dist/index.cjs"
}
}
}3. 엔트리 포인트가 실제 파일과 불일치
"main": "./dist/index.js"라고 선언했는데 실제 빌드 결과물에 dist/index.js가 없다면? 사용자는 설치 직후 import에서 바로 에러를 맞는다. Publint은 선언된 모든 경로가 실제로 디스크에 존재하는지 확인한다.
[💡 잠깐! 이 용어는?]
엔트리 포인트(Entry Point): 패키지를 import할 때 실제로 로드되는 파일이다. package.json의 main, module, exports 필드가 이 진입점을 정의한다. 잘못 설정하면 "Module not found" 에러가 발생한다.
4. exports 값의 상대 경로 생략
exports 필드의 값은 반드시 ./로 시작해야 한다. 사소해 보이지만, 이 한 글자 차이가 패키지 해석을 완전히 깨뜨릴 수 있다.
{
"exports": {
".": "dist/index.mjs"
}
}{
"exports": {
".": "./dist/index.mjs"
}
}검증 규칙 한눈에 보기
| 규칙 | 설명 | 심각도 |
|---|---|---|
| types 순서 | exports에서 types 조건이 첫 번째여야 함 | Warning |
| module vs exports | module 필드만 있으면 exports 권장 | Suggestion |
| 파일 존재 여부 | 선언된 엔트리 포인트가 디스크에 있어야 함 | Error |
| 상대 경로 | exports 값이 ./로 시작해야 함 | Error |
| ESM/CJS 포맷 일치 | .mjs는 ESM, .cjs는 CJS와 일치해야 함 | Warning |
| Dual Publishing | ESM과 CJS 동시 제공 시 일관성 확인 | Warning |
CI/CD 통합 — 자동화가 진짜 가치다
로컬에서 한 번 돌려보는 것도 좋지만, CI에서 매 PR마다 검증하거나 배포 직전에 강제하는 것이 훨씬 안전하다. 교통 법규를 알아도 사고는 나는 법이다. 과속 카메라가 있어야 속도를 줄이게 된다.
{
"scripts": {
"build": "tsup src/index.ts",
"lint:package": "publint",
"prepublishOnly": "npm run build && npm run lint:package"
},
"devDependencies": {
"publint": "^0.3.0"
}
}[💡 잠깐! 이 용어는?]
prepublishOnly: npm publish 실행 직전에 자동으로 실행되는 npm 라이프사이클 스크립트다. 빌드나 린트 같은 배포 전 검증을 여기에 넣으면, 깨진 패키지 배포를 원천 차단할 수 있다.
GitHub Actions에서는 이렇게 설정한다.
name: Validate Package
on: [push, pull_request]
jobs:
publint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
- run: npm run build
- run: npx publint웹에서 바로 확인하기
CLI 없이도 publint.dev에서 패키지 이름을 검색하면 즉시 결과를 확인할 수 있다. 이미 npm에 배포된 패키지라면 이름만 입력하면 되고, pkg.pr.new 링크를 붙여넣어 PR 단계의 패키지도 검증할 수 있다.
유사 도구와의 비교
| 도구 | 범위 | 특징 |
|---|---|---|
| Publint | package.json 필드 + 파일 존재 검증 | 가볍고 빠름, 포맷/환경 호환성에 집중 |
npm pack --dry-run | 배포 대상 파일 목록 확인 | 포맷/호환성 검증 없음 |
arethetypeswrong | TypeScript 타입 해석 검증 | 타입에만 집중, 더 깊은 분석 |
| ESLint | 코드 품질 | 패키지 메타데이터 검증 아님 |
Publint과 arethetypeswrong를 함께 쓰면 패키지 배포 실수의 대부분을 사전에 잡아낼 수 있다.
마무리
npm 패키지 배포에서 가장 흔한 실수는 코드 버그가 아니라 메타데이터 설정 오류다. exports 필드 순서 하나, 상대 경로 표기 하나가 사용자의 import를 깨뜨린다. Publint은 이 문제를 빌드 타임에 잡아주는 간결하면서도 효과적인 도구다. prepublishOnly에 한 줄 추가하는 것만으로 "배포했는데 import 안 돼요" 이슈를 대부분 예방할 수 있다.
참고:
- Publint: https://publint.dev/
- Publint Rules: https://publint.dev/rules
- Are The Types Wrong: https://arethetypeswrong.github.io/
관심 있을 만한 포스트
Native JSON Modules — 번들러 없이 JSON을 import하는 시대
Import Attributes와 함께 표준이 된 native JSON module. 어떻게 동작하고, 기존 번들러 방식과 뭐가 다른지 정리했다.
Babel 8 Beta — CJS를 버리고 ESM 전용으로 간다
2년간의 알파를 거쳐 베타에 진입한 Babel 8의 핵심 변경사항과 마이그레이션 전략을 정리한다.
validator.js의 isLength가 뚫렸다 — 유니코드 제로 폭 문자가 만든 CVSS 7.5 취약점
CVE-2025-12758로 등록된 validator.js의 isLength() 우회 취약점의 원리, 영향, 대응 방법을 분석한다.
AI 코딩의 맹점 — Artifacts 없이 에이전트는 기억을 잃는다
PRD, ADR, TDD가 AI 코딩 워크플로우에서 왜 선택이 아닌 필수인지, 실전 구조와 함께 살펴본다.
Next-Translate 3.0 — Turbopack과 App Router를 위한 i18n 재건
1년간 공백 후 돌아온 Next-Translate 3.0이 Turbopack 지원, 비동기 params, App Router 안정화를 한 번에 처리하는 방법.
V8 WasmGC 투기적 최적화 — 가상 메서드를 인라인으로 만드는 법
V8이 WasmGC의 가상 메서드 디스패치에 투기적 인라이닝을 도입해 Dart와 Java 앱에서 최대 8% 성능을 끌어낸 방법.
Vinext — Vite 위에서 Next.js를 1주일 만에 다시 만든 이야기
Cloudflare가 AI와 함께 단 일주일, $1,100의 API 비용으로 Next.js 호환 프레임워크를 Vite 위에 구축한 과정.
Tsonic — TypeScript를 네이티브 바이너리로 컴파일하는 실험
TypeScript → C# → NativeAOT 파이프라인으로 네이티브 실행 파일을 만드는 Tsonic. 어떻게 동작하고, 어떤 한계가 있는지 살펴봤다.