Prisma로 스키마를 바꿀 때마다 prisma generate를 돌려서 클라이언트 코드를 새로 만들어내는 과정이 있습니다. 스키마 파일도 Prisma 전용 DSL(.prisma)로 따로 작성해야 하고, 실제로 어떤 SQL이 나가는지는 생성된 쿼리 엔진 내부를 들여다봐야 알 수 있습니다. Drizzle ORM은 스키마도 마이그레이션도 전부 TypeScript 코드로 작성하고, 쿼리 빌더가 SQL 문법을 거의 그대로 따라가서 실행되는 쿼리를 코드만 보고 예측할 수 있게 만든 ORM입니다.

Drizzle ORM 공식 로고 — drizzle-team/drizzle-orm 저장소 README에 있는 워드마크
이미지: drizzle-team/drizzle-orm 공식 GitHub 저장소

핵심 개념

  • 스키마가 곧 TypeScript 코드: 테이블을 pgTable, mysqlTable, sqliteTable 같은 함수로 정의합니다. 별도 DSL이 없어서 에디터 자동완성과 타입 체크가 스키마 파일 자체에서 바로 동작합니다.
  • 쿼리 빌더가 SQL 구조를 그대로 따라감: db.select().from(table).where(...)처럼 SQL의 SELECT ... FROM ... WHERE 순서를 코드 호출 순서로 그대로 옮겨놓은 형태입니다. Prisma의 findMany({ where: {...} }) 같은 자체 쿼리 객체 문법을 새로 배울 필요가 없습니다.
  • 런타임 오버헤드가 거의 없음: Prisma는 Rust로 작성된 쿼리 엔진 바이너리를 거쳐 쿼리를 실행하지만, Drizzle은 자바스크립트로 SQL 문자열을 조립해서 드라이버(postgres, mysql2, better-sqlite3 등)에 바로 넘깁니다. 추가 프로세스나 바이너리가 없어서 서버리스/엣지 환경(Cloudflare Workers, Vercel Edge Functions)에 올리기 쉽습니다.
  • 타입 추론은 스키마에서 자동으로 나옴: typeof users.$inferSelect로 조회 결과 타입을, typeof users.$inferInsert로 insert에 필요한 타입을 스키마 정의만으로 뽑아낼 수 있습니다. 별도 타입 생성 스텝이 없습니다.
  • 마이그레이션은 generate → migrate 두 단계: drizzle-kit generate가 스키마 변경분을 SQL 파일로 만들고, drizzle-kit migrate가 그 SQL을 실제 DB에 적용합니다. SQL이 파일로 먼저 나오기 때문에 적용 전에 리뷰할 수 있습니다. 프로토타이핑 단계에서는 drizzle-kit push로 마이그레이션 파일 없이 스키마를 DB에 바로 반영하는 것도 가능합니다.

실전 예시

Postgres 기준으로 필요한 패키지를 설치합니다.

npm install drizzle-orm postgres
npm install -D drizzle-kit

스키마는 일반 TypeScript 모듈입니다.

// src/db/schema.ts
import { pgTable, serial, text, timestamp, integer } from "drizzle-orm/pg-core";

export const users = pgTable("users", {
  id: serial("id").primaryKey(),
  email: text("email").notNull().unique(),
  createdAt: timestamp("created_at").defaultNow().notNull(),
});

export const posts = pgTable("posts", {
  id: serial("id").primaryKey(),
  userId: integer("user_id").references(() => users.id).notNull(),
  title: text("title").notNull(),
});

drizzle-kit이 스키마 위치와 DB 접속 정보를 읽을 설정 파일이 따로 필요합니다.

// 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!,
  },
});
npx drizzle-kit generate   # drizzle/0000_xxx.sql 생성
npx drizzle-kit migrate    # 실제 DB에 적용

클라이언트를 만들고 쿼리를 날리는 부분입니다.

// src/db/client.ts
import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
import * as schema from "./schema";

const client = postgres(process.env.DATABASE_URL!);
export const db = drizzle(client, { schema });
// src/db/queries.ts
import { eq } from "drizzle-orm";
import { db } from "./client";
import { users, posts } from "./schema";

export async function getUserByEmail(email: string) {
  const [user] = await db.select().from(users).where(eq(users.email, email));
  return user; // typeof users.$inferSelect | undefined
}

export async function createPost(userId: number, title: string) {
  const [post] = await db
    .insert(posts)
    .values({ userId, title })
    .returning();
  return post;
}

where(eq(users.email, email))처럼 비교 연산자(eq, gt, and, or)를 전부 함수로 가져와 조합하는 방식이 처음엔 어색했습니다. SQL을 그대로 쓰면 WHERE email = $1인데, 이걸 eq(column, value) 함수 호출로 바꿔 써야 하는 이유는 타입 체크 때문입니다 — 컬럼과 비교값의 타입이 맞는지 eq 함수 시그니처에서 검증하므로, users.id(number)에 문자열을 넘기면 그 자리에서 컴파일 에러가 납니다. 문자열 템플릿으로 SQL을 직접 짜면 이 검증이 빠집니다.

장단점

관계(join)를 다룰 때는 db.select().from(users).leftJoin(posts, eq(posts.userId, users.id))처럼 SQL 조인을 그대로 코드로 옮겨 쓸 수 있어서, SQL을 아는 사람이라면 추가로 배울 게 많지 않습니다. 반대로 Prisma의 include: { posts: true }처럼 관계를 한 줄로 당겨오는 편의 문법은 Drizzle에 없고, 대신 relations()로 관계를 미리 정의해두고 db.query.users.findMany({ with: { posts: true } }) 형태의 별도 API(Relational Queries)를 써야 합니다. 두 방식이 공존하다 보니 처음 프로젝트에 들어오는 사람은 "쿼리 빌더 문법"과 "Relational Queries 문법" 중 뭘 써야 하는지 한 번은 헷갈리게 됩니다.

마이그레이션 파일이 SQL 그대로 저장되는 점은 장점이자 책임입니다. Prisma처럼 마이그레이션 히스토리를 자동으로 관리해주는 상위 레이어가 없어서, 여러 명이 동시에 스키마를 바꾸면 drizzle-kit generate가 만든 파일 번호가 충돌하는 상황을 직접 git으로 풀어야 합니다. 다만 그 대가로 "지금 DB에 무슨 SQL이 적용됐는지"가 항상 평범한 .sql 파일로 남아 있어서, 문제가 생겼을 때 추적하기는 오히려 Prisma보다 쉬웠습니다.