Tips & Tricks (업데이트: 2026. 6. 2.)

Claude Code로 Drizzle ORM 구현하기: PostgreSQL, 마이그레이션, Zod, CI

Claude Code로 Drizzle ORM을 구현하며 schema, migration, seed, transaction, Zod, CI 검증까지 정리합니다.

Claude Code로 Drizzle ORM 구현하기: PostgreSQL, 마이그레이션, Zod, CI

Drizzle ORM은 TypeScript로 데이터베이스 schema를 정의하고, SQL에 가까운 문법으로 타입 안전한 쿼리를 작성하게 해 주는 ORM입니다. ORM은 애플리케이션 코드와 데이터베이스 사이의 작업을 연결하는 도구입니다. Drizzle은 추상화를 크게 숨기기보다 SQL의 모양을 유지하므로, Claude Code가 schema와 query를 읽고 설명하기 좋습니다.

하지만 데이터베이스 작업은 겉보기보다 위험합니다. schema 파일이 컴파일되어도 migration SQL이 위험할 수 있습니다. seed가 한 번은 성공하고 두 번째 실행에서 unique 제약에 걸릴 수 있습니다. transaction 안에서 이메일이나 webhook을 기다리면 DB 연결을 오래 잡아 둘 수 있습니다. 그래서 Claude Code에는 schema만 요청하지 말고 migration, query, seed, Zod 검증, CI까지 한 묶음으로 요청해야 합니다.

공식 문서는 Drizzle ORM docs, Drizzle Kit docs, Transactions, Drizzle Zod docs를 기준으로 확인하세요. Claude Code 자체는 공식 문서를 봅니다. 내부 글로는 Prisma ORM 가이드, database migration, Zod validation을 함께 보면 비교가 쉽습니다.

전체 흐름

Claude Code에 줄 프롬프트는 구체적이어야 합니다.

Drizzle ORM으로 블로그 API의 DB 계층을 구현해 주세요.

전제:
- PostgreSQL
- drizzle-orm, drizzle-kit, node-postgres
- User, Post, Category, Comment, AuditLog
- Post status는 draft, published, archived
- email과 slug는 unique
- 목록은 status, publishedAt, author, category, search로 필터링
- Post 삭제 시 Comment와 중간 테이블만 cascade
- User 삭제로 Post가 삭제되면 안 됨

schema, drizzle.config.ts, query, transaction, seed, Zod 검증, CI 설정을 함께 출력해 주세요.
flowchart LR
  A["요구사항"] --> B["schema.ts"]
  B --> C["drizzle-kit generate"]
  C --> D["SQL migration review"]
  D --> E["query, transaction, seed"]
  E --> F["Zod validation"]
  F --> G["CI checks"]

schema와 migration

아래 schema는 실제 서비스에서 자주 문제가 되는 부분을 포함합니다. 삭제 규칙, unique, index를 Claude Code가 왜 선택했는지 설명하게 하세요.

// db/schema.ts
import { relations } from "drizzle-orm";
import {
  boolean,
  index,
  integer,
  jsonb,
  pgEnum,
  pgTable,
  primaryKey,
  text,
  timestamp,
  uniqueIndex,
  uuid,
  varchar,
} from "drizzle-orm/pg-core";

export const postStatus = pgEnum("post_status", ["draft", "published", "archived"]);

export const users = pgTable(
  "users",
  {
    id: uuid("id").defaultRandom().primaryKey(),
    email: varchar("email", { length: 255 }).notNull(),
    name: varchar("name", { length: 120 }).notNull(),
    role: varchar("role", { length: 40 }).default("editor").notNull(),
    createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
    updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
  },
  (table) => [
    uniqueIndex("users_email_unique").on(table.email),
    index("users_role_idx").on(table.role),
  ],
);

export const categories = pgTable(
  "categories",
  {
    id: uuid("id").defaultRandom().primaryKey(),
    slug: varchar("slug", { length: 120 }).notNull(),
    name: varchar("name", { length: 120 }).notNull(),
  },
  (table) => [uniqueIndex("categories_slug_unique").on(table.slug)],
);

export const posts = pgTable(
  "posts",
  {
    id: uuid("id").defaultRandom().primaryKey(),
    slug: varchar("slug", { length: 160 }).notNull(),
    title: varchar("title", { length: 160 }).notNull(),
    body: text("body").notNull(),
    status: postStatus("status").default("draft").notNull(),
    authorId: uuid("author_id").notNull().references(() => users.id, { onDelete: "restrict" }),
    viewCount: integer("view_count").default(0).notNull(),
    featured: boolean("featured").default(false).notNull(),
    publishedAt: timestamp("published_at", { withTimezone: true }),
    createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
    updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
  },
  (table) => [
    uniqueIndex("posts_slug_unique").on(table.slug),
    index("posts_status_published_at_idx").on(table.status, table.publishedAt),
    index("posts_author_id_idx").on(table.authorId),
  ],
);

