이번 편의 결과물: packages/ui/src 아래로 BookCard, StatusBadge, Modal, DataTable이 독립 패키지로 옮겨지고, apps/web이 @bookshelf/ui를 import해 화면이 그대로 렌더링됩니다. · 다루는 개념: 패키지 추출 기준, package.json의 main·types 필드, tsconfig project references에 세 번째 패키지 추가
16편까지 bookshelf-ts는 apps/web(react 컴포넌트 전체가 TypeScript로 전환된 앱)과 packages/shared-types(도메인 타입과 zod 스키마) 두 패키지로 이루어진 모노레포였습니다. 루트 tsc -b를 실행하면 shared-types가 먼저 빌드되고 그다음 web이 빌드되는 순서가 project references로 관리되고 있습니다. 이번 편은 여기에 세 번째 패키지 packages/ui를 더해, 책 도메인에 묶이지 않고 여러 화면에서 재사용되는 프레젠테이션 컴포넌트를 분리합니다.
이 편에서 만드는 파일
bookshelf-ts/
├── tsconfig.json ~ (references에 packages/ui 추가)
├── packages/
│ ├── shared-types/ (기존, 변경 없음)
│ └── ui/ + 신규 패키지
│ ├── package.json +
│ ├── tsconfig.json +
│ └── src/
│ ├── index.ts + (배럴 export)
│ ├── BookCard.tsx + (apps/web에서 이동)
│ ├── StatusBadge.tsx + (apps/web에서 이동)
│ ├── Modal.tsx + (apps/web에서 이동)
│ └── DataTable.tsx + (apps/web에서 이동)
└── apps/
└── web/
├── package.json ~ (dependencies에 @bookshelf/ui 추가)
├── tsconfig.app.json ~ (references에 packages/ui 추가)
└── src/
├── components/ - BookCard.tsx, StatusBadge.tsx, Modal.tsx, DataTable.tsx 삭제
└── pages/BookListPage.tsx ~ (import 경로를 @bookshelf/ui로 교체)개념 정리
무엇을 패키지로 뽑을 것인가
packages/shared-types는 “타입만” 담아 기준이 단순했습니다. UI 컴포넌트는 기준이 하나 더 필요합니다. BookCard, StatusBadge, Modal, DataTable은 모두 props로 받은 데이터만 그리는 프레젠테이션 컴포넌트입니다. TanStack Query 훅을 직접 호출하거나 Zustand 스토어를 구독하는 컨테이너 컴포넌트(BookListPage 등)는 앱의 데이터 흐름에 묶여 있으므로 apps/web에 남깁니다.
| 컴포넌트 | 데이터 소스 | 분리 대상 |
|---|---|---|
BookCard | props로 받은 Book 하나 | 예 |
StatusBadge | props로 받은 상태 값 | 예 |
Modal | props로 받은 열림 여부와 자식 요소 | 예 |
DataTable | props로 받은 배열과 컬럼 정의(제네릭) | 예 |
BookListPage | useBooks 훅으로 서버에서 직접 조회 | 아니오 |
package.json의 main·types 필드
패키지를 다른 패키지에서 import할 때, Node.js와 TypeScript는 각각 main과 types(또는 typings) 필드를 보고 실제 진입 파일을 찾습니다. 아직 tsdown을 붙이기 전인 이번 편에서는 tsc -b가 src를 컴파일해 만든 dist/index.js, dist/index.d.ts를 그대로 가리킵니다.
{
"main": "./dist/index.js",
"types": "./dist/index.d.ts"
}이 방식의 한계는 packages/ui의 코드를 고칠 때마다 dist를 다시 빌드해야 apps/web에 반영된다는 점입니다. 18편에서 tsdown의 개발용 참조 기능으로 이 부분을 개선합니다.
project references에 패키지 추가하기
16편에서 만든 루트 tsconfig.json의 references 배열에 packages/ui를 추가하고, packages/ui는 타입만 참조하는 shared-types를 다시 참조합니다. apps/web은 shared-types와 ui를 모두 참조합니다. 빌드 순서는 의존 관계를 거슬러 shared-types → ui → web으로 자동 결정됩니다.
실습
1. packages/ui 스캐폴딩
// packages/ui/package.json
{
"name": "@bookshelf/ui",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"scripts": {
"build": "tsc -b"
},
"dependencies": {
"@bookshelf/shared-types": "workspace:*"
},
"peerDependencies": {
"react": "^19.3.0",
"react-dom": "^19.3.0"
},
"devDependencies": {
"typescript": "^7.0.2",
"@types/react": "^19.3.0",
"@types/react-dom": "^19.3.0"
}
}react를 dependencies가 아니라 peerDependencies에 둡니다. 컴포넌트 라이브러리는 자기만의 React 인스턴스를 갖지 않고, 이 패키지를 설치하는 앱(apps/web)이 이미 가진 React를 빌려 씁니다.
// packages/ui/tsconfig.json
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"composite": true,
"outDir": "./dist",
"rootDir": "./src",
"jsx": "react-jsx",
"lib": ["ES2023", "DOM", "DOM.Iterable"]
},
"include": ["src"],
"references": [{ "path": "../shared-types" }]
}2. 컴포넌트 이동과 타입 적용
// packages/ui/src/BookCard.tsx
import type { Book } from '@bookshelf/shared-types'
interface BookCardProps {
book: Book
}
function BookCard({ book }: BookCardProps) {
return (
<article className="book-card">
{book.coverUrl ? (
<img className="book-cover" src={book.coverUrl} alt={`${book.title} 표지`} />
) : (
<div className="book-cover-placeholder" aria-hidden="true" />
)}
<h3>{book.title}</h3>
<p>{book.author}</p>
<p>{book.pages}쪽</p>
</article>
)
}
export default BookCardBook은 apps/web이 직접 정의하지 않고 @bookshelf/shared-types에서 가져옵니다. packages/ui가 shared-types에 의존하는 이유가 여기서 드러납니다. BookCard가 알아야 하는 책의 모양(제목, 저자, 쪽수, 표지 주소)이 앱과 라이브러리 양쪽에서 항상 같은 정의를 참조해야 하기 때문입니다.
// packages/ui/src/StatusBadge.tsx
type StatusBadgeProps =
| { status: 'wish' }
| { status: 'reading'; startedAt: string }
| { status: 'done'; rating: number }
function StatusBadge(props: StatusBadgeProps) {
if (props.status === 'reading') {
return <span className="badge badge-reading">읽는 중 · {props.startedAt}부터</span>
}
if (props.status === 'done') {
return <span className="badge badge-done">완독 · 별점 {props.rating}</span>
}
return <span className="badge badge-wish">읽고 싶어요</span>
}
export default StatusBadge
export type { StatusBadgeProps }09편에서 만든 판별 유니온 props를 그대로 옮겼습니다. status가 reading이면 startedAt이, done이면 rating이 함께 있어야 한다는 규칙이 패키지 경계를 넘어도 그대로 유지됩니다. StatusBadge는 Book을 몰라도 되도록 일부러 Book을 import하지 않습니다. Book에서 필요한 값을 뽑아 이 props 모양으로 바꾸는 변환 로직은 apps/web 쪽 코드가 맡고, packages/ui는 이미 정리된 값만 받아 그리는 역할만 합니다. 그래야 나중에 Book의 필드가 바뀌어도 packages/ui를 다시 배포하지 않아도 됩니다.
// packages/ui/src/Modal.tsx
import { useEffect, useRef, type ReactNode } from 'react'
interface ModalProps {
isOpen: boolean
onClose: () => void
title: string
children: ReactNode
}
export function Modal({ isOpen, onClose, title, children }: ModalProps) {
const dialogRef = useRef<HTMLDivElement>(null)
const previouslyFocused = useRef<HTMLElement | null>(null)
useEffect(() => {
if (!isOpen) return
previouslyFocused.current = document.activeElement as HTMLElement | null
dialogRef.current?.focus()
function handleKeyDown(event: KeyboardEvent): void {
if (event.key === 'Escape') onClose()
}
document.addEventListener('keydown', handleKeyDown)
return () => {
document.removeEventListener('keydown', handleKeyDown)
previouslyFocused.current?.focus()
}
}, [isOpen, onClose])
if (!isOpen) return null
return (
<div className="modal-backdrop" onClick={onClose}>
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
tabIndex={-1}
onClick={(event) => event.stopPropagation()}
>
<h2 id="modal-title">{title}</h2>
{children}
</div>
</div>
)
}// packages/ui/src/DataTable.tsx
import type { ReactNode } from 'react'
interface WithId {
id: string | number
}
export interface DataTableColumn<T> {
key: keyof T
label: string
sortable?: boolean
render?: (item: T) => ReactNode
}
interface DataTableProps<T extends WithId> {
items: T[]
columns: DataTableColumn<T>[]
sortBy?: keyof T
sortOrder?: 'asc' | 'desc'
onSortChange?: (key: keyof T) => void
}
function DataTable<T extends WithId>({ items, columns, sortBy, sortOrder, onSortChange }: DataTableProps<T>) {
return (
<table className="data-table">
<thead>
<tr>
{columns.map((column) => (
<th key={String(column.key)}>
{column.sortable && onSortChange ? (
<button type="button" onClick={() => onSortChange(column.key)}>
{column.label}
{sortBy === column.key ? (sortOrder === 'asc' ? ' ▲' : ' ▼') : ''}
</button>
) : (
column.label
)}
</th>
))}
</tr>
</thead>
<tbody>
{items.map((item) => (
<tr key={item.id}>
{columns.map((column) => (
<td key={String(column.key)}>{column.render ? column.render(item) : String(item[column.key])}</td>
))}
</tr>
))}
</tbody>
</table>
)
}
export default DataTable
export type { DataTableColumn }10편에서 만든 제네릭 컴포넌트를 그대로 옮겼습니다. DataTable은 Book을 전혀 모르고, T extends WithId 제약만으로 apps/web이 넘기는 어떤 목록에도 재사용됩니다. Book은 id가 string이라 WithId(id: string | number) 제약을 그대로 만족합니다.
// packages/ui/src/index.ts
export { default as BookCard } from './BookCard'
export { default as StatusBadge } from './StatusBadge'
export type { StatusBadgeProps } from './StatusBadge'
export { Modal } from './Modal'
export { default as DataTable } from './DataTable'
export type { DataTableColumn } from './DataTable'각 컴포넌트 파일 안에서는 export default(09·10편의 기존 방식)를 그대로 두고, 배럴 파일에서 export { default as 이름 }으로 이름을 붙여 다시 내보냅니다. apps/web은 이제 어떤 파일이 기본 내보내기였는지 신경 쓰지 않고 @bookshelf/ui에서 이름으로만 가져오면 됩니다.
3. 루트와 apps/web 설정 갱신
// tsconfig.json (루트, references 배열만 발췌)
{
"files": [],
"references": [
{ "path": "packages/shared-types" },
{ "path": "packages/ui" },
{ "path": "apps/web" }
]
}// apps/web/tsconfig.app.json (references 배열만 발췌 — 16편에서 shared-types 참조를 추가했던 바로 그 파일)
{
"references": [
{ "path": "../../packages/shared-types" },
{ "path": "../../packages/ui" }
]
}// apps/web/package.json (dependencies 발췌)
{
"dependencies": {
"@bookshelf/shared-types": "workspace:*",
"@bookshelf/ui": "workspace:*"
}
}// apps/web/src/pages/BookListPage.tsx (import 부분만 발췌 — 나머지는 기존 코드 유지)
import { DataTable, BookCard, StatusBadge } from '@bookshelf/ui'
import type { Book } from '@bookshelf/shared-types'기존에 ../components/BookCard, ../components/DataTable처럼 상대 경로로 가져오던 부분을 모두 @bookshelf/ui로 바꿉니다. apps/web/src/components에는 이제 Layout.tsx처럼 앱 전용 컴포넌트만 남습니다.
4. 설치와 빌드
pnpm install
pnpm --filter @bookshelf/ui build
pnpm -w exec tsc -b
pnpm --filter @bookshelf/web dev5. 확인
pnpm install실행 후node_modules/@bookshelf/ui가packages/ui로 연결된 심볼릭 링크로 보입니다.packages/ui에서pnpm build를 실행하면dist/index.js,dist/index.d.ts가 생성됩니다.- 루트에서
tsc -b를 실행하면shared-types,ui,web순서로 빌드되고 오류 없이 끝납니다. - 책 목록 화면, 상태 뱃지, 정렬 가능한 표가 이전과 동일하게 보입니다.
- VS Code에서
BookCard에 마우스를 올리면 정의로packages/ui/src/BookCard.tsx가 잡힙니다.
직접 해보기
apps/web/src/components에 남아 있는 컴포넌트 중 페이지 이동 없이 여러 화면에 쓰이는 것이 더 있는지 찾아보고, 있다면 같은 방식으로packages/ui로 옮겨 보세요.packages/ui/src에Pagination.tsx(현재 페이지·전체 페이지 수·페이지 변경 콜백만 props로 받는 컴포넌트)를 새로 만들고index.ts에 추가해 보세요.
정답 보기
// packages/ui/src/Pagination.tsx
interface PaginationProps {
page: number
totalPages: number
onChange: (page: number) => void
}
export function Pagination({ page, totalPages, onChange }: PaginationProps) {
return (
<nav className="pagination" aria-label="페이지 이동">
<button type="button" disabled={page <= 1} onClick={() => onChange(page - 1)}>
이전
</button>
<span>
{page} / {totalPages}
</span>
<button type="button" disabled={page >= totalPages} onClick={() => onChange(page + 1)}>
다음
</button>
</nav>
)
}Pagination도 DataTable과 마찬가지로 책 도메인을 전혀 모르는 순수 프레젠테이션 컴포넌트라 분리 기준에 정확히 들어맞습니다.
자주 하는 실수
| 증상 | 원인 | 고치는 법 |
|---|---|---|
| import 에러(@bookshelf/ui를 찾을 수 없음) | pnpm install을 다시 안 돌려 워크스페이스 링크가 안 생김 | 새 패키지를 추가한 뒤에는 항상 pnpm install 재실행 |
| 화면은 그대로인데 타입만 오류남 | packages/ui를 고치고 dist를 다시 빌드하지 않음 | pnpm --filter @bookshelf/ui build로 dist 갱신 |
| 훅 관련 런타임 오류(Invalid hook call) | react를 peerDependencies가 아닌 dependencies에 둠 | React는 항상 peerDependencies로 선언 |
| DataTable을 쓰는 곳에서 타입 추론이 안 됨 | 컬럼 배열을 빈 배열로 시작해 타입 매개변수를 추론할 근거가 없음 | columns 배열에 실제 Book 배열용 컬럼 정의를 채워 타입 인자가 추론되게 함 |