"이 컴포넌트, 다른 프로젝트에서도 쓰고 싶은데..." 라는 생각을 해본 적 있다면, 이 글이 도움이 될 것이다.
이 글에서는 React + TypeScript로 만든 컴포넌트를 npm 패키지로 만들고, 로컬에서 실제 설치 흐름까지 검증하는 전 과정을 다룬다. 단순히 npm publish만 다루는 것이 아니라, 왜 이런 선택을 했는지, 어디서 막히는지, 그리고 어떻게 해결했는지까지 함께 정리했다.
flowchart LR
A["소스 코드 (src/)"] --> B["tsup 빌드 (dist/)"]
B --> C["Verdaccio 로컬 publish"]
C --> D["테스트 프로젝트 npm install"]
D --> E["검증 완료 → GitHub Packages"]패키지 제작은 크게 두 단계다.
npm install 흐름을 로컬에서 테스트 (Verdaccio)tsup은 TypeScript/JavaScript 패키지를 빌드하는 도구다. 내부적으로 esbuild를 사용하기 때문에 매우 빠르고, 설정이 거의 없어도 동작한다.
💡 Webpack/Vite와 뭐가 다른가요?
Webpack과 Vite는 앱을 빌드하는 도구다. HTML, CSS, 이미지를 번들링하고 개발 서버를 띄우는 데 최적화되어 있다. tsup은 라이브러리를 빌드하는 도구다. ESM/CJS 형식의 JS 파일과.d.ts타입 선언 파일을 만드는 데 특화되어 있다.
패키지를 배포하면 사용자는 이 파일들을 쓰게 된다.
| 파일 | 형식 | 역할 |
|---|---|---|
dist/index.js | ESM | Vite, 최신 번들러용 |
dist/index.cjs | CJS | Node.js, 구형 번들러용 |
dist/index.d.ts | TypeScript 선언 | 자동완성, 타입 체크 |
// tsup.config.ts
import { defineConfig } from 'tsup'
export default defineConfig({
entry: ['src/index.ts'], // 진입점
format: ['esm', 'cjs'], // ESM + CJS 동시 출력
dts: false, // ← 이게 핵심 (아래 DTS 이슈 섹션 참고)
sourcemap: true,
external: [
'react', 'react-dom',
'three', '@react-three/fiber', '@react-three/drei',
'konva', 'react-konva',
],
})external에 나열된 패키지들은 번들에 포함되지 않는다. peer dependency로 선언된 것들은 여기에 넣어야 한다. 그렇지 않으면 번들 크기가 폭발적으로 커지고, 사용자 프로젝트와 버전 충돌이 일어난다.
{
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
},
"./utils": {
"types": "./dist/utils.d.ts",
"import": "./dist/utils.js",
"require": "./dist/utils.cjs"
}
}
}exports 필드는 최신 번들러가 우선적으로 읽는다. main/module은 구형 도구를 위한 fallback이다.
💡 서브패스(
./utils)는 왜 만들었나?
이 패키지는 Three.js와 Konva를 포함하기 때문에 번들 크기가 크다. 타입과 유틸 함수만 필요한 경우/utils진입점에서 가져오면 렌더러 코드가 포함되지 않아 초기 번들 크기를 대폭 줄일 수 있다. 실제로React.lazy()와 함께 사용하면 1,465 KB → 506 KB (-62%) 절감 효과가 있었다.
tsup.config.ts에서 dts: true로 설정하면 tsup이 자동으로 .d.ts 파일을 생성해준다. 그런데 프로젝트 루트에 tsconfig.json의 baseUrl이 설정되어 있으면 오류가 발생한다.
Error: Could not resolve "../types" from "src/components/FloorViewer.tsx"tsup의 DTS 생성 워커가 상위 디렉토리의 tsconfig.json****을 탐색하면서 baseUrl 등의 경로 설정을 잘못 해석하는 것이 원인이다. 모노레포 구조이거나 패키지 디렉토리 외부에 tsconfig가 존재하면 이 문제가 더 자주 발생한다.
dts: false로 tsup DTS를 끄고, TypeScript 컴파일러를 별도로 실행한다.
// package.json
{
"scripts": {
"build": "tsup && tsc --project tsconfig.json --emitDeclarationOnly --outDir dist"
}
}// tsup.config.ts
export default defineConfig({
entry: ['src/index.ts', 'src/utils.ts'],
format: ['esm', 'cjs'],
dts: false, // tsup DTS 비활성화
sourcemap: true,
external: [ /* peer deps */ ],
})// tsconfig.json (패키지 전용)
{
"compilerOptions": {
"declaration": true,
"emitDeclarationOnly": true,
"outDir": "dist",
"rootDir": "src"
}
}⚠️ 주의:
tsc --emitDeclarationOnly는 JS를 출력하지 않고.d.ts만 만든다. tsup이 JS를 만들고, tsc가 타입 선언만 만드는 방식으로 역할을 분리하는 것이다.
빌드 결과물은 아래와 같다.
dist/
├── index.js ← tsup (ESM)
├── index.cjs ← tsup (CJS)
├── index.d.ts ← tsc (타입 선언)
├── utils.js ← tsup (ESM, 서브패스)
├── utils.cjs ← tsup (CJS, 서브패스)
└── utils.d.ts ← tsc (타입 선언, 서브패스)Verdaccio는 로컬에서 실행하는 private npm registry다. 쉽게 말하면 npmjs.com을 내 컴퓨터 안에 똑같이 띄우는 것이다.
일반 npm publish: 패키지 → npmjs.com → 전 세계 공개
Verdaccio: 패키지 → localhost:4873 → 내 컴퓨터 전용💡 왜 로컬 registry가 필요한가?
npm pack으로.tgz를 만들어 로컬 경로로 설치하는 방법도 있지만, 실제npm install @scope/package-name흐름과 완전히 같지는 않다. Verdaccio는 실제 registry 프로토콜을 사용하기 때문에 publish → install 흐름을 100% 동일하게 재현할 수 있다.
| 방법 | 장점 | 단점 | 적합한 상황 |
|---|---|---|---|
npm link | 설정 없음, 즉시 사용 | 심볼릭 링크 기반 — peer dependency 버전 충돌, React 중복 인스턴스 문제 | 빠른 개발 중 임시 테스트 |
npm pack • 로컬 경로 설치 | 설정 없음 | 실제 registry 흐름과 다름, 버전 관리 불편 | 단순 파일 포함 여부 확인 |
| Verdaccio | 실제 publish/install 흐름 동일, peer dep 정상 처리 | 초기 설치 및 서버 실행 필요 | 출시 전 최종 검증 (권장) |
| GitHub Packages | 실제 배포 환경과 동일 | GitHub 조직/토큰 설정 필요, 인터넷 연결 필수 | 최종 배포 |
npm link의 가장 큰 문제는 React 중복 인스턴스다. 패키지 안의 React와 사용자 프로젝트의 React가 서로 다른 인스턴스로 인식되면서 훅 관련 오류가 발생한다. Verdaccio는 실제 node_modules에 설치되기 때문에 이 문제가 없다.
# 전역 설치 (최초 1회)
npm install -g verdaccio
# 실행 (기본 포트: 4873)
verdaccio
# 브라우저에서 http://localhost:4873 확인설정 파일 위치: ~/.config/verdaccio/config.yaml
npm adduser는 대화형 입력을 요구해서 스크립트에서 쓰기 어렵다. REST API로 직접 등록하는 것이 편하다.
curl -X PUT http://localhost:4873/-/user/org.couchdb.user:testuser \
-H "Content-Type: application/json" \
-d '{"name":"testuser","password":"testpass","email":"test@example.com","type":"user"}'
# 응답 예시
# {"ok":"user 'testuser' created","token":"eyJhbGci..."}응답의 token 값을 저장해둔다.
.npmrc 설정패키지 저장소 루트에 .npmrc 파일을 만든다.
# building-visualization/.npmrc
@energyx:registry=http://localhost:4873
//localhost:4873/:_authToken=eyJhbGci...⚠️ **
.npmrc**를 반드시 **.gitignore**에 추가하자. authToken이 포함되어 있기 때문이다.
# 패키지 디렉토리에서
bun run build
# 내부적으로: tsup && tsc --emitDeclarationOnly --outDir dist
npm publish --registry http://localhost:4873
# 또는 .npmrc에 registry가 설정되어 있으면
npm publish버전을 올릴 때는 package.json의 version 필드를 수동으로 수정하거나 npm version patch를 사용한다.
테스트 프로젝트(예: bv-test) 루트에 .npmrc 추가:
# bv-test/.npmrc
@energyx:registry=http://localhost:4873
//localhost:4873/:_authToken=eyJhbGci...# bun은 Verdaccio와 캐시 충돌이 발생할 수 있어 npm 권장
npm install @energyx/building-visualization⚠️ bun 사용 시 주의: bun은 자체 캐시 레이어를 사용하기 때문에 Verdaccio와 충돌이 발생하는 경우가 있다. Verdaccio 검증 단계에서는 npm을 사용하는 것을 권장한다.
// 테스트 프로젝트의 App.tsx
import { lazy, Suspense, useState } from 'react'
import {
toScene,
WALL_HEIGHT,
type ViewMode,
type ZoneBase,
type ZoneVisual,
} from '@energyx/building-visualization/utils' // 서브패스에서 타입/유틸 정적 import
// 렌더러만 lazy로 동적 import — 번들 분리
const FloorViewer = lazy(() =>
import('@energyx/building-visualization').then(m => ({ default: m.FloorViewer }))
)실제 앱에서 렌더링이 되고, 타입 자동완성이 뜨고, 에러가 없으면 검증 완료다.
Verdaccio 검증이 끝나면 GitHub Packages로 전환하는 건 간단하다. **.npmrc**의 registry URL 두 줄만 바꾸면 된다.
# 패키지 .npmrc
- @energyx:registry=http://localhost:4873
- //localhost:4873/:_authToken=<verdaccio-token>
+ @energyx:registry=https://npm.pkg.github.com
+ //npm.pkg.github.com/:_authToken=${GITHUB_TOKEN}# 사용자 프로젝트 .npmrc
- @energyx:registry=http://localhost:4873
- //localhost:4873/:_authToken=<verdaccio-token>
+ @energyx:registry=https://npm.pkg.github.com
+ //npm.pkg.github.com/:_authToken=${GITHUB_TOKEN}package.json의 publishConfig도 함께 업데이트:
{
"publishConfig": {
"registry": "https://npm.pkg.github.com",
"access": "restricted"
}
}GitHub Personal Access Token 발급 경로:
GitHub → Settings → Developer settings → Personal access tokens → Fine-grained tokens
write:packages 권한read:packages 권한✅ 핵심: Verdaccio와 GitHub Packages는
.npmrc두 줄만 다르다. 로컬에서 동작하면 GitHub Packages에서도 동일하게 동작한다. 이것이 Verdaccio로 검증하는 이유다.
tsup.config.ts 작성 — format: ['esm', 'cjs'], dts: false, external 목록tsconfig.json 타입 선언 전용 설정package.json exports 필드 구성bun run build 성공 확인 → dist/ 파일 목록 검토.npmrc 작성 → .gitignore 추가npm publish 성공 확인.npmrc 작성 → npm install 성공.npmrc registry URL 교체패키지 제작에서 가장 어려운 부분은 사실 "어디서 무엇을 써야 하는지" 파악하는 것이다. tsup은 빌드 도구고 Verdaccio는 배포 검증 도구다. 역할이 다르다.
tsup DTS 이슈처럼 처음 마주치면 당황스러운 문제들도 있지만, 원인을 이해하면 해결책이 명확하다. dts: false + tsc --emitDeclarationOnly 조합을 기억해두자.
Verdaccio 검증 단계를 거치면 GitHub Packages나 npmjs.com으로 옮길 때 .npmrc 두 줄만 바꾸면 된다. 이 흐름 자체가 패키지 배포의 본질이다.