Ch3. 공유 패키지 설계 패턴
DB(Prisma), UI, utils, config, types 패키지의 설계 패턴과 트리셰이킹
패키지 유형별 설계 원칙
| 유형 | 빌드 여부 | 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 const db = globalForPrisma.prisma ?? new PrismaClient({
log: process.env.NODE_ENV === 'development' ? ['query', 'warn', 'error'] : ['error'],
});
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = db;// packages/db/src/index.ts
export { db } 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": "^6.3.0"
},
"devDependencies": {
"prisma": "^6.3.0"
}
}앱에서 사용할 때:
// apps/web/app/api/users/route.ts
import { db } from '@acme/db';
export async function GET() {
const users = await db.user.findMany({ take: 20 });
return Response.json(users);
}// apps/admin/app/users/actions.ts
'use server';
import { db } from '@acme/db';
export async function deleteUser(id: string) {
await db.user.delete({ where: { id } });
}Prisma Client 싱글턴 패턴
Next.js 개발 모드에서는 HMR이 모듈을 재로드하므로, globalThis에 인스턴스를 저장하지 않으면 DB 연결이 매번 새로 생성됩니다.
프로덕션에서는 globalForPrisma 없이도 정상 동작하지만, 개발 환경을 위해 항상 싱글턴을 유지하세요.
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} />;
}