본문으로 건너뛰기

Cursor에서 Drizzle ORM 개발: 완전한 가이드

Drizzle development in Cursor

Drizzle ORM은 무거운 런타임 오버헤드 없이 타입 안전 SQL 쿼리를 제공하는 가벼운 TypeScript 우선 ORM입니다. 전통적인 ORM과 달리 Drizzle은 컴파일 타임에 완전한 타입 안전성을 유지하면서 완전한 SQL 제어를 제공합니다. Drizzle은 통합 API로 PostgreSQL, MySQL 및 SQLite를 지원합니다. Cursor는 통합 AI 어시스턴트와 내장 TypeScript 언어 서버를 통해 Drizzle 개발에 이상적인 편집기입니다 -- 스키마 정의에 대한 완전한 IntelliSense를 제공하고, 쿼리 빌더를 자동 완성하며, 자연어 설명에서 완전한 스키마와 쿼리를 생성할 수 있습니다.

이 가이드는 Cursor에서의 완전한 Drizzle 개발 워크플로우를 다룹니다: 설치 및 설정, 스키마 정의, drizzle-kit으로 마이그레이션, 타입 안전 쿼리, 관계 및 AI 지원 쿼리 빌딩.

Cursor에서 Drizzle 설치 및 설정​

사전 요구 사항​

Drizzle은 Node.js 18 이상이 필요하며 PostgreSQL, MySQL 또는 SQLite와 함께 작동합니다. Node.js 버전을 확인하세요:

node --version
팁

Cursor의 내장 TypeScript 언어 서버는 Drizzle 스키마와 쿼리에 대한 실시간 타입 검사를 제공합니다. 코드를 실행하기도 전에 인라인으로 타입 오류를 볼 수 있으며, 이는 Drizzle의 강력한 타입 쿼리 빌더에 특히 유용합니다.

종속성 설치​

Drizzle ORM과 drizzle-kit CLI 도구를 설치합니다:

# PostgreSQL의 경우
npm install drizzle-orm pg
npm install drizzle-kit --save-dev

# MySQL의 경우
npm install drizzle-orm mysql2
npm install drizzle-kit --save-dev

# SQLite (better-sqlite3)의 경우
npm install drizzle-orm better-sqlite3
npm install drizzle-kit --save-dev

# SQLite (libSQL/Turso)의 경우
npm install drizzle-orm @libsql/client
npm install drizzle-kit --save-dev

.env에 데이터베이스 연결 문자열을 설정합니다:

# .env
DATABASE_URL="postgresql://user:password@localhost:5432/mydb"

drizzle-kit 구성​

프로젝트 루트에 drizzle.config.ts 파일을 생성합니다:

// drizzle.config.ts
import { defineConfig } from 'drizzle-kit';

export default defineConfig({
schema: './src/db/schema.ts',
out: './drizzle',
dialect: 'postgresql',
dbCredentials: {
url: process.env.DATABASE_URL!,
},
verbose: true,
strict: true,
});
경고

MySQL을 사용하는 경우 구성에서 dialect: 'mysql'을 설정하세요. SQLite를 사용하는 경우 dialect: 'sqlite'를 설정하세요. dialect 필드는 drizzle-kit이 마이그레이션을 위해 생성하는 SQL 방언을 결정합니다. 잘못된 방언은 데이터베이스에서 실패하는 마이그레이션 파일을 생성합니다.

데이터베이스 클라이언트 생성​

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

const pool = new Pool({
connectionString: process.env.DATABASE_URL!,
});

export const db = drizzle(pool, { schema });
export type DB = typeof db;
팁

항상 스키마 객체를 가져와서 drizzle() 생성자에 전달하세요. 이렇게 하면 완전한 타입 안전성으로 중첩 데이터 가져오기를 제공하는 관계형 쿼리 API(db.query.users.findMany())가 활성화됩니다.

스키마 정의​

Drizzle 스키마는 빌더 함수를 사용하여 TypeScript 파일로 정의됩니다. 각 함수는 SQL 열 타입에 매핑됩니다.

테이블 정의​

// src/db/schema.ts
import {
pgTable,
uuid,
varchar,
text,
boolean,
integer,
timestamp,
enum,
pgEnum,
} from 'drizzle-orm/pg-core';
import { relations } from 'drizzle-orm';

// Enums
export const userRoleEnum = pgEnum('user_role', ['admin', 'member', 'viewer']);

// Users 테이블
export const users = pgTable('users', {
id: uuid('id').defaultRandom().primaryKey(),
email: varchar('email', { length: 255 }).notNull().unique(),
name: varchar('name', { length: 100 }),
role: userRoleEnum('role').default('member').notNull(),
avatarUrl: text('avatar_url'),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
});

