Prisma 스키마 설계: 관계, Enums 및 확장 가능한 인덱스

발행: (2026년 4월 7일 PM 08:42 GMT+9)
6 분 소요
원문: Dev.to

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.

0 조회
Back to Blog

관련 글

더 보기 »