Getting Started (업데이트: 2026. 6. 3.)

Claude Code로 Prisma ORM 구현하기: schema, migration, transaction, test

Claude Code로 Prisma ORM을 설계·구현하는 실전 가이드. schema, migration, transaction, seed/test와 리뷰 체크리스트.

Claude Code로 Prisma ORM 구현하기: schema, migration, transaction, test

Prisma ORM은 TypeScript에서 DB를 안전하게 다루기 위한 타입이 있는 DB 레이어입니다. schema.prisma에 데이터 모델을 선언하고, Prisma Migrate로 SQL migration을 만들고, Prisma Client로 타입 안전한 query를 작성합니다. Claude Code와 함께 쓰면 schema 설계부터 테스트까지 빠르게 만들 수 있지만, 검토 없이 맡기면 운영에서 문제가 생깁니다.

특히 콘텐츠 사이트나 SaaS처럼 트래픽이 늘어나는 서비스에서는 index 누락, 과한include, 위험한 cascade 삭제, migration SQL 미검토가 바로 비용으로 돌아옵니다. 이 글은 블로그 API를 예시로, Claude Code에게 무엇을 요청하고 어떤 부분을 사람이 리뷰해야 하는지 실무 흐름으로 정리합니다.

공식 기준은 Prisma ORM docs, Prisma Schema, Transactions, Prisma Migrate를 확인하세요. 함께 보면 좋은 글은 Claude Code 입문, Drizzle ORM 활용, Supabase 연동, Redis 캐싱입니다.

작업 흐름

flowchart LR
  A["비즈니스 규칙을 Claude Code에 전달"] --> B["schema.prisma 설계"]
  B --> C["migration.sql 생성 및 리뷰"]
  C --> D["Prisma Client query 구현"]
  D --> E["seed와 test 추가"]
  E --> F["배포 전 checklist 확인"]

Prisma 작업은 schema, SQL migration, TypeScript query 코드로 나누어 검토하면 안정적입니다. Claude Code는 초안을 만들고, 사람은 제약 조건과 삭제 정책, index, transaction 경계를 확인합니다.

Claude Code 프롬프트

블로그 API용 Prisma ORM 데이터 레이어를 설계해 주세요.

전제:
- TypeScript + Prisma ORM + SQLite로 로컬 검증, 이후 PostgreSQL 이전 가능
- User, Post, Category, Comment, Notification, AuditLog 모델 사용
- Post는 status 문자열로 DRAFT/PUBLISHED/ARCHIVED를 표현
- email과 slug는 unique
- 공개 글 목록은 status, publishedAt, author, category로 필터링
- Post 삭제 시 Comment와 join row는 cascade 삭제
- User 삭제는 우선 Restrict, 익명화 migration은 별도 설계

출력:
1. prisma/schema.prisma
2. migration SQL 리뷰 포인트
3. Prisma Client create/list/publish 코드
4. seed와 test 명령
5. 배포 전 review checklist

좋은 프롬프트는 단순 모델명이 아니라 운영 규칙을 포함합니다. 삭제해도 되는 데이터와 절대 지우면 안 되는 데이터를 구분해야 Claude Code가 위험한 cascade를 남발하지 않습니다.

실행 가능한 기본 설정

{
  "type": "module",
  "scripts": {
    "db:generate": "prisma generate",
    "db:migrate": "prisma migrate dev",
    "db:deploy": "prisma migrate deploy",
    "db:seed": "prisma db seed",
    "dev": "tsx src/demo.ts",
    "test": "vitest run"
  },
  "dependencies": {
    "@prisma/client": "latest",
    "dotenv": "latest"
  },
  "devDependencies": {
    "prisma": "latest",
    "tsx": "latest",
    "typescript": "latest",
    "vitest": "latest"
  }
}
npm install
echo 'DATABASE_URL="file:./dev.db"' > .env
mkdir prisma src
// prisma.config.ts
import "dotenv/config";
import { defineConfig, env } from "prisma/config";

export default defineConfig({
  schema: "prisma/schema.prisma",
  migrations: {
    path: "prisma/migrations",
    seed: "tsx prisma/seed.ts",
  },
  datasource: {
    url: env("DATABASE_URL"),
  },
});

schema.prisma 예시

generator client {
  provider = "prisma-client"
  output   = "../src/generated/prisma"
}

datasource db {
  provider = "sqlite"
}

model User {
  id            String         @id @default(cuid())
  email         String         @unique
  name          String
  role          String         @default("editor")
  posts         Post[]
  comments      Comment[]
  notifications Notification[]
  createdAt     DateTime       @default(now())
  updatedAt     DateTime       @updatedAt

  @@index([role])
}