// Posts 테이블
export const posts = pgTable('posts', {
id: uuid('id').defaultRandom().primaryKey(),
title: varchar('title', { length: 200 }).notNull(),
content: text('content'),
published: boolean('published').default(false).notNull(),
viewCount: integer('view_count').default(0).notNull(),
authorId: uuid('author_id').references(() => users.id, { onDelete: 'cascade' }),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
});

// Profiles 테이블
export const profiles = pgTable('profiles', {
id: uuid('id').defaultRandom().primaryKey(),
bio: text('bio'),
userId: uuid('user_id').references(() => users.id, { onDelete: 'cascade' }).unique(),
createdAt: timestamp('created_at').defaultNow().notNull(),
});

일반적인 열 타입 (PostgreSQL)​

Drizzle 함수SQL 타입참고
uuid()UUID자동 생성에 .defaultRandom() 사용
varchar({ length: n })VARCHAR(n)가변 길이 문자열
text()TEXT무제한 길이 문자열
integer()INTEGER32비트 정수
boolean()BOOLEAN참/거짓
timestamp()TIMESTAMP자동 채우기에 .defaultNow() 사용
jsonb()JSONB구조화된 데이터
serial()SERIAL자동 증가 정수
pgEnum()ENUMPostgreSQL enum 타입
decimal()DECIMAL고정 소수점 숫자
팁

Cursor에게 요청하세요: "User, Post, Comment, Tag 모델이 있는 PostgreSQL용 Drizzle 스키마를 생성해줘. UUID 기본 키를 사용하고, 타임스탬프를 포함하고, 1:n 및 m:n 관계를 설정해줘." Cursor가 올바른 임포트와 관계 정의가 포함된 검증된 TypeScript 스키마를 생성합니다.

관계 정의​

Drizzle 관계는 relations() 함수를 사용하여 테이블과 별도로 정의됩니다. 이는 데이터베이스 스키마에 영향을 주지 않습니다 -- 관계형 쿼리 API만 구성합니다:

// src/db/schema.ts (계속)
import { relations } from 'drizzle-orm';

export const usersRelations = relations(users, ({ one, many }) => ({
profile: one(profiles, {
fields: [users.id],
references: [profiles.userId],
}),
posts: many(posts),
}));

export const postsRelations = relations(posts, ({ one, many }) => ({
author: one(users, {
fields: [posts.authorId],
references: [users.id],
}),
comments: many(comments),
}));

export const profilesRelations = relations(profiles, ({ one }) => ({
user: one(users, {
fields: [profiles.userId],
references: [users.id],
}),
}));

export const comments = pgTable('comments', {
id: uuid('id').defaultRandom().primaryKey(),
content: text('content').notNull(),
authorId: uuid('author_id').references(() => users.id, { onDelete: 'cascade' }),
postId: uuid('post_id').references(() => posts.id, { onDelete: 'cascade' }),
createdAt: timestamp('created_at').defaultNow().notNull(),
});

export const commentsRelations = relations(comments, ({ one }) => ({
author: one(users, {
fields: [comments.authorId],
references: [users.id],
}),
post: one(posts, {
fields: [comments.postId],
references: [posts.id],
}),
}));
경고

Drizzle 관계는 쿼리 API 전용이며 데이터베이스에 외래 키 제약 조건을 생성하지 않습니다. 실제 외래 키는 .references()를 사용하여 테이블 정의에서 정의됩니다. relations()에 관계를 정의했지만 테이블에 .references()를 잊은 경우, 관계형 쿼리는 작동하지만 데이터베이스는 참조 무결성을 강제하지 않습니다.

drizzle-kit으로 마이그레이션​

drizzle-kit은 스키마 정의와 현재 데이터베이스 상태를 비교하여 SQL 마이그레이션 파일을 생성합니다.

마이그레이션 생성​

npx drizzle-kit generate

이것은 drizzle/ 디렉토리(drizzle.config.ts에 구성된 대로)에 SQL 마이그레이션 파일을 생성합니다:

-- drizzle/0000_initial_migration.sql
CREATE TYPE "user_role" AS ENUM('admin', 'member', 'viewer');

CREATE TABLE "users" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"email" varchar(255) NOT NULL,
"name" varchar(100),
"role" "user_role" DEFAULT 'member' NOT NULL,
"avatar_url" text,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL,
CONSTRAINT "users_email_unique" UNIQUE("email")
);

