배포 전 출국 심사 — Publint로 npm 패키지 실수를 원천 차단하는 법

9 min read
npmPublint패키지exportsESM
배포 전 출국 심사 — Publint로 npm 패키지 실수를 원천 차단하는 법

여권에 이름을 틀리게 적고 공항에 갔다고 상상해보자. 체크인까지는 문제없지만, 출국 심사에서 걸린다. npm 패키지 배포도 비슷하다. package.jsonexports 필드 순서가 잘못되었거나, mainmodule이 엇갈리거나, types 조건이 뒤에 빠져 TypeScript가 타입을 못 찾거나. 문제는 이런 실수가 로컬에서는 절대 드러나지 않는다는 것이다. 빌드도 되고 테스트도 통과하는데, npm publish 후에 사용자가 "import가 안 돼요"라고 이슈를 올린다. Publint는 출국 심사대처럼 배포 직전에 이런 실수를 잡아주는 도구다.


Publint — 패키지 메타데이터 린터

Publint은 package.json을 기반으로 선언된 엔트리 포인트와 실제 배포 파일이 일치하는지 검증하는 린터다. main, module, exports, types 필드를 순회하면서 Vite, Webpack, Rollup, Node.js 등 다양한 환경에서의 호환성을 확인한다.

publint-basic-usage.sh
npx publint@latest

이 한 줄이면 현재 프로젝트의 패키징 문제를 즉시 확인할 수 있다. 다른 디렉토리의 패키지를 검사하고 싶으면 경로를 인자로 전달한다.

publint-with-path.sh
npx publint ./packages/my-lib

[💡 잠깐! 이 용어는?] exports 필드: package.json에서 패키지의 진입점(entry points)을 정의하는 최신 방식이다. 조건부 내보내기(import, require, types)를 지원해서 ESM/CJS/TypeScript 환경별로 다른 파일을 제공할 수 있다.


Publint이 잡아내는 네 가지 함정

1. types 조건의 순서 함정

exports 필드의 조건은 순서가 의미를 가진다. TypeScript가 타입을 올바르게 찾으려면 types가 반드시 첫 번째여야 한다. 뷔페에서 디저트를 먼저 집으면 메인 요리를 놓치는 것처럼, 잘못된 순서는 TypeScript가 타입 정보를 놓치게 만든다.

package.json (잘못된 순서)
{
  "exports": {
    ".": {
      "import": "./dist/index.mjs",
      "types": "./dist/index.d.ts"
    }
  }
}
package.json (올바른 순서)
{
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.mjs"
    }
  }
}

Publint은 types 조건이 첫 번째가 아니면 경고를 표시한다.

2. module 필드만 있고 exports가 없는 경우

module 필드는 번들러 사이의 비공식 규약이다. Node.js는 이 필드를 인식하지 않는다. Publint은 module은 있는데 exports가 없으면, exports 사용을 권장한다.