model Post {
  id          String              @id @default(cuid())
  slug        String              @unique
  title       String
  body        String
  status      String              @default("DRAFT")
  authorId    String
  author      User                @relation(fields: [authorId], references: [id], onDelete: Restrict)
  categories  CategoriesOnPosts[]
  comments    Comment[]
  publishedAt DateTime?
  createdAt   DateTime            @default(now())
  updatedAt   DateTime            @updatedAt

  @@index([authorId])
  @@index([status, publishedAt])
}

model Category {
  id    String              @id @default(cuid())
  slug  String              @unique
  name  String
  posts CategoriesOnPosts[]
}

model CategoriesOnPosts {
  postId     String
  categoryId String
  assignedAt DateTime @default(now())
  post       Post     @relation(fields: [postId], references: [id], onDelete: Cascade)
  category   Category @relation(fields: [categoryId], references: [id], onDelete: Cascade)

  @@id([postId, categoryId])
}

model Comment {
  id        String   @id @default(cuid())
  body      String
  postId    String
  authorId  String
  post      Post     @relation(fields: [postId], references: [id], onDelete: Cascade)
  author    User     @relation(fields: [authorId], references: [id], onDelete: Restrict)
  createdAt DateTime @default(now())

  @@index([postId, createdAt])
  @@index([authorId])
}

model Notification {
  id        String    @id @default(cuid())
  userId    String
  user      User      @relation(fields: [userId], references: [id], onDelete: Cascade)
  type      String
  message   String
  readAt    DateTime?
  createdAt DateTime  @default(now())

  @@index([userId, readAt])
}

model AuditLog {
  id        String   @id @default(cuid())
  action    String
  targetId  String
  metadata  String?
  createdAt DateTime @default(now())

  @@index([action, createdAt])
}

@@index([status, publishedAt])는 공개 글 목록에, @@index([postId, createdAt])는 댓글 표시 화면에 대응합니다. Claude Code에게 각 index가 어떤 화면의 WHERE/ORDER BY를 지원하는지 설명하게 하면 불필요한 index도 줄일 수 있습니다.

migration 리뷰

npx prisma format
npx prisma migrate dev --create-only --name init_blog
# PR에서 prisma/migrations/*/migration.sql 확인
npx prisma migrate dev
npx prisma generate

운영 배포에서는 다음을 사용합니다.

npx prisma migrate deploy

db push는 빠른 프로토타입에는 좋지만, 운영 migration 이력으로는 부족합니다. 기존 데이터가 있는 테이블에NOT NULL을 추가하거나, unique 제약을 걸거나, cascade 삭제를 바꾸는 SQL은 반드시 사람이 읽어야 합니다.

rollback 계획은 배포 전에 PR에 적어 둡니다. 이미 성공한 migration을 되돌릴 때는 보통 새 forward migration으로 반대 변경을 만드는 편이 안전합니다. 중간에 실패한 migration은 Prisma의 down migration workflow에 맞춰 backup 확인, migrate status, 검토된 down SQL 적용, migrate resolve 순서로 처리합니다.

npx prisma migrate status
npx prisma migrate resolve --rolled-back "20260603090000_failed_change"

Prisma Client와 transaction

// src/db.ts
import { PrismaClient } from "./generated/prisma/client";

const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient };

export const prisma =
  globalForPrisma.prisma ??
  new PrismaClient({
    log: ["warn", "error"],
  });

if (process.env.NODE_ENV !== "production") {
  globalForPrisma.prisma = prisma;
}
// src/posts.ts
import { Prisma } from "./generated/prisma/client";
import { prisma } from "./db";

export async function listPublishedPosts(params: {
  page?: number;
  perPage?: number;
  categorySlug?: string;
  search?: string;
}) {
  const page = Math.max(params.page ?? 1, 1);
  const perPage = Math.min(Math.max(params.perPage ?? 20, 1), 50);

  const where: Prisma.PostWhereInput = {
    status: "PUBLISHED",
    ...(params.categorySlug
      ? { categories: { some: { category: { slug: params.categorySlug } } } }
      : {}),
    ...(params.search
      ? { OR: [{ title: { contains: params.search } }, { body: { contains: params.search } }] }
      : {}),
  };

  const [items, total] = await prisma.$transaction([
    prisma.post.findMany({
      where,
      skip: (page - 1) * perPage,
      take: perPage,
      orderBy: [{ publishedAt: "desc" }, { createdAt: "desc" }],
      select: {
        id: true,
        slug: true,
        title: true,
        publishedAt: true,
        author: { select: { name: true } },
        _count: { select: { comments: true } },
      },
    }),
    prisma.post.count({ where }),
  ]);

  return { items, pagination: { page, perPage, total } };
}