CREATE TABLE "posts" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"title" varchar(200) NOT NULL,
"content" text,
"published" boolean DEFAULT false NOT NULL,
"view_count" integer DEFAULT 0 NOT NULL,
"author_id" uuid,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);

ALTER TABLE "posts" ADD COLUMN "author_id" uuid REFERENCES "users"("id") ON DELETE CASCADE;

마이그레이션 적용​

npx drizzle-kit migrate

이것은 보류 중인 모든 마이그레이션 파일을 순서대로 데이터베이스에 적용합니다.

팁

스키마 변경 후마다 npx drizzle-kit generate를 사용하고, migrate를 실행하기 전에 생성된 SQL을 검토하세요. Drizzle의 마이그레이션 생성은 훌륭하지만 완벽하지 않습니다 -- 특히 열 이름 변경이나 타입 변경과 같은 복잡한 작업에서는 SQL이 의도와 일치하는지 항상 확인하세요.

스키마 변경 직접 푸시​

빠른 프로토타이핑을 위해 마이그레이션 파일을 생성하지 않고 스키마 변경을 직접 데이터베이스에 푸시할 수 있습니다:

npx drizzle-kit push

이것은 스키마를 데이터베이스와 즉시 동기화합니다.

경고

drizzle-kit push는 마이그레이션 파일을 우회하고 변경 사항을 직접 적용합니다. 이는 개발에 유용하지만 프로덕션에서는 절대 사용하지 마세요. 버전화된 마이그레이션 기록을 유지하려면 프로덕션 배포에 항상 generate + migrate를 사용하세요.

마이그레이션 저널​

drizzle-kit은 적용된 마이그레이션을 추적하는 meta/_journal.json 파일을 유지합니다:

[
{
"version": "5",
"idx": 0,
"when": 1700000000000,
"tag": "initial_migration",
"breakpoints": true
}
]

타입 안전 쿼리​

Drizzle의 쿼리 빌더는 완전히 타입이 지정됩니다. 작성하는 모든 쿼리는 컴파일 타임에 검사되며, Cursor의 IntelliSense는 테이블 이름, 열 및 연산자에 대한 자동 완성을 제공합니다.

Select 쿼리​

import { eq, and, gte, lte, desc, asc, sql } from 'drizzle-orm';
import { db } from './client';
import { users, posts, profiles } from './schema';

// 특정 열 선택
const userEmails = await db
.select({ id: users.id, email: users.email, name: users.name })
.from(users);

// 필터링으로 Select
const publishedPosts = await db
.select()
.from(posts)
.where(eq(posts.published, true))
.orderBy(desc(posts.createdAt))
.limit(10);

// 여러 조건으로 Select
const recentPosts = await db
.select()
.from(posts)
.where(
and(
eq(posts.published, true),
gte(posts.createdAt, new Date('2024-01-01')),
lte(posts.createdAt, new Date('2024-12-31'))
)
)
.orderBy(desc(posts.viewCount), asc(posts.createdAt));

Joins​

// Inner Join: 게시물과 작성자
const postsWithAuthors = await db
.select({
postId: posts.id,
title: posts.title,
authorName: users.name,
authorEmail: users.email,
})
.from(posts)
.innerJoin(users, eq(posts.authorId, users.id))
.where(eq(posts.published, true));

// Left Join: 사용자와 선택적 프로필
const usersWithProfiles = await db
.select({
userId: users.id,
email: users.email,
bio: profiles.bio,
})
.from(users)
.leftJoin(profiles, eq(users.id, profiles.userId));

Insert 쿼리​

// 단일 레코드 삽입
const [newUser] = await db
.insert(users)
.values({
name: 'New User',
role: 'member',
})
.returning();

// 여러 레코드 삽입
const newPosts = await db
.insert(posts)
.values([
{ title: 'Post 1', content: 'Content 1', authorId: userId },
{ title: 'Post 2', content: 'Content 2', authorId: userId },
])
.returning({ id: posts.id, title: posts.title });

// 충돌 처리로 Insert (Upsert)
const [upsertedUser] = await db
.insert(users)
.values({ email: '[email protected]', name: 'Updated Name' })
.onConflictDoUpdate({
target: users.email,
set: { name: 'Updated Name', updatedAt: new Date() },
})
.returning();
팁

Cursor에게 요청하세요: "이메일이 존재하지 않으면 새 사용자를 삽입하고, 존재하면 이름과 avatarUrl을 업데이트하는 Drizzle Upsert 쿼리를 작성해줘. 삽입/업데이트된 레코드를 반환해줘." Cursor가 onConflictDoUpdate 구문을 올바르게 생성합니다.

Update 및 Delete​