package.json (비권장)
{
  "main": "./dist/index.cjs",
  "module": "./dist/index.mjs"
}
package.json (권장)
{
  "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.jsonmain, module, exports 필드가 이 진입점을 정의한다. 잘못 설정하면 "Module not found" 에러가 발생한다.

4. exports 값의 상대 경로 생략

exports 필드의 값은 반드시 ./로 시작해야 한다. 사소해 보이지만, 이 한 글자 차이가 패키지 해석을 완전히 깨뜨릴 수 있다.

package.json (잘못된 경로)
{
  "exports": {
    ".": "dist/index.mjs"
  }
}
package.json (올바른 경로)
{
  "exports": {
    ".": "./dist/index.mjs"
  }
}

검증 규칙 한눈에 보기

규칙설명심각도
types 순서exports에서 types 조건이 첫 번째여야 함Warning
module vs exportsmodule 필드만 있으면 exports 권장Suggestion
파일 존재 여부선언된 엔트리 포인트가 디스크에 있어야 함Error
상대 경로exports 값이 ./로 시작해야 함Error
ESM/CJS 포맷 일치.mjs는 ESM, .cjs는 CJS와 일치해야 함Warning
Dual PublishingESM과 CJS 동시 제공 시 일관성 확인Warning

CI/CD 통합 — 자동화가 진짜 가치다

로컬에서 한 번 돌려보는 것도 좋지만, CI에서 매 PR마다 검증하거나 배포 직전에 강제하는 것이 훨씬 안전하다. 교통 법규를 알아도 사고는 나는 법이다. 과속 카메라가 있어야 속도를 줄이게 된다.

package.json
{
  "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에서는 이렇게 설정한다.

.github/workflows/validate-package.yml
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 단계의 패키지도 검증할 수 있다.


유사 도구와의 비교

도구범위특징
Publintpackage.json 필드 + 파일 존재 검증가볍고 빠름, 포맷/환경 호환성에 집중
npm pack --dry-run배포 대상 파일 목록 확인포맷/호환성 검증 없음
arethetypeswrongTypeScript 타입 해석 검증타입에만 집중, 더 깊은 분석
ESLint코드 품질패키지 메타데이터 검증 아님

Publint과 arethetypeswrong를 함께 쓰면 패키지 배포 실수의 대부분을 사전에 잡아낼 수 있다.


마무리

npm 패키지 배포에서 가장 흔한 실수는 코드 버그가 아니라 메타데이터 설정 오류다. exports 필드 순서 하나, 상대 경로 표기 하나가 사용자의 import를 깨뜨린다. Publint은 이 문제를 빌드 타임에 잡아주는 간결하면서도 효과적인 도구다. prepublishOnly에 한 줄 추가하는 것만으로 "배포했는데 import 안 돼요" 이슈를 대부분 예방할 수 있다.


참고:

관심 있을 만한 포스트

Native JSON Modules — 번들러 없이 JSON을 import하는 시대

Import Attributes와 함께 표준이 된 native JSON module. 어떻게 동작하고, 기존 번들러 방식과 뭐가 다른지 정리했다.

JavaScriptESM

Babel 8 Beta — CJS를 버리고 ESM 전용으로 간다

2년간의 알파를 거쳐 베타에 진입한 Babel 8의 핵심 변경사항과 마이그레이션 전략을 정리한다.

BabelESM

validator.js의 isLength가 뚫렸다 — 유니코드 제로 폭 문자가 만든 CVSS 7.5 취약점

CVE-2025-12758로 등록된 validator.js의 isLength() 우회 취약점의 원리, 영향, 대응 방법을 분석한다.

보안validator.js

AI 코딩의 맹점 — Artifacts 없이 에이전트는 기억을 잃는다

PRD, ADR, TDD가 AI 코딩 워크플로우에서 왜 선택이 아닌 필수인지, 실전 구조와 함께 살펴본다.

AI 코딩Artifacts

Next-Translate 3.0 — Turbopack과 App Router를 위한 i18n 재건

1년간 공백 후 돌아온 Next-Translate 3.0이 Turbopack 지원, 비동기 params, App Router 안정화를 한 번에 처리하는 방법.

Next.jsi18n

V8 WasmGC 투기적 최적화 — 가상 메서드를 인라인으로 만드는 법

V8이 WasmGC의 가상 메서드 디스패치에 투기적 인라이닝을 도입해 Dart와 Java 앱에서 최대 8% 성능을 끌어낸 방법.

V8WebAssembly

Vinext — Vite 위에서 Next.js를 1주일 만에 다시 만든 이야기

Cloudflare가 AI와 함께 단 일주일, $1,100의 API 비용으로 Next.js 호환 프레임워크를 Vite 위에 구축한 과정.

VinextNext.js

Tsonic — TypeScript를 네이티브 바이너리로 컴파일하는 실험

TypeScript → C# → NativeAOT 파이프라인으로 네이티브 실행 파일을 만드는 Tsonic. 어떻게 동작하고, 어떤 한계가 있는지 살펴봤다.

TypeScriptNativeAOT