이번 편의 결과물: apps/web의 import가 @bookshelf/shared-types에서 타입을 가져오도록 바뀌고, 앱이 그대로 동작합니다. · 다루는 개념: packages/shared-types 생성, 도메인 타입·zod 스키마 이동, workspace: 프로토콜로 참조
14편에서 apps/web + 빈 packages/로 폴더를 나눴습니다. 지금 bookshelf 도메인 타입(Book, BookInput)과 zod 스키마는 여전히 apps/web/src/schemas/book.ts 안에만 있습니다. 앞으로 만들 packages/ui(17편)나 서버 코드가 생기면 이 타입을 다시 정의하거나 상대 경로로 억지로 끌어와야 합니다. 이 편에서 packages/shared-types라는 독립 패키지로 옮겨, apps/web이 workspace: 프로토콜로 참조하게 만듭니다.
이 편에서 만드는 파일
bookshelf-ts/
├── apps/web/
│ ├── package.json (~, @bookshelf/shared-types 의존성 추가)
│ └── src/
│ ├── schemas/book.ts (삭제, 내용은 shared-types로 이동)
│ ├── services/books.ts (~, import 경로를 @bookshelf/shared-types로 변경)
│ └── routes/BookFormPage.tsx (~, import 경로를 @bookshelf/shared-types로 변경)
└── packages/
└── shared-types/ (+)
├── package.json
├── tsconfig.json
└── src/
├── book.ts (schemas/book.ts 내용 그대로 이동)
└── index.ts (재수출 진입점)개념 정리
왜 앱 안이 아니라 별도 패키지인가
apps/web/src/schemas/에 두면 이 타입을 쓸 수 있는 곳은 apps/web 하나뿐입니다. 별도 패키지로 빼면 packages/ui, 나중에 추가될 서버 코드, 심지어 다른 앱도 같은 타입·같은 검증 규칙을 그대로 가져다 쓸 수 있습니다. “한 곳에서 정의하고 여러 곳에서 참조한다”는 모노레포를 나누는 가장 흔한 이유입니다.
workspace: 프로토콜
일반적인 npm 패키지는 버전 범위(^1.2.0)로 의존성을 명시하지만, 같은 워크스페이스 안의 패키지는 workspace: 프로토콜로 가리킵니다.
| 표기 | 의미 |
|---|---|
workspace:* | 워크스페이스에 있는 버전 그대로 연결. 배포 시 실제 버전 번호로 치환됨 |
workspace:^ | 배포 시 ^가 붙은 버전 범위로 치환됨 |
{
"dependencies": {
"@bookshelf/shared-types": "workspace:*"
}
}pnpm install을 실행하면 pnpm이 apps/web/node_modules/@bookshelf/shared-types에 packages/shared-types를 가리키는 심볼릭 링크를 만듭니다. 별도의 빌드 없이도 import가 실제 소스 파일을 바로 찾아갑니다.
package.json의 main·types가 소스를 직접 가리키는 이유
packages/shared-types는 아직 빌드 스크립트가 없습니다(빌드는 18편에서 tsdown으로 추가). 지금은 package.json의 main·types가 dist/가 아니라 src/index.ts를 직접 가리키게 해서, apps/web을 개발 모드로 실행하는 vite가 이 소스를 즉시 변환해 쓰도록 합니다.
실습
1. shared-types 패키지 뼈대 작성
// bookshelf-ts/packages/shared-types/package.json
{
"name": "@bookshelf/shared-types",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts"
}// bookshelf-ts/packages/shared-types/tsconfig.json
{
"compilerOptions": {
"target": "ES2023",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"skipLibCheck": true,
"declaration": true,
"outDir": "./dist"
},
"include": ["src"]
}declaration·outDir은 지금 당장 쓰이지 않지만, 16편에서 composite: true를 추가할 때 그대로 재사용합니다.
2. 도메인 타입과 스키마 이동
// bookshelf-ts/packages/shared-types/src/book.ts
import { z } from 'zod'
export const bookStatusValues = ['wish', 'reading', 'done'] as const
export const bookInputSchema = z.object({
title: z.string().min(1, '제목을 입력하세요'),
author: z.string().min(1, '저자를 입력하세요'),
pages: z.coerce.number().int().positive('쪽수는 1 이상이어야 합니다'),
status: z.enum(bookStatusValues),
memo: z.string().optional().default(''),
coverUrl: z.string().optional(),
})
export const bookSchema = bookInputSchema.extend({
id: z.string(),
})
export const bookListSchema = z.array(bookSchema)
export type BookStatus = (typeof bookStatusValues)[number]
export type BookInput = z.infer<typeof bookInputSchema>
export type Book = z.infer<typeof bookSchema>// bookshelf-ts/packages/shared-types/src/index.ts
export * from './book'apps/web/src/schemas/book.ts는 내용이 그대로 옮겨졌으므로 삭제합니다.
rm apps/web/src/schemas/book.ts3. apps/web에 의존성 추가
// bookshelf-ts/apps/web/package.json (dependencies 부분만)
{
"dependencies": {
"@bookshelf/shared-types": "workspace:*",
"@hookform/resolvers": "5.9.1",
"@tanstack/react-query": "5.103.2",
"i18next": "26.4.2",
"react": "19.3.0",
"react-dom": "19.3.0",
"react-hook-form": "7.88.0",
"react-i18next": "17.0.15",
"react-router": "8.4.0",
"zod": "4.6.5",
"zustand": "5.0.15"
}
}4. import 경로 갱신
// bookshelf-ts/apps/web/src/services/books.ts (import 부분만)
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { bookListSchema, bookSchema, type Book, type BookInput } from '@bookshelf/shared-types'// bookshelf-ts/apps/web/src/routes/BookFormPage.tsx (import 부분만)
import { useNavigate, useParams } from 'react-router'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { bookInputSchema, bookStatusValues, type BookInput } from '@bookshelf/shared-types'
import { useBookQuery, useCreateBookMutation, useUpdateBookMutation } from '../services/books'상대 경로('../schemas/book')가 패키지 이름('@bookshelf/shared-types')으로 바뀐 것 외에는 나머지 코드가 전혀 바뀌지 않습니다. Book, BookInput, bookInputSchema가 가리키는 실제 선언이 옮겨졌을 뿐, 사용하는 쪽 코드 입장에서는 같은 이름·같은 타입입니다.
5. 설치와 실행
pnpm install
pnpm dev확인
- 루트에서
pnpm install을 실행하면apps/web/node_modules/@bookshelf/shared-types가packages/shared-types를 가리키는 링크로 생깁니다. apps/web/src/schemas/book.ts파일이 더 이상 존재하지 않습니다.- 앱을 다시 실행하면 책 목록·등록·수정이 13편과 똑같이 동작합니다.
packages/shared-types/src/book.ts에서 필드 하나를 지워보면,apps/web의services/books.ts에서 바로 타입 오류가 뜹니다.
직접 해보기
packages/shared-types/src/에pagination.ts를 새로 만들어PageParams(page,pageSize) 타입을 정의하고index.ts에서 재수출해보세요.apps/web에서 방금 만든PageParams를 import해 아무 함수의 매개변수 타입으로 써보고, 정상적으로 타입이 인식되는지 확인해보세요.
정답 보기
// packages/shared-types/src/pagination.ts
export interface PageParams {
page: number
pageSize: number
}// packages/shared-types/src/index.ts
export * from './book'
export * from './pagination'새 파일을 만들고 index.ts에서 재수출만 하면, 별도의 빌드나 설치 과정 없이 apps/web에서 import { type PageParams } from '@bookshelf/shared-types'로 바로 쓸 수 있습니다. main이 소스를 직접 가리키기 때문입니다.
자주 하는 실수
| 증상 | 원인 | 고치는 법 |
|---|---|---|
Cannot find module @bookshelf/shared-types | pnpm install을 다시 실행하지 않음 | package.json에 의존성을 추가한 뒤 반드시 pnpm install을 실행해 링크를 새로 만든다 |
| 타입은 잡히는데 실행 시 모듈을 못 찾음 | packages/shared-types/package.json의 main이 존재하지 않는 경로를 가리킴 | main·types가 실제 src/index.ts 경로와 정확히 일치하는지 확인한다 |
| 스키마를 옮긴 뒤 어디서 import하는지 찾기 어려움 | 상대 경로 import를 하나씩 수동으로 찾음 | 에디터의 전체 검색으로 from '../schemas/book', from '../../schemas/book'을 모두 찾아 바꾼다 |