// 레코드 업데이트
const [updatedPost] = await db
.update(posts)
.set({ title: 'Updated Title', viewCount: sql`${posts.viewCount} + 1` })
.where(eq(posts.id, postId))
.returning();

// 레코드 삭제
await db.delete(posts).where(eq(posts.authorId, deletedUserId));

// 조건으로 삭제
const deletedCount = await db
.delete(posts)
.where(and(eq(posts.published, false), lt(posts.createdAt, thirtyDaysAgo)))
.returning();

관계형 쿼리 API​

Drizzle의 관계형 쿼리 API는 중첩 데이터를 가져오는 선언적 방법을 제공합니다:

// 프로필과 게시물이 있는 사용자 가져오기
const userWithData = await db.query.users.findFirst({
where: eq(users.email, '[email protected]'),
with: {
profile: true,
posts: {
where: eq(posts.published, true),
orderBy: desc(posts.createdAt),
limit: 5,
with: {
comments: {
with: {
author: {
columns: { id: true, name: true, avatarUrl: true },
},
},
},
},
},
},
});

// 작성자와 댓글이 있는 모든 게시된 게시물 가져오기
const allPosts = await db.query.posts.findMany({
where: eq(posts.published, true),
orderBy: desc(posts.createdAt),
limit: 20,
with: {
author: {
columns: { id: true, name: true, email: true },
},
comments: {
orderBy: desc(comments.createdAt),
with: {
author: true,
},
},
},
});
경고

관계형 쿼리 API(db.query.*)는 스키마를 drizzle() 생성자에 전달해야 합니다: drizzle(pool, { schema }). 스키마가 없으면 db.query 네임스페이스가 정의되지 않고 모든 관계형 쿼리가 런타임 오류를 발생시킵니다.

집계 및 Group By​

import { count, sql } from 'drizzle-orm';

// 작성자당 게시물 수
const postCounts = await db
.select({
authorId: posts.authorId,
authorName: users.name,
postCount: count(posts.id),
})
.from(posts)
.innerJoin(users, eq(posts.authorId, users.id))
.where(eq(posts.published, true))
.groupBy(posts.authorId, users.name)
.orderBy(desc(count(posts.id)))
.limit(5);

// 합계 및 평균
const stats = await db
.select({
totalViews: sql<number>`sum(${posts.viewCount})`,
avgViews: sql<number>`avg(${posts.viewCount})`,
postCount: count(posts.id),
})
.from(posts)
.where(eq(posts.published, true));

트랜잭션​

Drizzle은 콜백 기반 및 문장 기반 트랜잭션을 모두 지원합니다:

// 콜백 기반 트랜잭션
const result = await db.transaction(async (tx) => {
const [newUser] = await tx
.insert(users)
.values({ email: '[email protected]', name: 'Author' })
.returning();

const [newPost] = await tx
.insert(posts)
.values({
title: 'First Post',
content: 'Hello',
authorId: newUser.id,
published: true,
})
.returning();

await tx
.insert(profiles)
.values({ bio: 'Author bio', userId: newUser.id })
.returning();

return { user: newUser, post: newPost };
});
팁

Cursor에게 요청하세요: "사용자를 생성하고, 워크스페이스에 멤버로 할당하고, 기본 프로젝트를 생성하는 Drizzle 트랜잭션을 작성해줘. 오류 발생 시 롤백해줘." Cursor가 적절한 오류 전파로 콜백 기반 트랜잭션을 생성합니다.

Cursor에서 AI 지원 쿼리 빌딩​

Cursor의 AI 어시스턴트는 자연어 설명에서 복잡한 Drizzle 쿼리, 스키마 정의 및 마이그레이션 전략을 생성할 수 있습니다.

프롬프트에서 쿼리 생성​

Cursor Chat(Ctrl+L)을 열고 필요한 것을 설명합니다:

Write a Drizzle ORM query to fetch the top 10 most-viewed published posts
from the last 30 days, including each post's author name and email, the
count of comments, and the count of likes. Order by view count descending.
Use the relational query API (db.query.posts.findMany).
const thirtyDaysAgo = new Date();
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);

const topPosts = await db.query.posts.findMany({
where: and(
eq(posts.published, true),
gte(posts.createdAt, thirtyDaysAgo)
),
orderBy: desc(posts.viewCount),
limit: 10,
with: {
author: {
columns: { id: true, name: true, email: true },
},
},
extras: {
commentCount: sql<number>`(
SELECT count(*) FROM comments WHERE comments.post_id = posts.id
)`.as('commentCount'),
likeCount: sql<number>`(
SELECT count(*) FROM likes WHERE likes.post_id = posts.id
)`.as('likeCount'),
},
});
팁

