Drizzle ORM 완벽 가이드 | TypeScript·SQL·마이그레이션·Prisma 대안
이 글의 핵심
Drizzle ORM으로 타입 안전한 데이터베이스 작업을 하는 완벽 가이드. 스키마 정의, 쿼리, 관계, 마이그레이션, Prisma 비교까지 실전 예제로 정리. Drizzle·ORM·TypeScript 중심으로 설명합니다.
이 글의 핵심
솔직히 말하면, 나는 Prisma 쓰다가 Drizzle로 넘어온 쪽이야. “ORM은 편하다”는 건 맞는데, 프로젝트가 커질수록 쿼리가 꼬이고, 번들이 두꺼워지고, “이거 SQL로 그냥 쓰고 싶은데”가 반복됐거든. 그때 Drizzle이 답이었고, 지금은 스키마 정의·쿼리·관계·마이그레이션까지 이 흐름이 더 편하다. 아래는 그때 쓰던 코드 패턴이랑, Prisma에서 갈아탈 때 겪은 일도 같이 섞어서 정리해봤어.
왜 썼냐면: 예전 API 서버를 Prisma → Drizzle로 옮기면서, 느껴지는 지연(p95)은 꽤 줄었고(환경·인덱스에 따라 다름), 번들은 확실히 가벼워졌어. 숫자는 캠페인용으로 30%·50% 같은 말 쓰기 싫고, “체감으로 이긴다” 정도로만 말해둘게.
들어가며: “Prisma가 무겁다”는 느낌
Prisma가 나쁜 건 절대 아님. 다만 나한테는 이런 때 답답했어.
시나리오 1: join이 느릴 때 — Prisma 쪽에서 N+1 잡다가, 결국 queryRaw로 빠질 때가 많았어. Drizzle은 처음부터 SQL에 가깝게 짜서, “어디가 병목인지” 눈에 더 잘 들어왔어.
시나리오 2: 번들·콜드스타트 — 클라이언트/워커 쪽에 Prisma 끼면 부담이 큰 편이야. Drizzle은 그에 비해 가볍고, Edge 쪽 느낌이 좋아.
시나리오 3: SQL이 보고 싶을 때 — Prisma는 추상화가 친절한 대신, “진짜로 뭐 쏘는지” 숨는 순간이 있지. Drizzle은 SQL 감각을 그대로 가져가면서 타입만 얹는 느낌에 가깝다.
1. Drizzle ORM이란?
핵심 특징
Drizzle은 TypeScript 먼저인 경량 ORM이야. 문서도 그렇고, 커뮤니티 감도 “SQL 좋아하는 TS 개발자” 쪽.
왜 쓰냐면(내 기준):
- 가벼움 — Prisma 클라이언트랑은 체급이 다름. 100KB vs 몇 MB 얘기 자주 나오는데, “배포 산출물”에서 확실히 덜 괴롭힘.
- 성능 — “항상”이라고는 안 할게. DB·인덱스·쿼리에 따라 갈리니까. 다만 작성한 SQL에 가깝게 내려가는 느낌은 확실해.
- SQL 퍼스트 — ORM이 SQL을 감싸는 게 싫을 때, Drizzle이 덜 싫어.
- 타입 —
select결과 타입이 따라오는 게 꽤 잘 맞아. - Edge — Workers 같은 데 쓰기 좋다는 말, 나도 동의.
지원 DB(자주 쓰는 것만):
- PostgreSQL
- MySQL
- SQLite
- Neon 같은 서버리스 Postgres도 흔함
2. 설치 및 설정
설치
npm install drizzle-orm
npm install -D drizzle-kit
# PostgreSQL
npm install pg
npm install -D @types/pg
# MySQL
npm install mysql2
# SQLite
npm install better-sqlite3
스키마 정의
// src/db/schema.ts
import { pgTable, serial, text, timestamp, integer, boolean } from 'drizzle-orm/pg-core';
export const users = pgTable('users', {
id: serial('id').primaryKey(),
email: text('email').notNull().unique(),
name: text('name').notNull(),
createdAt: timestamp('created_at').defaultNow(),
});
export const posts = pgTable('posts', {
id: serial('id').primaryKey(),
title: text('title').notNull(),
content: text('content'),
published: boolean('published').default(false),
authorId: integer('author_id').references(() => users.id),
createdAt: timestamp('created_at').defaultNow(),
});
데이터베이스 연결
// src/db/index.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 });
3. CRUD 작업
Create
import { db } from './db';
import { users, posts } from './db/schema';
// 단일 생성
const user = await db.insert(users).values({
email: '[email protected]',
name: 'John Doe',
}).returning();
// 다중 생성
const newUsers = await db.insert(users).values([
{ email: '[email protected]', name: 'Jane' },
{ email: '[email protected]', name: 'Bob' },
]).returning();
Read
import { eq, and, or, like, gt } from 'drizzle-orm';
// 전체 조회
const allUsers = await db.select().from(users);
// 조건 조회
const user = await db
.select()
.from(users)
.where(eq(users.id, 1));
// 복잡한 조건
const filteredUsers = await db
.select()
.from(users)
.where(
and(
like(users.email, '%@example.com'),
gt(users.id, 10)
)
);
// 정렬 및 제한
const recentUsers = await db
.select()
.from(users)
.orderBy(users.createdAt)
.limit(10)
.offset(0);
Update
// 단일 업데이트
// 실행 예제
await db
.update(users)
.set({ name: 'John Smith' })
.where(eq(users.id, 1));
// 다중 업데이트
await db
.update(posts)
.set({ published: true })
.where(eq(posts.authorId, 1));
Delete
// 단일 삭제
await db
.delete(users)
.where(eq(users.id, 1));
// 조건 삭제
await db
.delete(posts)
.where(eq(posts.published, false));
4. 관계 (Relations)
스키마에 관계 정의
import { relations } from 'drizzle-orm';
export const usersRelations = relations(users, ({ many }) => ({
posts: many(posts),
}));
export const postsRelations = relations(posts, ({ one }) => ({
author: one(users, {
fields: [posts.authorId],
references: [users.id],
}),
}));
관계 쿼리
// 사용자와 포스트 함께 조회
const usersWithPosts = await db.query.users.findMany({
with: {
posts: true,
},
});
// 특정 사용자의 포스트
const userWithPosts = await db.query.users.findFirst({
where: eq(users.id, 1),
with: {
posts: {
where: eq(posts.published, true),
orderBy: posts.createdAt,
},
},
});
5. 마이그레이션
drizzle.config.ts
// drizzle.config.ts
import type { Config } from 'drizzle-kit';
export default {
schema: './src/db/schema.ts',
out: './drizzle',
driver: 'pg',
dbCredentials: {
connectionString: process.env.DATABASE_URL!,
},
} satisfies Config;
마이그레이션 생성 및 적용
# 마이그레이션 생성
npx drizzle-kit generate:pg
# 마이그레이션 적용
npx drizzle-kit push:pg
# Drizzle Studio (GUI)
npx drizzle-kit studio
Prisma 쓰다가 Drizzle로 갈아탈 때(내 썰)
한 번에 몽땅 바꾸는 건 비추야. DB는 그대로 두고, 레이어만 갈아끼우는 식이 현실적이거든. 내가 돌렸던 순서는 대략 이랬어.
-
Prisma 스키마는 참고용으로만 남기고 —
schema.prisma는 “진실”에서 한 발 짜르고, Drizzleschema.ts를 새 소스 오브 트루스로 뒀어. (동시에 고치면 지옥 열림) -
Drizzle 쪽에 테이블을 1:1로 옮김 — 컬럼명·null·FK는 DB에 맞게.
prisma db pull로 실제 DB 스냅샷 확인하고 Drizzle에 반영하는 게 덜 틀려. -
읽기부터 옮김 —
findMany/ 복잡한 조회부터 Drizzledb.query나select로 옮기고, 응답 DTO는 기존이랑 동일하게 맞춤. 그래야 프론트/테스트가 덜 울어. -
쓰기·트랜잭션 —
create/update는 멱등·제약(유니크 충돌) 같은 걸 다시 점검. Prisma가 살짝 떠먹여 주던 것도, Drizzle에선 “내가 안다” 느낌으로 돌아옴. -
마이그레이션 툴 전환 — Prisma Migrate 쓰던 팀이면, 배포 파이프라인에서 “누가
migrate돌리나”부터 정리해야 함. 나는drizzle-kit generate→ 스테이징 적용 → 프로덕션, 순서로 고정했어. -
Prisma는 점진적으로 제거 — import 범위 줄이고, 마지막에 패키지 uninstall.
prismaCLI는 스키마 백업용으로 잠깐 남겨둔 적도 있어.
막말로, Prisma → Drizzle은 “언어”가 바뀌는 수준이야. 문법이 비슷해 보여도, 트랜잭션/리레이션/에러 맛이 달라. 그래서 “한 방에 교체”보다, 읽기 경로부터 스트랭글하는 게 정신 건강에 좋다.
Drizzle이 더 나은 경우(편견 있음, 주의)
아래는 일반론이 아니라 나의 기준이야. 팀/제품/운영 방식에 따라 Prisma가 훨씬 맞는 경우도 많다.
- 쿼리를 직접 튜닝해야 하고, EXPLAIN 찍는 게 일상이면 Drizzle 쪽이 덜 막힌다. (반대로, ORM이 알아서 해주길 원하면 Prisma가 편할 수 있어.)
- Edge·가벼운 번들이 목표면 Drizzle 쪽이 유리한 경우가 많다. (Node 전용·마이그레이션 팀이 Prisma에 이미 익숙하면 그건 Prisma 쪽.)
- SQL 냄새를 포기하고 싶지 않을 때. Drizzle은 “SQL + 타입”에 가깝다.
- 제약: 팀이 Prisma Data Platform·뷰어·스튜디오에 이미 투자돼 있으면, 굳이 당장 옮길 이유는 약해질 수 있어. 나는 그런 락인이 없었음.
반대로 Prisma가 맞는 경우 — 빠른 CRUD, 스키마 언어로 팀을 통일하고 싶다, Prisma의 생태계(마이그레이션, 서버리스 DB 연동)에 이미 기대고 있다, 같은 것들. 둘 중 하나를 “신”이라고 부르지 말고, 비용(학습+운영) 대비로 고르면 된다.
6. 트랜잭션
await db.transaction(async (tx) => {
const user = await tx.insert(users).values({
email: '[email protected]',
name: 'John',
}).returning();
await tx.insert(posts).values({
title: 'First Post',
authorId: user[0].id,
});
});
7. Raw SQL
import { sql } from 'drizzle-orm';
// Raw SQL 실행
const result = await db.execute(sql`
SELECT * FROM users WHERE email LIKE ${'%@example.com'}
`);
// 타입 안전한 Raw SQL
const users = await db.execute<{ id: number; name: string }>(sql`
SELECT id, name FROM users WHERE id > ${10}
`);
8. Prisma vs Drizzle 비교
스키마 정의
// Prisma
model User {
id Int @id @default(autoincrement())
email String @unique
name String
posts Post[]
createdAt DateTime @default(now())
}
// Drizzle
export const users = pgTable('users', {
id: serial('id').primaryKey(),
email: text('email').notNull().unique(),
name: text('name').notNull(),
createdAt: timestamp('created_at').defaultNow(),
});
쿼리
// Prisma
// 변수 선언 및 초기화
const users = await prisma.user.findMany({
where: { email: { contains: '@example.com' } },
include: { posts: true },
});
// Drizzle
const users = await db.query.users.findMany({
where: like(users.email, '%@example.com'),
with: { posts: true },
});
성능·번들은 표 대신 말로
막 예쁘게 “단순 조회 5ms vs 2ms” 같은 표를 박아두기엔, 실제로는 측정 환경마다 수치가 널뛰기해서 오히려 기분 나빠. 내 경험으로만 말하면, 따닥따닥 쏘는 조회는 Drizzle 쪽이 대체로 수월했고(특히 SQL 모양이 내 손에 가까웠음), 번들은 Prisma를 워커/클라이언트 쪽에 끼울 땐 꽤 눈에 띄게 무거웠다. 숫자는 직접 p95 측정해보고, 팀이 설득되면 쓰는 쪽이 맞다고 본다.
9. 실전 예제: 블로그 API
읽기만 해보다가, “우리도 이런 API 한 번 짜봐야지” 할 때 쓰려고 만든 조각 예제야. 실제로는 권한·에러 응답·페이지 메타가 더 있겠지.
// src/db/schema.ts
import { pgTable, serial, text, timestamp, integer, boolean } from 'drizzle-orm/pg-core';
import { relations } from 'drizzle-orm';
export const users = pgTable('users', {
id: serial('id').primaryKey(),
email: text('email').notNull().unique(),
name: text('name').notNull(),
createdAt: timestamp('created_at').defaultNow(),
});
export const posts = pgTable('posts', {
id: serial('id').primaryKey(),
title: text('title').notNull(),
content: text('content'),
published: boolean('published').default(false),
authorId: integer('author_id').references(() => users.id),
createdAt: timestamp('created_at').defaultNow(),
updatedAt: timestamp('updated_at').defaultNow(),
});
export const comments = pgTable('comments', {
id: serial('id').primaryKey(),
content: text('content').notNull(),
postId: integer('post_id').references(() => posts.id),
authorId: integer('author_id').references(() => users.id),
createdAt: timestamp('created_at').defaultNow(),
});
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],
}),
}));
// src/api/posts.ts
import { db } from '../db';
import { posts, users, comments } from '../db/schema';
import { eq, desc } from 'drizzle-orm';
// 게시글 목록 (페이지네이션)
export async function getPosts(page: number = 1, limit: number = 10) {
const offset = (page - 1) * limit;
const [postList, total] = await Promise.all([
db.query.posts.findMany({
where: eq(posts.published, true),
with: {
author: {
columns: {
id: true,
name: true,
email: true,
},
},
},
orderBy: [desc(posts.createdAt)],
limit,
offset,
}),
db.select({ count: sql<number>`count(*)` })
.from(posts)
.where(eq(posts.published, true)),
]);
return {
posts: postList,
total: total[0].count,
page,
totalPages: Math.ceil(total[0].count / limit),
};
}
// 게시글 상세
export async function getPost(id: number) {
const post = await db.query.posts.findFirst({
where: eq(posts.id, id),
with: {
author: {
columns: {
id: true,
name: true,
email: true,
},
},
comments: {
with: {
author: {
columns: {
id: true,
name: true,
},
},
},
orderBy: [desc(comments.createdAt)],
},
},
});
if (!post) {
throw new Error('Post not found');
}
return post;
}
// 게시글 생성
export async function createPost(data: {
title: string;
content: string;
authorId: number;
}) {
const [post] = await db.insert(posts).values(data).returning();
return post;
}
// 게시글 수정
export async function updatePost(id: number, data: {
title?: string;
content?: string;
published?: boolean;
}) {
const [post] = await db
.update(posts)
.set({ ...data, updatedAt: new Date() })
.where(eq(posts.id, id))
.returning();
return post;
}
// 게시글 삭제
export async function deletePost(id: number) {
await db.delete(posts).where(eq(posts.id, id));
}
정리 및 체크리스트
핵심 요약(편견 30%)
- Drizzle — TypeScript·SQL 쪽 취향이면 끌리는 쪽. “ORM인데 SQL 같다”는 밈이 괜히 있는 건 아님.
- 가벼움 — Prisma랑은 번들/의존성 느낌이 다름. 워커/엣지 가면 체감 큼.
- 성능 — 숫자는 직접 재라. “항상 2배” 같은 말은 믿지 말고, 내 케이스에선 p95가 괜찮았음.
- SQL — 손이 가는 사람한테는 덜 답답할 때가 많음. 반대로 Prisma에 익숙한 팀은 러닝 커브 있을 수 있음.
- Edge — 쓰는 제품/런타임에 따라 다르지만, 내 경험으론 여기가 편한 편.
구현 체크리스트
- Drizzle + drizzle-kit 설치
-
schema짜기 (Prisma에서 오면: DB와 1:1 먼저 맞추기) -
db인스턴스 연결 - CRUD + 관계 쿼리
-
drizzle-kit로 마이그 (파이프라인 합의까지) - (Prisma → 전환 시) 읽기 경로부터, 마지막에 패키지 정리
같이 보면 좋은 글
- Prisma 완벽 가이드
- PostgreSQL 고급 가이드
- TypeScript 5 완벽 가이드
이 글에서 다루는 키워드
Drizzle, ORM, TypeScript, SQL, Database, PostgreSQL, MySQL
자주 묻는 질문 (FAQ)
Q. Drizzle vs Prisma, 뭘 써?
A. “낫다”는 없고, 팀 상황이지. SQL·번들·엣지에 예민하면 Drizzle 후보, Prisma에 이미 락인돼 있고 DX 통일이 중요하면 Prisma. 나는 전자 쪽.
Q. Prisma에서 Drizzle로 옮기기 빡센가?
A. 스키마는 한 번 다시 쓰는 셈이고, 쿼리는 개념은 비슷한데 디테일이 다름. 그래서 위에 쓴 것처럼 읽기 먼저, 점진적이 답. 한 방에 갈아엎으면 운이 나쁘면 며칠이 아니라 몇 주 갈릴 수 있어.
Q. Edge에서 쓸 수 있냐?
A. 워커/엣지 쪽 케이스 많아. Prisma는 제품/런타임에 따라 막히는 게 있고, Drizzle은 그에 비해 덜 껄끄러운 편(그래도 드라이버·DB 연결은 봐야 함).
Q. Drizzle Studio가 뭐야?
A. Prisma Studio 느낌의 뷰어. npx drizzle-kit studio로 띄움. 둘 다 “응급 덤프”용으로 쓰지 말자는 주의(운영 권한은 따로).
심화 부록: 구현·운영 관점
여기부터는 톤이 살짝 문서에 가깝다. 싫으면 이 섹션은 스킵해도 됨. 앞에 본문(「Drizzle ORM 완벽 가이드 | TypeScript·SQL·마이그레이션·Prisma 대안」)을, 운영이랑 장애 잡는 관점에서만 다시 압축한 거고, 입력 검증 → 핵심 연산 → 부작용(I/O·네트워크·동시성) → 관측으로 쪼개서 생각하면 디버깅이 빨라지는 케이스가 많다.
내부 동작과 핵심 메커니즘
flowchart TD A[입력·요청·이벤트] --> B[파싱·검증·디코딩] B --> C[핵심 연산·상태 전이] C --> D[부작용: I/O·네트워크·동시성] D --> E[결과·관측·저장]
sequenceDiagram participant C as 클라이언트/호출자 participant B as 경계(런타임·게이트웨이·프로세스) participant D as 의존성(API·DB·큐·파일) C->>B: 요청/이벤트 B->>D: 조회·쓰기·RPC D-->>B: 지연·부분 실패·재시도 가능 B-->>C: 응답 또는 오류(코드·상관 ID)
- 불변 조건(Invariant) — 버퍼 경계, 프로토콜 상태, 트랜잭션 격리, FD 상한 같은 거, 한 줄로 적어두면 나중에 싸울 상대가 줄어듦.
- 결정성 — 순수한 층이랑 시간·네트워크 타는 층이랑 갈라두면, 테스트·장애 분석이 쉬워짐.
- 경계 비용 — 직렬화, syscall, 락, GC, 캐시 미스, 의심 리스트에 넣고 하나씩 때려 맞춤.
- 백프레셔 — 생산 > 소비면 어디서 속도 줄일지(버퍼·큐) 정해두는 게 덜 터짐.
프로덕션 운영 패턴(표는 빼고, 질문만)
표로 정리하려다가, 이 글 스타일이랑 안 맞아서 걍 리스트로 둔다. 운영 볼 때 “이거 답해볼 수 있냐”만 체크해보면 돼.
- 관측성 — 상관 ID 붙냐, p95/p99랑 에러율, 타임아웃·재시도가 대시보드에 눈에 뜨냐.
- 안전성 — 입력 검증·권한·시크릿·감사 로그가 경로마다 일관되냐.
- 신뢰성 — 재시도는 멱등이랑 섞이면 안 터지냐, 백오프·서킷·DLQ 쓰냐.
- 성능 — N+1·풀·인덱스·캐시, 이거 “우리 스케일”에 맞냐.
- 배포 — 롤백 루틴, 카나리, 마이그 스텝, 피처 플래그 문서화돼 있냐.
- 용량 — 피크·디스크·FD·스레드 풀 상한, 가끔이라도 눈 감지 말고 봤냐.
스테이징은 데이터 양·RTT·동시성을 프로덕이랑 비슷하게 맞출수록, “왜 prod만?” 버그가 줄어드는 쪽.
확장 예시: 엔드투엔드 미니 시나리오
같은 주제(「Drizzle ORM 완벽 가이드 | TypeScript·SQL·마이그레이션·Prisma 대안」)를 배포·운영 흐름에 맞출 때 쓰는 체크리스트 느낌. 제품에 맞게 단계만 바꿔 쓰면 됨.
- 입력 계약 고정: 스키마·버전·최대 페이로드·타임아웃·에러 코드를 경계에 둔다.
- 핵심 경로 계측: 요청 ID, 단계별 지연, 외부 호출 결과 코드를 로그·메트릭·트레이스에서 한 흐름으로 본다.
- 실패 주입: 의존성 타임아웃·5xx·부분 데이터·락 대기를 스테이징에서 재현한다.
- 호환·롤백: 설정/마이그레이션/클라이언트 버전을 되돌릴 수 있는지 확인한다.
- 부하 후 검증: 피크 대비 p95/p99, 에러율, 리소스 상한, 알림 임계값을 점검한다.
handle(request):
ctx = newCorrelationId()
validated = validateSchema(request)
authorize(validated, ctx)
result = domainCore(validated)
persistOrEmit(result, idempotentKey)
recordMetrics(ctx, latency, outcome)
return result
문제 해결(Troubleshooting) — 이것도 표 대신
증상·원인·할 일을 한 줄씩만 적어봤다. (표 쓰기 싫어서)
- 간헐 터짐 — 레이스, 타임아웃, 외부 의존, DNS. 최소 재현 스크립트부터 만들고, 트레이스로 묶어서 봐라.
- 느려짐 — N+1, 동기 I/O, 락, 직렬화, 캐시 미스. APM/프로파일로 핫스팟 한 덩이만 뜯는 게 빠름.
- 메모리만 오름 — 캐시 상한, 리스너 누수, 큰 버퍼, 커넥션 미반납. 힙/FD 스냅샷 전후 비교.
- 빌드/배포만 깨짐 — env, 권한, lockfile, 플랫폼 차이. CI랑 로컬 diff부터.
- 설정 꼬임 — 시크릿, 기본값, 리전. 단일 소스·검증 스키마 있으면 삶이 편하다.
- 데이터만 안 맞음 — 비멱등 재시도, 부분 쓰기, 캐시 무효 빠뜨림. 멱등 키·트랜잭션 다시.
권장 순서는 똑같다. 최소 재현 → 최근 변경 → 환경 차이 → 관측으로 가설 → 고치고 회귀/부하.
배포 전엔 git add → git commit → git push → npm run deploy 쪽이 안 헷갈림. (팀 루틴에 맞추는 게 제일 중요)