export const postCategories = pgTable(
  "post_categories",
  {
    postId: uuid("post_id").notNull().references(() => posts.id, { onDelete: "cascade" }),
    categoryId: uuid("category_id").notNull().references(() => categories.id, { onDelete: "cascade" }),
  },
  (table) => [primaryKey({ columns: [table.postId, table.categoryId] })],
);

export const comments = pgTable(
  "comments",
  {
    id: uuid("id").defaultRandom().primaryKey(),
    postId: uuid("post_id").notNull().references(() => posts.id, { onDelete: "cascade" }),
    authorId: uuid("author_id").notNull().references(() => users.id, { onDelete: "restrict" }),
    body: text("body").notNull(),
    createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
  },
  (table) => [
    index("comments_post_created_at_idx").on(table.postId, table.createdAt),
    index("comments_author_id_idx").on(table.authorId),
  ],
);

export const auditLogs = pgTable(
  "audit_logs",
  {
    id: uuid("id").defaultRandom().primaryKey(),
    action: varchar("action", { length: 80 }).notNull(),
    targetId: uuid("target_id").notNull(),
    metadata: jsonb("metadata").$type<Record<string, unknown>>(),
    createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
  },
  (table) => [index("audit_logs_action_created_at_idx").on(table.action, table.createdAt)],
);

export const usersRelations = relations(users, ({ many }) => ({ posts: many(posts), comments: many(comments) }));
export const postsRelations = relations(posts, ({ one, many }) => ({
  author: one(users, { fields: [posts.authorId], references: [users.id] }),
  comments: many(comments),
  categories: many(postCategories),
}));
export const categoriesRelations = relations(categories, ({ many }) => ({ posts: many(postCategories) }));
export const postCategoriesRelations = relations(postCategories, ({ one }) => ({
  post: one(posts, { fields: [postCategories.postId], references: [posts.id] }),
  category: one(categories, { fields: [postCategories.categoryId], references: [categories.id] }),
}));
export const commentsRelations = relations(comments, ({ one }) => ({
  post: one(posts, { fields: [comments.postId], references: [posts.id] }),
  author: one(users, { fields: [comments.authorId], references: [users.id] }),
}));
// drizzle.config.ts
import "dotenv/config";
import { defineConfig } from "drizzle-kit";

export default defineConfig({
  schema: "./db/schema.ts",
  out: "./drizzle",
  dialect: "postgresql",
  dbCredentials: { url: process.env.DATABASE_URL! },
  verbose: true,
  strict: true,
});
npm run db:generate
npm run db:check
npm run db:migrate

migration SQL에서는 DROP, 기존 데이터가 있는 테이블의 NOT NULL 추가, 너무 넓은 cascade, 빠진 unique/index를 봅니다. CI가 통과해도 production data에 안전하다는 뜻은 아닙니다.

query, transaction, seed

// db/client.ts
import "dotenv/config";
import { drizzle } from "drizzle-orm/node-postgres";
import { Pool } from "pg";
import * as schema from "./schema";

export const pool = new Pool({ connectionString: process.env.DATABASE_URL });
export const db = drizzle(pool, { schema });
// db/posts.ts
import { and, desc, eq, ilike, sql } from "drizzle-orm";
import { z } from "zod";
import { db } from "./client";
import { auditLogs, categories, comments, postCategories, posts, users } from "./schema";
import { createPostInputSchema } from "./validation";

type CreatePostInput = z.infer<typeof createPostInputSchema>;

export async function createPost(input: CreatePostInput) {
  const data = createPostInputSchema.parse(input);

  return db.transaction(async (tx) => {
    const [post] = await tx.insert(posts).values({
      slug: data.slug,
      title: data.title,
      body: data.body,
      authorId: data.authorId,
    }).returning();

    for (const slug of data.categorySlugs) {
      const [category] = await tx.insert(categories)
        .values({ slug, name: slug })
        .onConflictDoUpdate({ target: categories.slug, set: { name: slug } })
        .returning();
      await tx.insert(postCategories)
        .values({ postId: post.id, categoryId: category.id })
        .onConflictDoNothing();
    }

    await tx.insert(auditLogs).values({
      action: "post.create",
      targetId: post.id,
      metadata: { slug: post.slug },
    });

    return post;
  });
}

export async function listPublishedPosts(params: { page?: number; perPage?: number; search?: string } = {}) {
  const page = Math.max(params.page ?? 1, 1);
  const perPage = Math.min(Math.max(params.perPage ?? 20, 1), 50);
  const where = params.search
    ? and(eq(posts.status, "published"), ilike(posts.title, `%${params.search}%`))
    : eq(posts.status, "published");

  const [items, [{ total }]] = await Promise.all([
    db.select({
      id: posts.id,
      slug: posts.slug,
      title: posts.title,
      publishedAt: posts.publishedAt,
      authorName: users.name,
      commentCount: sql<number>`count(${comments.id})::int`,
    })
      .from(posts)
      .innerJoin(users, eq(posts.authorId, users.id))
      .leftJoin(comments, eq(comments.postId, posts.id))
      .where(where)
      .groupBy(posts.id, posts.slug, posts.title, posts.publishedAt, users.name)
      .orderBy(desc(posts.publishedAt), desc(posts.createdAt))
      .limit(perPage)
      .offset((page - 1) * perPage),
    db.select({ total: sql<number>`count(*)::int` }).from(posts).where(where),
  ]);

  return { items, pagination: { page, perPage, total, totalPages: Math.ceil(total / perPage) } };
}

