Ch3. 공유 패키지 설계 패턴
DB(Prisma), UI, utils, config, types 패키지의 설계 패턴과 트리셰이킹
핵심 요약
- DB(Prisma)·UI·utils·types·config 패키지는 빌드 여부와 exports 방식(배럴 vs 서브패스)을 유형별로 다르게 설계합니다.
- Prisma는
@org/db패키지로 공유하고, 클라이언트는 module scope가 아닌getDb()함수 안에서 lazy initialize +globalThis캐시로 HMR 연결 폭증을 막습니다. - UI 패키지는 컴포넌트별 서브패스 export로 소비자가 필요한 것만 import해 번들 크기를 줄입니다.
sideEffects: false로 미사용 export를 제거하되, 글로벌 CSS가 있으면["*.css"]로 CSS만 보존합니다.- 대부분은 내부 패키지(
private: true, 소스 직접 export)로 충분합니다. React 19에서는forwardRef없이ref를 prop으로 받습니다.
패키지 유형별 설계 원칙
| 유형 | 빌드 여부 | exports 방식 | 예시 |
|---|---|---|---|
| DB (Prisma) | prisma generate 필요 | 배럴 export | @org/db |
| UI 컴포넌트 | 소스 직접 export | 컴포넌트별 서브패스 | @org/ui/button |
| 유틸리티 | 소스 직접 export | 배럴 + 서브패스 | @org/utils, @org/utils/cn |
| 타입 정의 | 빌드 불필요 | 배럴 export | @org/types |
| 설정 | 빌드 불필요 | 프리셋별 서브패스 | @org/tsconfig/nextjs |
DB 패키지 (Prisma)
모노레포에서 Prisma는 @org/db 패키지로 공유합니다. 스키마와 Prisma Client를 한곳에서 관리하면 여러 Next.js 앱이 같은 타입-안전 DB 클라이언트를 import합니다.
packages/db/
├── prisma/
│ ├── schema.prisma # 스키마 정의
│ └── migrations/ # 마이그레이션 이력
├── src/
│ ├── client.ts # PrismaClient 싱글턴
│ └── index.ts # 배럴 export
├── package.json
└── tsconfig.json// packages/db/src/client.ts
import { PrismaClient } from '@prisma/client';
const globalForPrisma = globalThis as unknown as { prisma: PrismaClient | undefined };
export function getDb() {
if (!globalForPrisma.prisma) {
globalForPrisma.prisma = new PrismaClient({
log: process.env.NODE_ENV === 'development' ? ['query', 'warn', 'error'] : ['error'],
});
}
return globalForPrisma.prisma;
}// packages/db/src/index.ts
export { getDb } from './client';
export type { User, Post, Prisma } from '@prisma/client';// packages/db/package.json
{
"name": "@acme/db",
"private": true,
"exports": {
".": "./src/index.ts"
},
"scripts": {
"db:generate": "prisma generate",
"db:migrate": "prisma migrate dev",
"db:push": "prisma db push",
"db:studio": "prisma studio"
},
"dependencies": {
"@prisma/client": "^7.8.0"
},
"devDependencies": {
"prisma": "^7.8.0"
}
}앱에서 사용할 때:
// apps/web/app/api/users/route.ts
import { getDb } from '@acme/db';
export async function GET() {
const db = getDb();
const users = await db.user.findMany({ take: 20 });
return Response.json(users);
}// apps/admin/app/users/actions.ts
'use server';
import { getDb } from '@acme/db';
export async function deleteUser(id: string) {
const db = getDb();
await db.user.delete({ where: { id } });
}Prisma Client lazy singleton 패턴
Next.js 빌드와 정적 평가 과정에서는 런타임 환경 변수가 준비되기 전에 모듈이 먼저 로드되기도 합니다.
DB 클라이언트는 module scope에서 즉시 생성하지 말고 getDb() 같은 함수 안에서 lazy initialize하세요.
개발 환경에서는 globalThis 캐시로 HMR 중 연결 폭증을 막습니다.
UI 컴포넌트 패키지
// packages/ui/src/button.tsx
import { cn } from '@acme/utils/cn';
import type { ComponentPropsWithRef } from 'react';
type ButtonProps = ComponentPropsWithRef<'button'> & {
variant?: 'primary' | 'secondary' | 'ghost';
size?: 'sm' | 'md' | 'lg';
};
export function Button({ variant = 'primary', size = 'md', className, ...props }: ButtonProps) {
return (
<button
className={cn(
'inline-flex items-center justify-center rounded-md font-medium transition-colors',
variantStyles[variant],
sizeStyles[size],
className
)}
{...props}
/>
);
}서브패스 export 패턴을 쓰면 소비자가 필요한 컴포넌트만 import해 번들 크기를 줄입니다.
// packages/ui/package.json — exports
{
"exports": {
".": "./src/index.ts",
"./button": "./src/button.tsx",
"./card": "./src/card.tsx",
"./dialog": "./src/dialog.tsx"
}
}유틸리티 패키지
유틸리티 함수는 순수 함수 원칙을 지킵니다. 외부 상태에 기대지 않고 입력→출력만으로 동작해야 트리셰이킹이 정확하게 작동합니다.
// packages/utils/src/cn.ts
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}// packages/utils/src/format-date.ts
export function formatDate(date: Date, locale = 'ko-KR'): string {
return new Intl.DateTimeFormat(locale, {
year: 'numeric', month: 'long', day: 'numeric'
}).format(date);
}타입 정의 패키지
// packages/types/src/user.ts
export type User = {
id: string;
email: string;
name: string;
role: 'admin' | 'member' | 'viewer';
createdAt: Date;
};
export type CreateUserInput = Omit<User, 'id' | 'createdAt'>;Prisma가 생성하는 타입(@prisma/client)과 커스텀 타입(@org/types)을 함께 쓰면 Server Actions과 클라이언트 컴포넌트 사이의 타입 불일치를 컴파일 타임에 잡습니다.
트리셰이킹과 sideEffects
// packages/utils/package.json
{
"sideEffects": false // 번들러에게 미사용 export 제거 허용
}| 설정 | 효과 |
|---|---|
"sideEffects": false | 미사용 export를 번들에서 완전 제거 |
"sideEffects": ["*.css"] | CSS만 부수효과로 보존, 나머지 제거 |
| 미설정 | 번들러가 보수적으로 판단 (제거 안 함) |
UI 패키지에 글로벌 CSS가 있다면 "sideEffects": ["*.css"]를 설정하세요.
내부 vs 퍼블리시 패키지
내부 패키지 (권장 기본값):
├── "private": true
├── 소스 직접 export (빌드 불필요)
├── workspace:* 참조
└── 버전 번호 의미 없음
퍼블리시 패키지 (npm 배포 시):
├── "private": false
├── dist/ 빌드 필수 (tsup, unbuild)
├── Changesets로 버전 관리
└── CI에서 자동 publish엔터프라이즈 프로젝트는 대부분 내부 패키지로 충분합니다. 외부 배포가 필요해지는 시점에 tsup이나 unbuild를 도입하세요.
React 19 타입 변경
React 19에서 ref가 prop으로 전달되면서 forwardRef가 불필요해졌습니다. UI 패키지의 컴포넌트를 React 19 스타일로 업데이트하세요:
// React 19: forwardRef 없이 ref를 직접 받음
type ButtonProps = ComponentPropsWithRef<'button'> & {
variant?: 'primary' | 'secondary';
};
export function Button({ ref, variant = 'primary', ...props }: ButtonProps) {
return <button ref={ref} className={variantStyles[variant]} {...props} />;
}