Drizzle ORM - 타입스크립트 우선 SQL 툴킷
발행 읽는 시간 약 7 분
- Drizzle
- ORM
- TypeScript
- PostgreSQL
- 데이터베이스
프로젝트 소개
SQL을 감추지 않으면서 타입 안전성을 제공하는 경량 ORM입니다. 스키마 파일 하나로 타입과 마이그레이션을 동시에 얻고, 런타임 오버헤드 없이 서버리스 환경에서 동작합니다.
Drizzle ORM: SQL을 숨기지 않는 타입스크립트 ORM#
Drizzle ORM은 타입스크립트로 작성된 경량 ORM으로, SQL을 추상화해서 감추는 대신 SQL과 거의 1:1로 대응하는 API를 제공합니다. GitHub에서 3만 개 이상의 스타를 받았고, 서버리스·엣지 환경이 늘어나면서 채택이 빠르게 늘고 있습니다.
왜 또 다른 ORM인가#
기존 ORM이 해결한 것과 못 한 것#
Prisma, TypeORM 같은 도구는 두 가지를 잘 해결했습니다. 하나는 타입 안전성이고, 다른 하나는 마이그레이션 관리입니다. 대신 세 가지 비용을 남겼습니다.
첫째, 쿼리가 불투명해집니다. 코드에 쓴 것과 실제로 나가는 SQL이 다르고, 성능 문제가 생겼을 때 원인을 찾으려면 결국 생성된 SQL을 봐야 합니다. 그 시점에서 추상화의 이득이 사라집니다.
둘째, 런타임 비용이 있습니다. Prisma는 별도 쿼리 엔진 바이너리를 띄웁니다. 서버가 상시 떠 있는 환경에서는 문제가 아니지만, 요청마다 콜드 스타트가 발생하는 서버리스에서는 부담이 됩니다.
셋째, SQL 지식이 이전되지 않습니다. ORM 문법을 익히는 시간은 그 ORM 안에서만 쓰입니다.
Drizzle의 선택#
Drizzle은 세 문제 모두를 "SQL을 감추지 않는다"는 한 가지 원칙으로 처리합니다.
// 코드
db.select().from(users).where(eq(users.id, 1))
// 실제 SQL — 거의 그대로 대응
// select * from users where id = 1쿼리 빌더가 SQL 구조를 그대로 따르기 때문에, SQL을 아는 사람은 문서를 거의 안 보고 쓸 수 있고, 성능 문제가 생겼을 때 코드만 보고 원인을 짐작할 수 있습니다.
핵심 개념#
1. 스키마가 곧 타입#
테이블 정의 파일 하나가 타입 정의와 마이그레이션의 단일 출처가 됩니다. 별도의 스키마 언어(.prisma 같은)를 배울 필요가 없고, 그냥 타입스크립트입니다.
import { pgTable, serial, text, jsonb, timestamp, index } from 'drizzle-orm/pg-core'
export const leads = pgTable(
'leads',
{
id: serial('id').primaryKey(),
createdAt: timestamp('created_at', { withTimezone: true })
.defaultNow()
.notNull(),
tool: text('tool').notNull(),
projectType: text('project_type'),
features: jsonb('features').$type<string[]>().default([]).notNull(),
},
(t) => ({
createdAtIdx: index('leads_created_at_idx').on(t.createdAt),
})
)여기서 $type<string[]>()가 눈여겨볼 부분입니다. jsonb 컬럼은 DB 차원에서는 임의의 JSON이지만, 애플리케이션 코드에서는 string[]로 좁혀서 다룰 수 있습니다. DB 타입과 애플리케이션 타입을 분리해서 지정할 수 있다는 뜻입니다.
2. 타입 추론#
스키마에서 타입을 뽑아 쓸 수 있어 별도 인터페이스를 유지할 필요가 없습니다.
import type { InferSelectModel, InferInsertModel } from 'drizzle-orm'
type Lead = InferSelectModel<typeof leads> // 조회 결과 타입
type NewLead = InferInsertModel<typeof leads> // 삽입 시 타입InferInsertModel은 기본값이 있거나 자동 생성되는 컬럼(id, createdAt)을 선택 항목으로 처리합니다. 스키마를 바꾸면 이 타입들이 자동으로 따라오고, 맞지 않는 코드는 컴파일 단계에서 걸립니다.
3. 쿼리 빌더#
SQL 절 순서를 그대로 따릅니다.
import { eq, and, desc, gte } from 'drizzle-orm'
const recent = await db
.select({
id: leads.id,
tool: leads.tool,
createdAt: leads.createdAt,
})
.from(leads)
.where(
and(
eq(leads.tool, 'estimate'),
gte(leads.createdAt, since)
)
)
.orderBy(desc(leads.createdAt))
.limit(20)select()에 객체를 넘기면 그 필드만 선택되고, 반환 타입도 그 모양으로 좁혀집니다. select()를 비우면 전체 컬럼입니다.
4. 관계형 쿼리#
조인을 직접 쓰는 대신 중첩 구조로 받고 싶을 때는 별도 API가 있습니다.
const result = await db.query.users.findMany({
with: {
posts: {
limit: 5,
orderBy: (posts, { desc }) => [desc(posts.createdAt)],
},
},
})이 방식은 내부적으로 단일 쿼리로 처리됩니다. 여러 번 왕복하면서 N+1이 생기는 구조가 아닙니다.
5. 관계 정의#
테이블 간 관계는 스키마와 별도로 선언합니다. 외래키 제약(DB 차원)과 관계 선언(쿼리 편의)이 분리되어 있다는 점이 처음에는 낯설 수 있습니다.
import { relations } from 'drizzle-orm'
export const users = pgTable('users', {
id: serial('id').primaryKey(),
email: text('email').notNull().unique(),
})
export const posts = pgTable('posts', {
id: serial('id').primaryKey(),
authorId: integer('author_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
title: text('title').notNull(),
})
export const usersRelations = relations(users, ({ many }) => ({
posts: many(posts),
}))
export const postsRelations = relations(posts, ({ one }) => ({
author: one(users, {
fields: [posts.authorId],
references: [users.id],
}),
}))references()는 실제 DB에 외래키를 만들고, relations()는 db.query에서 중첩 조회를 쓰기 위한 선언입니다. 둘 중 하나만 있어도 동작하지만, 목적이 다르므로 보통 함께 씁니다.
6. 트랜잭션#
여러 작업을 하나로 묶습니다. 콜백 안에서 예외가 나면 전체가 되돌아갑니다.
await db.transaction(async (tx) => {
const [order] = await tx
.insert(orders)
.values({ userId, total })
.returning()
await tx.insert(orderItems).values(
items.map((it) => ({ orderId: order.id, ...it }))
)
await tx
.update(inventory)
.set({ stock: sql`${inventory.stock} - ${qty}` })
.where(eq(inventory.sku, sku))
})sql 템플릿을 쓰면 현재 값을 기준으로 계산하는 SQL을 그대로 표현할 수 있습니다. 값을 읽어 와서 빼고 다시 쓰는 방식과 달리 경쟁 조건이 생기지 않습니다.
7. 원시 SQL로 내려가기#
빌더로 표현하기 어려운 쿼리는 SQL을 직접 씁니다. 이때도 타입을 지정할 수 있습니다.
import { sql } from 'drizzle-orm'
const stats = await db.execute<{ tool: string; cnt: number }>(sql`
select tool, count(*)::int as cnt
from leads
where created_at >= now() - interval '30 days'
group by tool
order by cnt desc
`)ORM을 쓰다가 특정 쿼리만 SQL로 내려가는 것이 예외적 탈출구가 아니라 정상적인 사용 방식이라는 점이 Drizzle의 성격을 잘 보여줍니다.
마이그레이션#
drizzle-kit이 스키마 파일과 실제 DB 상태를 비교해 SQL 마이그레이션 파일을 생성합니다.
// drizzle.config.ts
import type { Config } from 'drizzle-kit'
export default {
schema: './lib/db/schema.ts',
out: './lib/db/migrations',
dialect: 'postgresql',
dbCredentials: {
url: process.env.DATABASE_URL!,
},
} satisfies Confignpx drizzle-kit generate # 스키마 변경분을 SQL 파일로 생성
npx drizzle-kit migrate # 생성된 마이그레이션 적용
npx drizzle-kit studio # 브라우저에서 데이터 확인생성물이 읽을 수 있는 순수 SQL 파일이라는 점이 중요합니다. 적용 전에 검토할 수 있고, 필요하면 손으로 고칠 수 있으며, 나중에 Drizzle을 걷어내도 마이그레이션 기록은 그대로 남습니다.
컬럼 이름을 바꿀 때#
도구가 판단할 수 없는 대표적인 경우입니다. 스키마에서 name을 full_name으로 바꾸면, 생성기는 이것을 "이름 변경"으로 볼 수도 있고 "기존 컬럼 삭제 + 새 컬럼 추가"로 볼 수도 있습니다. 후자로 생성되면 데이터가 사라집니다.
-- 위험: 생성된 마이그레이션이 이렇게 나왔다면
ALTER TABLE "users" DROP COLUMN "name";
ALTER TABLE "users" ADD COLUMN "full_name" text;
-- 의도한 것은 이것
ALTER TABLE "users" RENAME COLUMN "name" TO "full_name";drizzle-kit generate는 대화형으로 "이름을 바꾼 것인지" 물어보는 경우가 있지만, 항상 물어보지는 않습니다. 생성된 SQL을 열어 보는 습관이 필요한 이유가 여기 있습니다. 운영 DB에 적용하기 전에 반드시 확인하세요.
배포 파이프라인에 넣기#
마이그레이션 적용은 코드 배포와 분리하는 편이 안전합니다. 애플리케이션 시작 시점에 자동 적용하면, 여러 인스턴스가 동시에 뜰 때 같은 마이그레이션을 중복 실행하려 시도할 수 있습니다.
{
"scripts": {
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:studio": "drizzle-kit studio"
}
}CI에서 db:migrate를 배포 직전 단계로 두고, 애플리케이션 코드에서는 마이그레이션을 실행하지 않는 구성이 일반적입니다.
서버리스 환경에서의 이점#
Drizzle이 최근 주목받는 가장 큰 이유입니다.
| Prisma | Drizzle | |
|---|---|---|
| 런타임 | 별도 쿼리 엔진 바이너리 | 순수 JS/TS |
| 번들 크기 | 큼 | 작음 |
| 콜드 스타트 | 엔진 초기화 필요 | 추가 초기화 없음 |
| 엣지 런타임 | 제약 있음 | 동작 |
서버리스 함수는 요청이 없으면 내려가고 다시 요청이 오면 새로 뜹니다. 이때 무거운 초기화가 있으면 첫 요청이 느려집니다. Drizzle은 추가 프로세스가 없어 이 비용이 거의 없습니다.
HTTP 기반 드라이버와 조합하면 커넥션 풀 문제도 피할 수 있습니다.
import { neon } from '@neondatabase/serverless'
import { drizzle } from 'drizzle-orm/neon-http'
const sql = neon(process.env.DATABASE_URL!)
export const db = drizzle(sql, { schema })Prisma에서 옮겨올 때#
이미 Prisma로 돌아가는 프로젝트를 옮기는 경우가 많아 짚어 둡니다.
스키마는 자동 변환이 가능합니다. drizzle-kit pull이 기존 DB 구조를 읽어 Drizzle 스키마 파일을 만들어 줍니다. 즉 .prisma 파일을 손으로 옮길 필요는 없고, DB에서 역으로 뽑으면 됩니다.
npx drizzle-kit pull쿼리는 손으로 옮겨야 합니다. 사고방식이 다르기 때문입니다.
| Prisma | Drizzle |
|---|---|
findMany({ where: { id } }) | .select().from(t).where(eq(t.id, id)) |
include: { posts: true } | db.query.t.findMany({ with: { posts: true } }) 또는 명시적 조인 |
$transaction([...]) | db.transaction(async (tx) => { ... }) |
한꺼번에 옮기지 않아도 됩니다. 두 라이브러리가 같은 DB를 동시에 바라볼 수 있으므로, 새로 쓰는 코드부터 Drizzle로 하고 기존 코드는 그대로 두는 점진적 이행이 가능합니다. 다만 마이그레이션 관리는 한쪽으로 몰아야 합니다. 두 도구가 각자 스키마 상태를 추적하면 충돌합니다.
이럴 때 적합하다#
적합한 경우
- 서버리스·엣지에 배포하는 프로젝트
- 팀에 SQL을 아는 사람이 있고, 쿼리를 직접 통제하고 싶은 경우
- 번들 크기와 콜드 스타트가 중요한 경우
- 마이그레이션 SQL을 직접 검토하려는 경우
덜 적합한 경우
- SQL을 모르는 팀이 빠르게 시작해야 하는 경우 — 추상화가 두꺼운 쪽이 초기 속도가 빠릅니다
- 어드민 화면 자동 생성 같은 주변 도구가 많이 필요한 경우 — 생태계는 아직 Prisma가 넓습니다
- 매우 복잡한 관계 매핑을 ORM에 맡기고 싶은 경우
도입 시 주의할 점#
첫째, SQL을 여전히 알아야 합니다. 이것은 단점이 아니라 설계 의도입니다. 다만 팀 구성에 따라 진입 장벽이 됩니다.
둘째, 마이그레이션 생성물은 반드시 검토하세요. 컬럼 이름 변경 같은 작업은 도구가 "삭제 후 추가"로 해석할 수 있습니다. 그대로 적용하면 데이터가 사라집니다.
셋째, 버전 변화가 빠릅니다. 아직 API가 정리되는 중이라, 마이너 버전 업그레이드에서도 변경 사항을 확인하는 편이 안전합니다.
결론#
Drizzle ORM의 가치는 기능의 양이 아니라 포기한 것에 있습니다. SQL을 감추지 않기로 하면서 런타임 오버헤드, 쿼리 불투명성, 학습의 비이전성 세 가지를 함께 덜어냈습니다.
그래서 선택 기준도 단순합니다. 팀이 SQL을 통제하고 싶은가, 아니면 SQL로부터 보호받고 싶은가. 전자라면 Drizzle이 잘 맞고, 후자라면 추상화가 두꺼운 도구가 낫습니다. 어느 쪽도 틀린 답이 아닙니다.
- 공식 문서: orm.drizzle.team(새 창)
- 저장소: drizzle-team/drizzle-orm(새 창)

