Drizzle ORM이란?
Drizzle ORM은 TypeScript 네이티브 ORM으로, SQL에 가까운 타입 안전 쿼리 빌더를 제공합니다. Prisma가 자체 스키마 언어와 코드 생성 방식을 취하는 반면, Drizzle은 TypeScript 코드 자체가 스키마이며 제로 코드 생성, 번들 크기 최소화, 서버리스 환경 최적화를 핵심 철학으로 삼습니다.
“If you know SQL, you know Drizzle” — SQL을 아는 개발자라면 별도 학습 없이 바로 사용할 수 있는 직관적 API가 최대 강점입니다. 이 글에서는 스키마 정의부터 관계형 쿼리, 마이그레이션, 성능 최적화까지 심화 분석합니다.
설치와 프로젝트 설정
# PostgreSQL 드라이버 기준
npm install drizzle-orm postgres
npm install -D drizzle-kit
# drizzle.config.ts
import { defineConfig } from 'drizzle-kit';
export default defineConfig({
schema: './src/db/schema/*',
out: './drizzle',
dialect: 'postgresql',
dbCredentials: {
url: process.env.DATABASE_URL!,
},
verbose: true,
strict: true,
});
DB 연결 설정:
// src/db/index.ts
import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
import * as schema from './schema';
const connection = postgres(process.env.DATABASE_URL!, {
max: 20, // 커넥션 풀 크기
idle_timeout: 20, // 유휴 커넥션 타임아웃 (초)
connect_timeout: 10,
});
export const db = drizzle(connection, { schema, logger: true });
스키마 정의: TypeScript가 곧 스키마
Drizzle의 가장 큰 차별점은 TypeScript 코드로 직접 스키마를 정의하는 것입니다:
// src/db/schema/users.ts
import {
pgTable, serial, varchar, text, timestamp,
boolean, integer, pgEnum, uniqueIndex, index
} from 'drizzle-orm/pg-core';
import { relations } from 'drizzle-orm';
// Enum 정의
export const userRoleEnum = pgEnum('user_role', ['admin', 'user', 'moderator']);
// 테이블 정의
export const users = pgTable('users', {
id: serial('id').primaryKey(),
email: varchar('email', { length: 255 }).notNull().unique(),
name: varchar('name', { length: 100 }).notNull(),
role: userRoleEnum('role').default('user').notNull(),
bio: text('bio'),
isActive: boolean('is_active').default(true).notNull(),
loginCount: integer('login_count').default(0).notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
}, (table) => ({
// 복합 인덱스
emailIdx: uniqueIndex('email_idx').on(table.email),
roleActiveIdx: index('role_active_idx').on(table.role, table.isActive),
}));
// 타입 추론 — 코드 생성 불필요!
export type User = typeof users.$inferSelect;
export type NewUser = typeof users.$inferInsert;
$inferSelect와 $inferInsert는 테이블 정의에서 자동으로 TypeScript 타입을 추론합니다. Prisma처럼 별도 generate 명령이 필요 없습니다.
관계 정의와 Relational Query
// src/db/schema/posts.ts
export const posts = pgTable('posts', {
id: serial('id').primaryKey(),
title: varchar('title', { length: 255 }).notNull(),
content: text('content').notNull(),
authorId: integer('author_id').notNull()
.references(() => users.id, { onDelete: 'cascade' }),
publishedAt: timestamp('published_at'),
createdAt: timestamp('created_at').defaultNow().notNull(),
});
export const comments = pgTable('comments', {
id: serial('id').primaryKey(),
body: text('body').notNull(),
postId: integer('post_id').notNull()
.references(() => posts.id, { onDelete: 'cascade' }),
authorId: integer('author_id').notNull()
.references(() => users.id),
createdAt: timestamp('created_at').defaultNow().notNull(),
});
// 관계 정의
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),
}));
export const commentsRelations = relations(comments, ({ one }) => ({
post: one(posts, {
fields: [comments.postId],
references: [posts.id],
}),
author: one(users, {
fields: [comments.authorId],
references: [users.id],
}),
}));
Relational Query API로 중첩 관계를 타입 안전하게 조회합니다:
// 사용자 + 게시글 + 댓글을 한번에 조회
const usersWithPosts = await db.query.users.findMany({
where: eq(users.isActive, true),
columns: {
id: true,
name: true,
email: true,
},
with: {
posts: {
columns: { id: true, title: true, publishedAt: true },
where: isNotNull(posts.publishedAt),
orderBy: [desc(posts.publishedAt)],
limit: 5,
with: {
comments: {
columns: { id: true, body: true },
limit: 3,
with: {
author: {
columns: { name: true },
},
},
},
},
},
},
limit: 10,
});
// 반환 타입이 완전히 추론됨 — IDE 자동완성 지원
SQL 스타일 쿼리 빌더
Drizzle의 쿼리 빌더는 SQL 구문과 거의 1:1로 대응합니다:
import { eq, and, or, gt, like, sql, inArray, between, count, avg, sum } from 'drizzle-orm';
// SELECT with JOIN
const result = await db
.select({
userName: users.name,
postTitle: posts.title,
commentCount: count(comments.id),
})
.from(users)
.leftJoin(posts, eq(users.id, posts.authorId))
.leftJoin(comments, eq(posts.id, comments.postId))
.where(
and(
eq(users.isActive, true),
or(
like(users.name, '%kim%'),
gt(users.loginCount, 10)
)
)
)
.groupBy(users.name, posts.title)
.having(gt(count(comments.id), 5))
.orderBy(desc(count(comments.id)))
.limit(20);
// 서브쿼리
const subquery = db
.select({ authorId: posts.authorId, postCount: count().as('post_count') })
.from(posts)
.groupBy(posts.authorId)
.as('post_counts');
const activeAuthors = await db
.select({
name: users.name,
postCount: subquery.postCount,
})
.from(users)
.innerJoin(subquery, eq(users.id, subquery.authorId))
.where(gt(subquery.postCount, 10));
INSERT / UPDATE / DELETE 패턴
// 단일 INSERT + RETURNING
const [newUser] = await db
.insert(users)
.values({
email: 'dev@example.com',
name: 'Developer',
role: 'user',
})
.returning();
// 벌크 INSERT
await db.insert(posts).values([
{ title: 'Post 1', content: 'Content 1', authorId: newUser.id },
{ title: 'Post 2', content: 'Content 2', authorId: newUser.id },
]);
// UPSERT (ON CONFLICT)
await db
.insert(users)
.values({ email: 'dev@example.com', name: 'Updated Name', role: 'admin' })
.onConflictDoUpdate({
target: users.email,
set: {
name: sql`excluded.name`,
updatedAt: new Date(),
},
});
// UPDATE with 조건
await db
.update(users)
.set({ loginCount: sql`${users.loginCount} + 1`, updatedAt: new Date() })
.where(eq(users.id, 1));
// DELETE
await db
.delete(comments)
.where(
and(
eq(comments.authorId, 1),
lt(comments.createdAt, new Date('2025-01-01'))
)
);
트랜잭션
Drizzle의 트랜잭션은 콜백 패턴으로 간결합니다:
// 기본 트랜잭션
const result = await db.transaction(async (tx) => {
const [user] = await tx
.insert(users)
.values({ email: 'new@example.com', name: 'New User' })
.returning();
const [post] = await tx
.insert(posts)
.values({ title: 'First Post', content: 'Hello!', authorId: user.id })
.returning();
return { user, post };
});
// 중첩 트랜잭션 (Savepoint)
await db.transaction(async (tx) => {
await tx.insert(users).values({ email: 'a@test.com', name: 'A' });
try {
await tx.transaction(async (nestedTx) => {
await nestedTx.insert(users).values({ email: 'b@test.com', name: 'B' });
throw new Error('rollback nested only');
});
} catch {
// 중첩 트랜잭션만 롤백, 외부 트랜잭션은 유지
}
// user A는 커밋됨
});
// 격리 수준 설정
await db.transaction(async (tx) => {
// ...
}, {
isolationLevel: 'serializable',
accessMode: 'read write',
});
마이그레이션
# 마이그레이션 파일 생성 (스키마 diff 기반)
npx drizzle-kit generate
# 마이그레이션 적용
npx drizzle-kit migrate
# DB를 스키마에 바로 동기화 (개발용)
npx drizzle-kit push
# 현재 DB 상태를 스키마로 역추출
npx drizzle-kit pull
# GUI로 데이터 확인
npx drizzle-kit studio
drizzle-kit generate는 현재 스키마 코드와 마지막 마이그레이션 상태를 비교하여 SQL diff를 자동 생성합니다. 프로덕션에서는 반드시 generate → migrate 워크플로를 사용하세요.
Prepared Statement와 성능
// Prepared Statement — 반복 쿼리 성능 최적화
const getUserByEmail = db
.select()
.from(users)
.where(eq(users.email, sql.placeholder('email')))
.prepare('get_user_by_email');
// 실행 시 파라미터만 바인딩
const user = await getUserByEmail.execute({ email: 'dev@example.com' });
// 동적 쿼리 빌드
function buildUserQuery(filters: UserFilters) {
const conditions = [];
if (filters.role) conditions.push(eq(users.role, filters.role));
if (filters.isActive !== undefined) conditions.push(eq(users.isActive, filters.isActive));
if (filters.search) conditions.push(like(users.name, `%${filters.search}%`));
if (filters.createdAfter) conditions.push(gt(users.createdAt, filters.createdAfter));
return db
.select()
.from(users)
.where(conditions.length ? and(...conditions) : undefined)
.orderBy(desc(users.createdAt))
.limit(filters.limit ?? 20)
.offset(filters.offset ?? 0);
}
Prisma vs Drizzle 비교
- 스키마 정의 — Prisma: 자체 .prisma 파일 / Drizzle: TypeScript 코드
- 코드 생성 — Prisma: prisma generate 필수 / Drizzle: 불필요 (타입 추론)
- 번들 크기 — Prisma: ~10MB+ (엔진 포함) / Drizzle: ~50KB
- 서버리스 — Prisma: 콜드 스타트 이슈 / Drizzle: 즉시 시작
- SQL 제어 — Prisma: 추상화 우선 / Drizzle: SQL 1:1 대응
- 관계 쿼리 — Prisma: include/select / Drizzle: with (유사한 사용감)
- 학습 곡선 — Prisma: 자체 문법 학습 / Drizzle: SQL 지식으로 충분
관련 글: Prisma ORM 실전 심화 가이드에서 Prisma의 접근 방식을, TypeORM Transaction 심화에서 TypeORM 트랜잭션 패턴을 비교해보세요.
마무리
Drizzle ORM은 SQL을 아는 개발자에게 최적화된 TypeScript ORM입니다. 제로 코드 생성으로 빠른 개발 사이클, 50KB 미만의 번들 크기로 서버리스 최적화, SQL과 1:1 대응하는 직관적 API — 특히 Cloudflare Workers, Vercel Edge Functions 같은 엣지 런타임 환경에서 Prisma의 한계를 느꼈다면 Drizzle은 강력한 대안입니다.