seed는 반복 실행 가능해야 합니다.

// db/seed.ts
import { db, pool } from "./client";
import { categories, postCategories, posts, users } from "./schema";

async function main() {
  const [user] = await db.insert(users)
    .values({ email: "masa@example.com", name: "Masa", role: "admin" })
    .onConflictDoUpdate({ target: users.email, set: { name: "Masa", role: "admin", updatedAt: new Date() } })
    .returning();

  const [category] = await db.insert(categories)
    .values({ slug: "drizzle", name: "Drizzle ORM" })
    .onConflictDoUpdate({ target: categories.slug, set: { name: "Drizzle ORM" } })
    .returning();

  const [post] = await db.insert(posts)
    .values({
      slug: "claude-code-drizzle-demo",
      title: "Claude Code Drizzle demo",
      body: "A seeded post for local verification.",
      status: "published",
      authorId: user.id,
      publishedAt: new Date(),
    })
    .onConflictDoUpdate({ target: posts.slug, set: { title: "Claude Code Drizzle demo", updatedAt: new Date() } })
    .returning();

  await db.insert(postCategories).values({ postId: post.id, categoryId: category.id }).onConflictDoNothing();
}

main().finally(async () => pool.end());

Zod와 CI

API 입력은 TypeScript 타입만으로 안전하지 않습니다. Drizzle schema에서 Zod schema를 만들고, API 입력에 맞게 필요한 필드만 남깁니다.

// db/validation.ts
import { createInsertSchema } from "drizzle-orm/zod";
import { z } from "zod";
import { posts } from "./schema";

export const createPostInputSchema = createInsertSchema(posts, {
  slug: (schema) => schema.min(3).max(160).regex(/^[a-z0-9-]+$/),
  title: (schema) => schema.min(1).max(160),
  body: (schema) => schema.min(50),
})
  .pick({ slug: true, title: true, body: true, authorId: true })
  .extend({
    categorySlugs: z.array(z.string().min(1).max(120)).min(1).max(5),
  });
name: drizzle
on:
  pull_request:
jobs:
  db:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:16
        env:
          POSTGRES_USER: app
          POSTGRES_PASSWORD: app
          POSTGRES_DB: app_test
        ports:
          - 5432:5432
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5
    env:
      DATABASE_URL: postgresql://app:app@localhost:5432/app_test
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: npm
      - run: npm ci
      - run: npm run db:generate
      - run: npm run db:check
      - run: npm run db:migrate
      - run: npm run db:seed
      - run: npm run typecheck
      - run: npm test

활용 사례와 실수

사례확인할 점Claude Code에 줄 지시
콘텐츠 CMSslug, 공개 상태, 공개일 index각 index가 어떤 화면에 쓰이는지 설명
SaaS 관리자tenant, 권한, audit log사용자 삭제로 비즈니스 데이터 삭제 금지
교육 플랫폼진도, 접근 권한, transaction진도와 audit log를 일관되게 저장
수익형 콘텐츠CTA, 상품 링크, 읽기 완료analytics table 추가 시 기존 제약 유지

자주 나는 실수는 migration SQL을 읽지 않는 것, seed를 한 번만 실행 가능한 코드로 두는 것, transaction 안에서 외부 API를 호출하는 것, Zod를 DB 제약의 대체물로 보는 것, pagination 제한을 두지 않는 것, Claude Code가 관련 없는 migration까지 수정하게 두는 것입니다.

팀에서 이 흐름을 실제 저장소에 적용하려면 영어 Claude Code training and consultation이 출발점으로 좋습니다. 개인 학습이라면 Supabase integrationCI/CD setup을 함께 읽고, 이 글의 prompt를 자신의 테이블로 바꿔 보세요.

작은 블로그 API에서 이 방식을 시험했을 때 가장 효과가 컸던 부분은 Claude Code에게 TypeScript가 아니라 생성된 SQL을 설명하게 한 것이었습니다. 첫 출력은 삭제 규칙이 조금 강했지만, onDelete, index, seed 멱등성을 따로 review하게 하자 훨씬 안전한 diff가 되었습니다. Drizzle ORM은 가볍고 SQL에 가깝기 때문에, Claude Code의 속도와 사람의 SQL review를 같이 써야 장점이 살아납니다.

#Claude Code #Drizzle ORM #database #TypeScript #SQL
무료

무료 PDF: Claude Code 치트시트

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

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

Masa

작성자 소개

Masa

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