Cursor에게 Drizzle 쿼리 생성을 요청할 때, 쿼리 빌더 API(db.select().from()) 또는 관계형 쿼리 API(db.query.*.findMany()) 중 원하는 것을 지정하세요. 두 API는 서로 다른 구문과 장단점이 있습니다 -- 쿼리 빌더는 더 많은 SQL 제어를 제공하고 관계형 API는 더 선언적입니다.

설명에서 스키마 생성​

Generate a Drizzle schema for a multi-tenant SaaS application using
PostgreSQL. Include: Tenant, User (with tenantId), Subscription (with
plan enum: FREE/PRO/ENTERPRISE), ApiKey, and Webhook table. Use UUIDs,
timestamps, proper foreign keys with cascade delete, and add indexes on
tenantId columns.

AI 지원 스키마 마이그레이션​

스키마를 리팩토링할 때 Cursor에게 도움을 요청합니다:

I need to add a "soft delete" feature to my Drizzle schema. Add a
"deleted_at" timestamp column to the posts and comments tables that is
nullable. Then update my existing queries to filter out soft-deleted
records by default. Show me how to generate the migration.

Cursor가 다음을 수행합니다:

  1. 두 테이블에 deletedAt 열을 추가합니다.
  2. isNull(posts.deletedAt) 필터를 추가하도록 Select 쿼리를 업데이트합니다.
  3. drizzle-kit 마이그레이션 명령을 생성합니다.
경고

소프트 삭제 열을 추가할 때, deletedAt으로 필터링하지 않는 기존 쿼리는 소프트 삭제된 레코드를 반환합니다. 스키마 변경 후 Cursor에게 모든 쿼리를 감사하여 소프트 삭제된 데이터가 누출되지 않도록 요청하세요.

성능 및 모범 사례​

인덱싱 전략​

import { index } from 'drizzle-orm/pg-core';

export const posts = pgTable(
'posts',
{
id: uuid('id').defaultRandom().primaryKey(),
title: varchar('title', { length: 200 }).notNull(),
authorId: uuid('author_id').references(() => users.id),
published: boolean('published').default(false).notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
},
(table) => ({
authorIdx: index('posts_author_id_idx').on(table.authorId),
publishedIdx: index('posts_published_created_idx').on(
table.published,
table.createdAt
),
})
);

연결 풀링​

import { Pool } from 'pg';
import { drizzle } from 'drizzle-orm/node-postgres';

const pool = new Pool({
connectionString: process.env.DATABASE_URL!,
max: 20,
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 2000,
});

export const db = drizzle(pool, { schema, logger: true });
경고

서버리스 환경(Vercel, AWS Lambda)에서는 PgBouncer 같은 연결 풀러 또는 @neondatabase/serverless 같은 서버리스 드라이버를 사용하세요. 직접 PostgreSQL 연결은 각 함수 인스턴스가 자체 풀을 생성하여 데이터베이스의 연결 제한을 소진하므로 서버리스에서 잘 확장되지 않습니다.

Prepared Statements​

import { sql } from 'drizzle-orm';

const findPostsByAuthor = db
.select()
.from(posts)
.where(and(eq(posts.authorId, sql.placeholder('authorId')), eq(posts.published, true)))
.prepare('findPostsByAuthor');

const result = await findPostsByAuthor.execute({ authorId: userId });

결론​

Drizzle ORM과 Cursor의 조합은 강력하고 타입 안전한 데이터베이스 개발 경험을 만듭니다. TypeScript 네이티브 스키마 정의는 완전한 SQL 제어를 제공하고, Cursor의 언어 서버는 실시간으로 타입 오류를 잡습니다. drizzle-kit은 프로덕션을 위한 generate 및 migrate 명령, 빠른 프로토타이핑을 위한 push로 신뢰할 수 있는 마이그레이션 워크플로우를 제공합니다. 이중 쿼리 API -- 빌더 스타일 db.select().from()과 선언적 db.query.*.findMany() -- 를 통해 각 쿼리에 적절한 추상화 수준을 선택할 수 있습니다. Cursor의 AI 어시스턴트는 복잡한 도메인을 위한 완전한 스키마 생성부터 조인, 집계 및 트랜잭션이 있는 최적화된 쿼리 작성까지 모든 단계를 가속화합니다. TypeScript로 스키마를 정의하고, drizzle-kit을 구성하고, 스키마 진화에 generate와 migrate를 사용하고, Cursor가 타입 안전 쿼리를 작성하도록 하여 애플리케이션 로직에 집중하세요.