Prisma 스키마 설계: 관계, Enums 및 확장 가능한 인덱스
Source: Dev.to
Prisma 스키마는 단순한 ORM 설정이 아니라 데이터 아키텍처입니다. 여기서 내린 잘못된 결정은 앱이 성장함에 따라 복합적으로 영향을 미칩니다.
핵심 관계 패턴
일대다
model User {
id String @id @default(cuid())
email String @unique
posts Post[] // one user has many posts
createdAt DateTime @default(now())
}
model Post {
id String @id @default(cuid())
title String
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
@@index([userId]) // always index foreign keys
}
다대다 (암시적)
model Post {
id String @id @default(cuid())
tags Tag[]
}
model Tag {
id String @id @default(cuid())
name String @unique
posts Post[]
}
// Prisma creates a join table automatically
다대다 (명시적 — 메타데이터가 필요할 때)
model User {
id String @id @default(cuid())
memberships Membership[]
}
model Organization {
id String @id @default(cuid())
memberships Membership[]
}
model Membership {
id String @id @default(cuid())
userId String
orgId String
role Role @default(MEMBER)
joinedAt DateTime @default(now())
user User @relation(fields: [userId], references: [id])
org Organization @relation(fields: [orgId], references: [id])
@@unique([userId, orgId]) // one membership per user per org
@@index([orgId])
}
enum Role {
OWNER
ADMIN
MEMBER
}
열거형
enum SubscriptionStatus {
TRIALING
ACTIVE
PAST_DUE
CANCELED
PAUSED
}
enum PlanType {
FREE
PRO
ENTERPRISE
}
model Subscription {
id String @id @default(cuid())
userId String @unique
status SubscriptionStatus @default(TRIALING)
plan PlanType @default(FREE)
user User @relation(fields: [userId], references: [id])
}
열거형은 애플리케이션 수준뿐만 아니라 데이터베이스 수준에서도 검증됩니다. 고정된 값 집합에는 문자열 필드보다 열거형을 사용하는 것이 좋습니다.
중요한 인덱스
model Event {
id String @id @default(cuid())
userId String
type String
createdAt DateTime @default(now())
@@index([userId]) // for user event feeds
@@index([type, createdAt]) // for filtering by type + time
@@index([userId, createdAt]) // for user activity sorted by time
}
항상 인덱스 지정:
- 외래키 필드
WHERE절에 사용되는 필드- 대형 테이블에서
ORDER BY에 사용되는 필드 - 고유 제약 조건 필드 (Prisma가 자동으로 추가합니다)
복합 인덱스: 순서가 중요합니다. 가장 선택도가 높은 필드를 먼저 두거나, 등호 비교에 사용되는 필드를 범위 비교에 사용되는 필드보다 앞에 두세요.
유연한 데이터를 위한 JSON 필드
model AuditLog {
id String @id @default(cuid())
userId String
action String
metadata Json // flexible schema for action‑specific data
createdAt DateTime @default(now())
@@index([userId, createdAt])
@@index([action])
}
// Type‑safe JSON access
const log = await prisma.auditLog.findFirst();
const meta = log.metadata as { ip: string; userAgent: string };
소프트 삭제
model Post {
id String @id @default(cuid())
title String
deletedAt DateTime? // null = active, timestamp = deleted
@@index([deletedAt]) // filter active records efficiently
}
// Only fetch non‑deleted posts
const posts = await prisma.post.findMany({
where: { deletedAt: null },
});
// Soft delete
await prisma.post.update({
where: { id },
data: { deletedAt: new Date() },
});
멀티‑테넌시 패턴
model Organization {
id String @id @default(cuid())
name String
slug String @unique
users User[]
projects Project[]
}
model Project {
id String @id @default(cuid())
name String
orgId String
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
@@index([orgId])
@@unique([orgId, name]) // unique project names within org
}
모든 테넌트‑스코프 쿼리는 where 절에 orgId를 포함합니다. 인덱스는 수백만 행이 있더라도 이러한 쿼리가 빠르게 수행되도록 보장합니다.
마이그레이션 모범 사례
# Development: create + apply migration
npx prisma migrate dev --name add-subscription-table
# Production: apply pending migrations
npx prisma migrate deploy
# Never edit existing migrations
# Create new ones to fix mistakes
신중히 검토해야 할 위험한 마이그레이션:
- 기존 테이블에
NOT NULL컬럼을 추가하기 (기본값 또는 백필 필요) - 컬럼 제거 (데이터 손실; 먼저 애플리케이션 코드를 업데이트)
- 컬럼 타입 변경 (명시적 캐스팅이 필요할 수 있음)
대용량 테이블의 경우, SQL에서 직접 동시에 인덱스를 생성하세요:
-- In a migration file
CREATE INDEX CONCURRENTLY idx_user_email ON "User" (email);
CREATE INDEX CONCURRENTLY "Event_userId_createdAt_idx"
ON "Event" ("userId", "createdAt");
스키마는 데이터베이스와의 계약입니다. 초기에는 변경 비용이 저렴하지만 나중에는 비쌉니다. 데이터를 갖기 전에 관계와 인덱스를 충분히 고민하세요.
인증, 결제, 멀티 테넌시 및 감사 로그가 포함된 완전한 Prisma 스키마: Whoff Agents AI SaaS Starter Kit.