Drizzle ORM으로 타입 안전한 데이터베이스 다루기
Prisma가 자체 스키마 언어와 코드 생성기를 통해 ORM의 새로운 기준을 만들었다면 Drizzle ORM은 다른 방향에서 출발합니다. “SQL을 알면 Drizzle도 안다(If you know SQL — you know Drizzle ORM)“가 공식 슬로건인데요. 스키마도 TypeScript, 쿼리도 TypeScript, 그리고 그 결과 타입도 TypeScript에서 자동으로 추론됩니다. 별도의 코드 생성 단계 없이요.
번들 크기도 가볍고 서버리스 환경에도 잘 맞아서 요즘 Cloudflare D1이나 Turso 같은 엣지 데이터베이스와 함께 쓰는 프로젝트가 눈에 띄게 늘고 있어요. 이번 글에서는 Drizzle ORM을 처음부터 직접 사용해보면서 어떤 느낌인지 알아보겠습니다.
Drizzle ORM이란?
Drizzle ORM은 TypeScript와 JavaScript를 위한 ORM입니다. 전통적인 ORM이 SQL을 추상화해서 감추려는 편이라면 Drizzle은 SQL의 표현력을 그대로 살리면서 타입 안전성만 얹는 접근을 취합니다.
스키마를 TypeScript 코드로 정의하기 때문에 별도의 DSL(Domain Specific Language)을 배울 필요가 없습니다. select().from().where() 형태의 쿼리 API가 실제 SQL 구문과 거의 1:1로 대응되어서 SQL을 아는 개발자라면 러닝 커브 없이 바로 쓸 수 있어요. 코드 생성 과정도 없습니다. TypeScript 컴파일러가 직접 타입을 추론하니까 prisma generate 같은 별도 빌드 단계가 필요 없어요.
PostgreSQL, MySQL, SQLite를 모두 지원하고 better-sqlite3, bun:sqlite, @libsql/client(Turso), postgres(PostgreSQL), mysql2 같은 여러 드라이버와 연동됩니다.
프로젝트 설정
실습을 위해 Bun으로 새 프로젝트를 만들어보겠습니다. SQLite를 사용해서 별도의 데이터베이스 서버 없이 바로 시작할 수 있습니다.
$ bun init -y
drizzle-orm은 ORM 본체이고, drizzle-kit은 마이그레이션 파일 생성과 적용을 담당하는 CLI 도구입니다.
$ bun add drizzle-orm
installed drizzle-orm@0.45.2
$ bun add -d drizzle-kit
installed drizzle-kit@0.31.10 with binaries:
- drizzle-kit
다음으로 Drizzle Kit의 설정 파일을 프로젝트 루트에 만듭니다.
import { defineConfig } from "drizzle-kit";
export default defineConfig({
schema: "./src/db/schema.ts",
out: "./drizzle",
dialect: "sqlite",
dbCredentials: {
url: "./dev.db",
},
});
schema는 테이블 정의 파일의 경로, out은 마이그레이션 SQL 파일이 저장될 디렉토리, dialect는 사용할 데이터베이스 종류를 지정합니다. SQLite는 파일 기반 데이터베이스니까 dbCredentials에 파일 경로만 넣으면 됩니다.
스키마 정의
Drizzle ORM에서 가장 눈에 띄는 부분은 스키마를 TypeScript 코드로 작성한다는 점인데요. src/db/schema.ts 파일을 만들어보겠습니다.
import { sqliteTable, integer, text } from "drizzle-orm/sqlite-core";
export const users = sqliteTable("users", {
id: integer("id").primaryKey({ autoIncrement: true }),
name: text("name").notNull(),
email: text("email").notNull().unique(),
age: integer("age"),
});
export const posts = sqliteTable("posts", {
id: integer("id").primaryKey({ autoIncrement: true }),
title: text("title").notNull(),
content: text("content").notNull(),
authorId: integer("author_id")
.notNull()
.references(() => users.id),
});
sqliteTable() 함수로 테이블을 정의하고, 각 칼럼은 integer(), text() 같은 함수로 타입을 지정합니다. 첫 번째 인자는 실제 데이터베이스의 칼럼 이름이에요. .notNull(), .unique(), .primaryKey() 같은 메서드 체이닝으로 제약 조건을 추가하는 방식이라 SQL의 NOT NULL, UNIQUE, PRIMARY KEY와 바로 매칭됩니다.
외래 키도 .references(() => users.id) 형태로 표현하는데, 화살표 함수를 쓰는 이유는 순환 참조를 방지하기 위해서입니다. posts 테이블이 users 테이블보다 아래에 선언되어도 문제없이 참조할 수 있죠.
PostgreSQL을 사용한다면 drizzle-orm/pg-core에서 pgTable, serial, varchar 등을 가져오면 되고, MySQL이라면 drizzle-orm/mysql-core에서 mysqlTable, int 등을 사용합니다. 데이터베이스별로 지원하는 칼럼 타입이 다르기 때문에 이렇게 분리되어 있어요.
마이그레이션
스키마를 작성했으니 이제 실제 데이터베이스에 테이블을 만들어야 합니다. Drizzle Kit의 generate 명령어로 마이그레이션 SQL 파일을 생성합니다.
$ bunx drizzle-kit generate
2 tables
posts 4 columns 0 indexes 1 fks
users 4 columns 1 indexes 0 fks
[✓] Your SQL migration file ➜ drizzle/0000_solid_sally_floyd.sql 🚀
스키마에서 테이블 2개, 칼럼 수, 인덱스, 외래 키를 분석해서 drizzle/ 디렉토리에 SQL 파일을 만들어줍니다. 생성된 파일을 열어보면 우리가 기대하는 SQL 그대로입니다.
CREATE TABLE `posts` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`title` text NOT NULL,
`content` text NOT NULL,
`author_id` integer NOT NULL,
FOREIGN KEY (`author_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE TABLE `users` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`name` text NOT NULL,
`email` text NOT NULL,
`age` integer
);
--> statement-breakpoint
CREATE UNIQUE INDEX `users_email_unique` ON `users` (`email`);
이제 migrate 명령어로 이 SQL을 실제 데이터베이스에 적용합니다.
$ bunx drizzle-kit migrate
[✓] migrations applied successfully!
프로젝트 루트에 dev.db 파일이 생기고 테이블이 만들어졌습니다. 이런 두 단계(generate → migrate) 접근법은 마이그레이션 SQL을 직접 확인하고 코드 리뷰에 포함시킬 수 있다는 장점이 있어요. Prisma의 migrate dev처럼 한 번에 처리하는 것도 편리하지만 실제로 어떤 SQL이 실행되는지 눈으로 확인할 수 있다는 건 프로덕션 환경에서 꽤 안심이 됩니다.
빠른 프로토타이핑에서는 bunx drizzle-kit push를 쓸 수도 있는데, 이 명령어는 마이그레이션 파일을 생성하지 않고 스키마 변경을 데이터베이스에 바로 반영합니다. 개발 초기에 스키마를 자주 바꿀 때 유용하지만, 마이그레이션 이력이 남지 않으니 프로덕션에서는 generate + migrate 조합을 쓰는 게 좋습니다.
데이터베이스 연결
마이그레이션까지 끝났으니 이제 코드에서 데이터베이스에 접근해볼 차례입니다. Drizzle ORM의 drizzle() 함수로 데이터베이스 인스턴스를 생성합니다.
import { drizzle } from "drizzle-orm/bun-sqlite";
const db = drizzle("./dev.db");
Bun 환경에서는 drizzle-orm/bun-sqlite를 사용하면 내장 SQLite 드라이버(bun:sqlite)를 활용합니다. Node.js 환경이라면 drizzle-orm/better-sqlite3를 쓰면 되고요. 연결 문자열 하나만 넘기면 끝이에요.
PostgreSQL이나 MySQL을 사용한다면 각각 drizzle-orm/node-postgres, drizzle-orm/mysql2 같은 드라이버 패키지를 쓰면 됩니다. 어떤 데이터베이스를 쓰든 drizzle() 함수 호출로 인스턴스를 만드는 패턴은 동일합니다.
CRUD 쿼리
이제 본격적으로 데이터를 다뤄보겠습니다. 스키마에서 정의한 테이블 객체와 drizzle-orm의 연산자 함수를 가져와서 사용합니다.
import { eq } from "drizzle-orm";
import { drizzle } from "drizzle-orm/bun-sqlite";
import { users, posts } from "./src/db/schema";
const db = drizzle("./dev.db");
먼저 사용자를 추가합니다. db.insert(테이블).values(데이터) 형태인데, SQL의 INSERT INTO ... VALUES ...와 구조가 같죠.
await db
.insert(users)
.values({ name: "김철수", email: "cs@test.com", age: 30 });
await db
.insert(users)
.values({ name: "이영희", email: "yh@test.com", age: 25 });
전체 사용자를 조회할 때는 db.select().from(테이블)을 사용합니다. SQL의 SELECT * FROM users에 해당합니다.
const allUsers = db.select().from(users).all();
console.log(allUsers);
[
{ id: 1, name: "김철수", email: "cs@test.com", age: 30 },
{ id: 2, name: "이영희", email: "yh@test.com", age: 25 }
]
결과의 타입이 자동으로 { id: number; name: string; email: string; age: number | null }[]로 추론됩니다. 스키마에서 age를 .notNull() 없이 정의했기 때문에 number | null이 되는 거예요. 이런 세밀한 타입 추론이 Drizzle의 강점입니다.
조건부 조회에는 where() 절과 함께 eq(), lt(), gte() 같은 연산자 함수를 사용합니다. SQL의 WHERE name = '김철수'가 where(eq(users.name, "김철수"))가 되는 식이에요.
const user = db.select().from(users).where(eq(users.name, "김철수")).get();
console.log(user);
{ id: 1, name: "김철수", email: "cs@test.com", age: 30 }
.all()은 배열을, .get()은 단일 레코드를 반환합니다. 반환 타입도 각각 T[]와 T | undefined로 정확하게 추론돼요.
수정과 삭제도 비슷한 패턴입니다.
// UPDATE users SET age = 31 WHERE email = 'cs@test.com'
await db.update(users).set({ age: 31 }).where(eq(users.email, "cs@test.com"));
// DELETE FROM users WHERE email = 'yh@test.com'
await db.delete(users).where(eq(users.email, "yh@test.com"));
외래 키로 연결된 테이블에 데이터를 넣는 것도 어렵지 않습니다.
await db.insert(posts).values({
title: "Drizzle ORM 시작하기",
content: "Drizzle ORM은 타입 안전한 ORM입니다.",
authorId: 1,
});
여기서 authorId 속성의 이름은 TypeScript 쪽 이름이고, 실제 데이터베이스 칼럼은 스키마에서 integer("author_id")로 지정한 author_id입니다. 이렇게 TypeScript의 카멜 케이스와 데이터베이스의 스네이크 케이스를 자연스럽게 매핑할 수 있어요.
쿼리 연산자
Drizzle ORM은 SQL의 연산자를 함수 형태로 제공합니다. 전부 drizzle-orm에서 가져올 수 있어요.
import {
eq,
ne,
gt,
gte,
lt,
lte,
and,
or,
like,
between,
isNull,
inArray,
} from "drizzle-orm";
비교 연산자는 SQL과 이름이 같아서 따로 외울 것이 없어요. eq는 =, ne는 <>, gt는 >, lt는 <에 대응됩니다.
// 나이가 20 이상 30 이하인 사용자
db.select()
.from(users)
.where(and(gte(users.age, 20), lte(users.age, 30)));
// 이름이 '김'으로 시작하는 사용자
db.select().from(users).where(like(users.name, "김%"));
// 나이가 비어있는 사용자
db.select().from(users).where(isNull(users.age));
and()와 or()로 조건을 조합할 수 있고 between(), inArray(), like() 등 SQL에서 흔히 쓰는 연산자가 대부분 갖춰져 있습니다. SQL을 함수 호출로 옮겨놓은 느낌이에요.
조인 쿼리
여러 테이블을 합쳐서 조회하는 조인도 SQL과 비슷한 형태로 작성합니다.
const result = db
.select({
postTitle: posts.title,
authorName: users.name,
})
.from(posts)
.leftJoin(users, eq(posts.authorId, users.id))
.all();
select() 안에 원하는 칼럼만 골라서 객체 형태로 지정할 수 있고, leftJoin(), innerJoin(), rightJoin() 메서드로 조인 조건을 걸면 됩니다. 결과 타입은 { postTitle: string; authorName: string | null }[]로 추론됩니다. LEFT JOIN이니까 authorName이 null일 수 있다는 것까지 타입에 반영되는 거예요.
관계 정의와 관계형 쿼리
조인이 SQL에 가까운 저수준 API라면 Drizzle은 관계형 쿼리(Relational Queries)라는 고수준 API도 제공합니다. 테이블 간의 관계를 미리 정의해두면 with 옵션으로 관련 데이터를 한 번에 가져올 수 있어요.
먼저 스키마 파일에 관계를 추가합니다.
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] }),
}));
one()은 N:1 또는 1:1 관계를, many()는 1:N 관계를 나타냅니다. 이 관계 정의는 데이터베이스 스키마에 영향을 주지 않고 TypeScript 레벨에서만 동작합니다. 외래 키는 이미 테이블 정의에서 .references()로 걸어놨으니까요.
관계를 정의했으면 db.query를 통해 관계형 쿼리를 사용할 수 있습니다.
import * as schema from "./src/db/schema";
const db = drizzle("./dev.db", { schema });
const usersWithPosts = db.query.users.findMany({
with: {
posts: true,
},
});
Prisma의 include와 비슷한 느낌인데, Drizzle은 이 기능을 사용하려면 drizzle() 초기화 시 스키마 전체를 넘겨야 합니다. findMany()와 findFirst()로 데이터를 조회하면서 with 옵션으로 연관 데이터를 함께 가져올 수 있고, where 옵션으로 필터링도 가능합니다.
타입 추론
Drizzle의 큰 장점 중 하나는 스키마에서 TypeScript 타입을 직접 추출할 수 있다는 점입니다.
type User = typeof users.$inferSelect;
// { id: number; name: string; email: string; age: number | null }
type NewUser = typeof users.$inferInsert;
// { name: string; email: string; id?: number; age?: number | null }
$inferSelect는 조회 결과의 타입을, $inferInsert는 삽입할 때 필요한 타입을 추론합니다. id는 자동 증가 칼럼이니까 삽입 시 선택적(?)이 되고, age는 nullable이니까 역시 선택적이에요. 이 타입들을 API 핸들러나 폼 유효성 검사 같은 곳에서 재사용하면 데이터베이스부터 프론트엔드까지 타입이 끊기지 않는 흐름을 만들 수 있습니다.
Prisma도 비슷한 기능을 제공하지만 prisma generate로 코드를 먼저 생성해야 합니다. Drizzle은 이 과정 없이 TypeScript 컴파일러가 알아서 추론하니까, CI에서 빌드 단계가 하나 줄어들고 스키마를 수정하면 IDE에서 바로 타입이 갱신됩니다.
Prisma에서 넘어온다면
이미 Prisma를 사용해보신 분이라면 Drizzle과 뭐가 다른지 궁금하실 텐데요.
가장 큰 차이는 스키마 정의 방식입니다. Prisma는 .prisma 확장자의 자체 스키마 언어를 사용하지만 Drizzle은 스키마 자체가 TypeScript 파일이에요. 에디터에서 타입 검사, 자동 완성, 리팩터링이 바로 작동하니까 별도 IDE 플러그인이 필요 없습니다.
쿼리 작성 방식도 다릅니다. Prisma는 prisma.user.findMany({ where: { age: { gte: 18 } } }) 같은 객체 기반 API를 쓰는 반면 Drizzle은 db.select().from(users).where(gte(users.age, 18)) 같은 SQL 빌더 스타일입니다. SQL에 익숙하다면 Drizzle 쪽이 더 읽기 편할 거예요.
번들 크기 차이도 눈에 띕니다. Prisma는 Rust로 작성된 쿼리 엔진 바이너리를 포함하는데 Drizzle은 순수 JavaScript/TypeScript로만 구성되어 있어 훨씬 가볍습니다. 서버리스 환경에서 콜드 스타트 시간이 중요하다면 이 차이가 체감될 수 있어요.
반면 Prisma가 강한 부분도 있습니다. Prisma Studio라는 GUI 도구로 데이터를 시각적으로 탐색하고 편집할 수 있고, 마이그레이션 이력 관리나 시딩(seeding) 기능도 오랜 시간 다듬어져 있습니다. Drizzle Studio라는 대응 도구가 있긴 하지만 아직 성숙도에서는 Prisma에 미치지 못합니다.
결국 SQL에 가까운 제어가 필요하고 번들 크기가 중요한 서버리스 환경이라면 Drizzle이, 팀 전체가 일관된 패턴으로 빠르게 개발하는 것이 목표라면 Prisma가 더 맞을 수 있습니다. 어느 쪽이 더 낫다기보다는 프로젝트 맥락에 따라 선택이 달라지는 문제예요.
마치며
Drizzle ORM을 처음 써보면 “이게 다야?” 하는 느낌이 들 수 있습니다. 설치하고, TypeScript로 스키마 정의하고, SQL과 닮은 API로 쿼리를 작성하면 끝이니까요. 이 단순함이 오히려 Drizzle의 가장 큰 매력입니다. SQL을 이미 알고 있는 개발자에게는 새로 배울 것이 거의 없고, 그러면서도 TypeScript의 타입 안전성은 온전히 누릴 수 있으니까요.
스키마 변경이 잦은 초기 개발에서는 drizzle-kit push로 빠르게 반복하다가, 프로덕션에 가까워지면 generate + migrate로 안전한 마이그레이션 흐름을 갖추면 됩니다. 서버리스 환경이나 엣지 컴퓨팅에서 가벼운 ORM이 필요하다면 꼭 한 번 시도해보시길 추천합니다.
더 자세한 내용은 Drizzle ORM 공식 문서를 참고하세요.
This work is licensed under
CC BY 4.0