Claude Code로 Drizzle ORM 구현하기: PostgreSQL, 마이그레이션, Zod, CI
Claude Code로 Drizzle ORM을 구현하며 schema, migration, seed, transaction, 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에 줄 지시 |
|---|---|---|
| 콘텐츠 CMS | slug, 공개 상태, 공개일 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 integration과 CI/CD setup을 함께 읽고, 이 글의 prompt를 자신의 테이블로 바꿔 보세요.
작은 블로그 API에서 이 방식을 시험했을 때 가장 효과가 컸던 부분은 Claude Code에게 TypeScript가 아니라 생성된 SQL을 설명하게 한 것이었습니다. 첫 출력은 삭제 규칙이 조금 강했지만, onDelete, index, seed 멱등성을 따로 review하게 하자 훨씬 안전한 diff가 되었습니다. Drizzle ORM은 가볍고 SQL에 가깝기 때문에, Claude Code의 속도와 사람의 SQL review를 같이 써야 장점이 살아납니다.
무료 PDF: Claude Code 치트시트
이메일을 입력하면 명령, 리뷰 습관, 안전한 워크플로를 정리한 PDF를 받을 수 있습니다.
개인정보를 안전하게 관리하며 스팸을 보내지 않습니다.
작성자 소개
Masa
Claude Code 실무 워크플로와 팀 도입을 검증하는 엔지니어입니다.
관련 글
Claude Code 권한 세이프티 래더: 통제력을 잃지 않고 allow 넓히기
read-only에서 제한 편집, 검증 명령, deploy 확인까지 권한을 단계적으로 넓히는 방법.
Claude Code Small PR Proof Pack: 작은 PR을 리뷰 가능한 상태로 만드는 증거 세트
Claude Code의 작은 PR에 diff, 검증, 공개 URL, CTA 경로, rollback을 붙이는 실무 체크리스트.
Claude Code 커밋 전 리뷰 게이트: diff, 테스트, 공개 URL, CTA 확인
Claude Code 작업을 커밋하기 전에 diff 범위, build, 공개 URL, Gumroad 링크, 상담 CTA, 테스트 누락과 무관한 파일을 확인하는 방법입니다.