export async function publishPost(postId: string) {
  return prisma.$transaction(async (tx) => {
    const post = await tx.post.findUnique({
      where: { id: postId },
      select: { id: true, title: true, status: true, authorId: true },
    });

    if (!post) throw new Error("Post not found");
    if (post.status === "PUBLISHED") return post;

    const published = await tx.post.update({
      where: { id: post.id },
      data: { status: "PUBLISHED", publishedAt: new Date() },
      select: { id: true, title: true, status: true, publishedAt: true },
    });

    await tx.notification.create({
      data: {
        userId: post.authorId,
        type: "POST_PUBLISHED",
        message: `${post.title} was published`,
      },
    });

    await tx.auditLog.create({
      data: {
        action: "POST_PUBLISHED",
        targetId: post.id,
        metadata: JSON.stringify({ title: post.title }),
      },
    });

    return published;
  });
}

목록과 count처럼 독립적인 읽기는$transaction([])로 묶을 수 있습니다. 글 발행처럼 여러 테이블이 함께 바뀌는 작업은 interactive transaction이 적합합니다. 단, transaction 안에서 이메일 발송이나 외부 API 호출을 하지 마세요. DB 연결을 오래 잡아 deadlock과 성능 저하를 부를 수 있습니다.

safe SQL

먼저 Prisma Client query를 사용합니다. 리포팅 때문에 raw SQL이 꼭 필요하다면 pitfall은 문자열 연결입니다. SQL-injection risk를 피하려면 Prisma raw queries의 권장처럼 tagged template이나 Prisma.sql을 씁니다.

import { Prisma } from "./generated/prisma/client";
import { prisma } from "./db";

export async function topAuthors(limit = 10) {
  return prisma.$queryRaw<
    { authorId: string; postCount: bigint }[]
  >(Prisma.sql`
    SELECT "authorId", COUNT(*) AS "postCount"
    FROM "Post"
    WHERE "status" = ${"PUBLISHED"}
    GROUP BY "authorId"
    ORDER BY "postCount" DESC
    LIMIT ${Math.min(limit, 50)}
  `);
}

Claude Code가 $queryRawUnsafe를 제안하면 반드시 이유를 쓰게 하세요. 동적 테이블명이나 컬럼명은 사람이 검토한 allowlist로 고정합니다.

seed, test, checklist

// prisma/seed.ts
import { PrismaClient } from "../src/generated/prisma/client";

const prisma = new PrismaClient();

async function main() {
  const user = await prisma.user.upsert({
    where: { email: "editor@example.com" },
    update: { name: "Editor" },
    create: { email: "editor@example.com", name: "Editor", role: "admin" },
  });

  await prisma.post.upsert({
    where: { slug: "claude-code-prisma-demo" },
    update: { status: "PUBLISHED", publishedAt: new Date() },
    create: {
      slug: "claude-code-prisma-demo",
      title: "Claude Code Prisma demo",
      body: "Seeded article for local verification.",
      status: "PUBLISHED",
      publishedAt: new Date(),
      authorId: user.id,
    },
  });
}

main().finally(async () => prisma.$disconnect());
npm run db:migrate -- --name init_blog
npm run db:generate
npm run db:seed
npm run test

배포 전에는 unique/index/relation/delete rule, migration SQL, pagination 제한, transaction 길이, seed의 반복 실행 가능성, rollback 테스트, 운영 환경의migrate deploy사용 여부를 확인합니다.

사용 사례와 주의점

Prisma ORM은 콘텐츠 관리, SaaS 관리 화면, 결제 후 권한 부여 같은 업무에 잘 맞습니다. 콘텐츠 사이트에서는 slug와 공개 상태, SaaS에서는 tenant 조건, 결제 흐름에서는 idempotency와 audit log가 핵심입니다.

Masa가 작은 블로그 API로 검증했을 때, Claude Code에게 migration 위험을 먼저 설명하게 하자 과한 cascade 삭제와 불필요한 author 필드 반환을 초기에 발견했습니다. “돌아가는 코드”보다 “왜 이 제약과 query가 필요한지 설명되는 코드”가 운영에 강합니다.

혼자 학습한다면 /products/의 자료부터 볼 수 있습니다. 팀의 DB review workflow와 Claude Code 운영 교육이 필요하다면 /training/이 맞습니다. 기존 schema나 AI가 만든 PR을 가져오면 migration 사고를 피하는 구체적인 개선점부터 볼 수 있습니다.

#Claude Code #Prisma #ORM #database #TypeScript
무료

무료 PDF: Claude Code 치트시트

이메일을 입력하면 명령, 리뷰 습관, 안전한 워크플로를 정리한 PDF를 받을 수 있습니다.

개인정보를 안전하게 관리하며 스팸을 보내지 않습니다.

Masa

작성자 소개

Masa

Claude Code 실무 워크플로와 팀 도입을 검증하는 엔지니어입니다.