Drizzle ORM으로 타입 안전한 복잡 쿼리 작성하기 — 관계형 쿼리, 마이그레이션, Edge 런타임 대응까지
Prisma를 쓰다 보면 불편함이 생기는 지점이 있습니다. 여러 테이블에 걸친 복잡한 include 체인을 쓸 때 N+1 쿼리 문제나 비효율적인 JOIN 전략이 발생해도 Prisma가 생성하는 SQL을 직접 조정하기 어렵고, Cloudflare Workers 같은 Edge 환경에 배포하려면 Rust 기반 쿼리 엔진이 걸림돌이 됩니다. 저는 이 두 문제에 부딪히고 나서 Drizzle ORM을 살펴봤고, 이후 여러 프로젝트에서 실제로 전환하게 됐습니다.
Drizzle은 "SQL-first ORM"이라는 표현을 씁니다. SQL을 추상화해 숨기는 대신, SQL 문법에 최대한 가까운 TypeScript API로 쿼리를 작성하게 하면서 컬럼 nullable 여부, JOIN 결과, 중첩 객체까지 모두 TypeScript 타입으로 추론해 줍니다. SQL의 가시성과 TypeScript의 타입 안전성을 동시에 가져갈 수 있다는 게 핵심입니다.
이 글에서는 Drizzle ORM의 스키마 정의, 두 가지 쿼리 API, 마이그레이션 전략, NestJS 통합, Cloudflare Workers 대응까지 실제 코드와 함께 살펴봅니다.
Prisma와 Drizzle의 구조적 차이
두 ORM의 근본적인 차이는 "타입이 어디서 오는가"에 있습니다.
Prisma는 .prisma 스키마 파일을 소스 오브 트루스로 삼습니다. prisma generate 단계에서 TypeScript 타입을 생성하고, Rust로 작성된 쿼리 엔진이 런타임에 로드됩니다. Drizzle은 반대로, TypeScript 파일 자체가 스키마입니다. 별도의 코드 생성 단계가 없고, 타입은 스키마 정의에서 직접 추론됩니다.
drizzle-kit은 마이그레이션 관리를 위한 CLI 도구이며 런타임 쿼리 경로와 완전히 분리되어 있습니다. 런타임에는 drizzle-orm만 사용됩니다.
한눈에 보는 비교
코드 예시로 들어가기 전에, 실무 선택 기준이 될 항목들을 먼저 정리합니다.
| 기준 | Drizzle | Prisma |
|---|---|---|
| 번들 크기 | 약 7KB (min+gzip) | 수 MB (Rust 엔진 바이너리 포함) |
| 코드 생성 단계 | 없음 | prisma generate 필수 |
| SQL 가시성 | 실행 SQL 완전 제어 | 추상화됨 |
| Edge 런타임 지원 | 공식 지원 (Workers, D1) | 최근 버전에서 지원 시작 (일부 제한) |
| 타입 추론 방식 | TypeScript 직접 추론 | 코드 생성 기반 |
| 마이그레이션 | push / generate + migrate | prisma migrate |
| 런타임 검증 | drizzle-zod 등 별도 통합 필요 | 동일 (별도 통합 필요) |
| SQL 지식 요구 | 필수 | 낮음 (높은 추상화) |
| 쿼리 성능 | 벤치마크 기준 유의미한 차이 | 상대적으로 느림 |
| 생태계 성숙도 | 빠르게 성장 중 | 성숙한 생태계, 자료 풍부 |
Drizzle이 적합한 경우: SQL에 익숙하고, Edge 런타임이나 번들 크기 제약이 있거나, 복잡한 쿼리의 실행 계획을 직접 제어해야 하는 경우.
Prisma가 적합한 경우: SQL에 익숙하지 않은 팀원이 있거나, 빠른 프로토타이핑이 우선이거나, Prisma 생태계의 서드파티 통합이 필요한 경우.
스키마 정의: TypeScript 파일이 단일 소스 오브 트루스
Prisma에서는 DSL 파일에 스키마를 씁니다:
// schema.prisma
model User {
id Int @id @default(autoincrement())
email String @unique
name String?
posts Post[]
createdAt DateTime @default(now())
}Drizzle에서는 TypeScript 파일에 직접 씁니다:
// db/schema.ts
import {
pgTable, serial, integer, text, timestamp, 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'),
active: boolean('active').notNull().default(true),
createdAt: timestamp('created_at').defaultNow().notNull(),
// defaultNow()는 INSERT 기본값입니다.
// UPDATE 시 자동 갱신이 필요하면 애플리케이션 레이어에서 new Date()를 명시적으로 전달하거나
// DB 트리거를 별도로 구성해야 합니다.
updatedAt: timestamp('updated_at').defaultNow().notNull(),
});
export const posts = pgTable('posts', {
id: serial('id').primaryKey(),
title: text('title').notNull(),
content: text('content'),
published: boolean('published').notNull().default(false),
// FK 컬럼은 integer()로 정의합니다. serial()은 auto-increment 생성 컬럼 전용입니다.
authorId: integer('author_id').references(() => users.id),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
});
export type User = typeof users.$inferSelect;
export type NewUser = typeof users.$inferInsert;TypeScript 스프레드를 활용해 공통 컬럼을 재사용할 수도 있습니다. Prisma DSL에서는 불가능한 패턴입니다:
const timestamps = {
createdAt: timestamp('created_at').defaultNow().notNull(),
// updatedAt은 INSERT 기본값만 설정됩니다. UPDATE 시 자동 갱신 없음.
updatedAt: timestamp('updated_at').defaultNow().notNull(),
};
export const users = pgTable('users', {
id: serial('id').primaryKey(),
email: text('email').notNull().unique(),
...timestamps,
});두 가지 쿼리 API: 언제 무엇을 쓰나
Drizzle에는 쿼리를 작성하는 방식이 두 가지 있습니다. 한 프로젝트 안에서 두 가지를 상황에 따라 혼용하는 것이 자연스럽습니다.
중첩 관계와 GROUP BY가 동시에 필요한 경우처럼 두 가지 API의 용도가 겹치는 상황에서는 SQL-like API와 명시적 JOIN이 더 읽기 쉽고 관리하기 쉽습니다.
SQL-like API
SQL 구문에 1:1 대응하는 방식으로, JOIN, 집계, 서브쿼리처럼 실행 SQL을 정확히 제어해야 할 때 씁니다:
import { db } from './db';
import { users, posts } from './db/schema';
import { eq, desc, count } from 'drizzle-orm';
// 기본 SELECT
const allUsers = await db.select().from(users);
// WHERE 조건
const activeUsers = await db
.select()
.from(users)
.where(eq(users.active, true));
// LEFT JOIN + 집계
const usersWithPostCount = await db
.select({
id: users.id,
email: users.email,
postCount: count(posts.id),
})
.from(users)
.leftJoin(posts, eq(users.id, posts.authorId))
.groupBy(users.id, users.email)
.orderBy(desc(count(posts.id)));관계형 쿼리 빌더 (RQB)
중첩 관계를 선언적으로 로딩할 때 씁니다. 내부적으로 최적화된 단일 SQL로 컴파일되어 N+1 문제가 발생하지 않습니다.
관계형 쿼리 완결 예시
RQB를 사용하기 위한 세 단계를 하나의 흐름으로 정리합니다.
1단계: 관계 정의
schema.ts 파일 안에 relations()로 관계를 함께 정의하는 방식이 가장 단순합니다:
// db/schema.ts (기존 테이블 정의에 추가)
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],
}),
}));2단계: drizzle() 초기화 시 schema에 관계 포함
// db/index.ts
import { drizzle } from 'drizzle-orm/node-postgres';
import { Pool } from 'pg';
import * as schema from './schema'; // usersRelations, postsRelations 포함
export const db = drizzle(new Pool({ connectionString: process.env.DATABASE_URL }), { schema });3단계: RQB 쿼리 작성
where 조건은 콜백 함수로 전달합니다:
const result = await db.query.users.findMany({
where: (u, { eq }) => eq(u.active, true),
with: {
posts: {
where: (p, { eq }) => eq(p.published, true),
orderBy: (p, { desc }) => [desc(p.createdAt)],
limit: 5,
},
},
});
// result 타입이 자동으로 추론됩니다:
// Array<{
// id: number;
// email: string;
// name: string | null;
// active: boolean;
// createdAt: Date;
// updatedAt: Date;
// posts: Array<{ id: number; title: string; published: boolean; ... }>;
// }>마이그레이션 전략: push vs generate + migrate
drizzle-kit이 제공하는 세 가지 명령어를 환경에 따라 다르게 씁니다.
# 로컬: 스키마 변경을 즉시 DB에 반영 (파일 생성 없음)
pnpm drizzle-kit push
# PR/스테이징: 검토 가능한 SQL 파일 생성
pnpm drizzle-kit generate
# 프로덕션: 생성된 SQL 파일 순차 적용
pnpm drizzle-kit migrate설정 파일은 프로젝트 루트에 둡니다:
// drizzle.config.ts
import { defineConfig } from 'drizzle-kit';
export default defineConfig({
schema: './src/db/schema.ts',
out: './drizzle/migrations',
dialect: 'postgresql',
dbCredentials: {
url: process.env.DATABASE_URL!,
},
});drizzle-kit push는 마이그레이션 파일을 남기지 않으므로, 프로덕션에서 사용하면 변경 이력이 사라지고 CI/CD 파이프라인과도 연결하기 어렵습니다. 로컬 개발에만 쓰고, PR부터는 generate → migrate 흐름을 CI에 명시적으로 연결해 두는 것이 권장됩니다.
최근 버전의 drizzle-kit push는 컬럼 삭제나 타입 변경처럼 데이터 손실 가능성이 있는 작업에 대해 확인 프롬프트를 띄웁니다.
NestJS 통합
NestJS에서 Drizzle을 연결하는 공식적인 방법은 커스텀 프로바이더를 직접 구성하는 것입니다. Symbol을 주입 토큰으로 사용하는 패턴이 가장 명확합니다.
// db/db.module.ts
import { Global, Module } from '@nestjs/common';
import { Pool } from 'pg';
import { drizzle } from 'drizzle-orm/node-postgres';
import * as schema from './schema'; // relations export 포함
export const DRIZZLE = Symbol('DRIZZLE');
@Global()
@Module({
providers: [
{
provide: DRIZZLE,
useFactory: () => {
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
return drizzle(pool, { schema });
},
},
],
exports: [DRIZZLE],
})
export class DbModule {}// users/users.service.ts
import { Injectable, Inject } from '@nestjs/common';
import { eq } from 'drizzle-orm';
import type { NodePgDatabase } from 'drizzle-orm/node-postgres';
import { DRIZZLE } from '../db/db.module';
import * as schema from '../db/schema';
import { users } from '../db/schema';
@Injectable()
export class UsersService {
constructor(
@Inject(DRIZZLE) private db: NodePgDatabase<typeof schema>,
) {}
async findById(id: number) {
const result = await this.db
.select()
.from(users)
.where(eq(users.id, id))
.limit(1);
return result[0] ?? null;
}
async findWithPublishedPosts(id: number) {
return this.db.query.users.findFirst({
where: (u, { eq }) => eq(u.id, id),
with: {
posts: {
where: (p, { eq }) => eq(p.published, true),
},
},
});
}
async create(body: unknown) {
const validated = createUserSchema.parse(body);
const [created] = await this.db.insert(users).values(validated).returning();
return created;
}
async update(id: number, name: string) {
await this.db
.update(users)
.set({ name, updatedAt: new Date() }) // updatedAt 수동 갱신 필수
.where(eq(users.id, id));
}
}커뮤니티 패키지(예: drizzle-nestjs)를 사용하면 의존성 주입 보일러플레이트를 줄일 수 있지만, 유지보수 상태와 버전 호환성을 직접 확인해야 합니다. 위 패턴은 외부 패키지 없이 NestJS 기본 기능만으로 동일한 결과를 냅니다.
런타임 검증: drizzle-zod 통합
Drizzle은 컴파일 타임 타입 안전성을 제공하지만, HTTP 요청 본문 같은 외부 입력의 런타임 검증은 별도로 처리해야 합니다. drizzle-zod를 쓰면 스키마에서 Zod 검증 스키마를 자동으로 생성해 중복을 줄일 수 있습니다.
// users/users.schema.ts
import { createInsertSchema } from 'drizzle-zod';
import { z } from 'zod';
import { users } from '../db/schema';
export const createUserSchema = createInsertSchema(users, {
email: z.string().email('유효한 이메일이 아닙니다'),
name: z.string().min(2, '이름은 2자 이상이어야 합니다').optional(),
});
export type CreateUserInput = z.infer<typeof createUserSchema>;createInsertSchema는 users 테이블의 $inferInsert 타입을 기반으로 Zod 스키마를 만들어 줍니다. createUserSchema.parse(body)를 통과한 값은 별도 타입 캐스팅 없이 db.insert(users).values(validated)에 바로 전달할 수 있습니다.
Edge 런타임: Cloudflare Workers + D1
지금까지는 전통적인 서버 환경(Node.js + PostgreSQL)을 기준으로 살펴봤습니다. 이제 환경이 완전히 달라지는 Edge로 넘어갑니다.
Cloudflare Workers는 번들 크기와 런타임 제약이 엄격합니다. Drizzle의 약 7KB(min+gzip) 번들은 Workers 환경에 적합하고, D1(Cloudflare의 SQLite 기반 서버리스 DB)을 공식 드라이버로 지원합니다. 번들 크기 측면에서 두 ORM의 차이가 가장 뚜렷하게 드러나는 이유는 Drizzle이 런타임 엔진 없이 순수 TypeScript 쿼리 빌더로만 동작하기 때문입니다.
// src/worker.ts
import { drizzle } from 'drizzle-orm/d1'; // Node.js 드라이버가 아닌 D1 드라이버
import * as schema from './db/schema';
import { posts } from './db/schema';
interface Env {
DB: D1Database;
}
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const db = drizzle(env.DB, { schema });
const url = new URL(request.url);
if (url.pathname === '/users') {
const result = await db.query.users.findMany({
where: (u, { eq }) => eq(u.active, true),
with: {
posts: {
where: (p, { eq }) => eq(p.published, true),
orderBy: (p, { desc }) => [desc(p.createdAt)],
limit: 3,
},
},
});
return Response.json(result);
}
return new Response('Not Found', { status: 404 });
},
};# wrangler.toml
name = "my-worker"
main = "src/worker.ts"
compatibility_date = "2025-01-01"
[[d1_databases]]
binding = "DB"
database_name = "my-database"
database_id = "your-database-id"D1 마이그레이션은 drizzle-kit generate로 SQL 파일을 생성한 뒤 wrangler로 적용합니다:
pnpm drizzle-kit generate
wrangler d1 execute DB --file=./drizzle/migrations/0000_init.sql실무에서 흔한 실수
1. relations() 없이 RQB 사용하기
relations()로 관계를 정의하지 않거나, 정의했더라도 drizzle() 초기화에 schema를 전달하지 않으면 db.query.users.findMany({ with: { posts: true } })가 의도대로 동작하지 않습니다. SQL-like API의 JOIN은 relations 없이도 작동하기 때문에 처음에 혼동하기 쉽습니다.
// ❌ schema에 relations가 빠진 경우
const db = drizzle(pool); // schema 없음
const result = await db.query.users.findMany({ with: { posts: true } }); // 에러
// ✅ relations를 포함한 schema를 drizzle()에 전달
const db = drizzle(pool, { schema }); // usersRelations, postsRelations 포함2. 프로덕션에서 push 사용하기
drizzle-kit push는 마이그레이션 파일을 생성하지 않습니다. 프로덕션 DB에 push를 쓰면 변경 이력이 남지 않아 롤백이나 감사 추적이 어렵습니다. generate → migrate 흐름을 CI에 명시적으로 연결해 두는 것이 좋습니다.
3. serial()을 FK 컬럼에 사용하기
serial('author_id')는 SERIAL 타입(auto-increment 시퀀스 생성)으로 FK 컬럼을 만듭니다. 의도치 않은 시퀀스가 생성되고 스키마가 잘못 만들어집니다. FK는 integer('author_id').references(() => users.id)로 정의해야 합니다.
4. 대형 프로젝트에서의 IDE 성능 저하
수백 개 테이블이 있는 프로젝트에서는 복잡한 TypeScript 추론으로 IDE 자동완성이 느려질 수 있습니다. tsconfig.json에 "incremental": true, "skipLibCheck": true를 설정하거나, 스키마를 도메인별 파일로 분리하면 도움이 됩니다.
마치며
Drizzle ORM은 SQL을 알고 있는 개발자가 TypeScript의 타입 안전성을 유지하면서 SQL 수준의 제어력을 가져가고 싶을 때 잘 맞는 선택입니다. 번들 크기 제약이 있는 Edge 런타임이나, 복잡한 쿼리의 실행 계획을 정확히 제어해야 하는 상황에서 이점이 명확하게 드러납니다.
반면 SQL에 익숙하지 않은 팀원이 많거나, 빠른 프로토타이핑이 우선이거나, Prisma 생태계의 서드파티 통합이 필요한 상황이라면 Prisma가 여전히 합리적인 선택입니다. 둘 중 하나가 절대적으로 우월한 것이 아니라, 프로젝트 맥락에 맞는 도구를 고르는 것이 중요합니다.
지금 시작해보고 싶다면 이 순서를 권합니다:
- 스키마 정의부터:
drizzle-orm과drizzle-kit을 설치하고, 기존 Prisma 스키마를pgTable로 옮겨보면서$inferSelect와$inferInsert타입 추론이 어떻게 동작하는지 확인합니다. - SQL-like API → RQB 순서로: 처음엔
db.select().from().where()로 쿼리를 작성하고, 중첩 관계가 필요해지면relations()와db.query로 전환합니다. - 마이그레이션 워크플로 정립: 로컬에는
push, PR/프로덕션에는generate+migrate로 환경별 전략을 명확히 정해두고 CI/CD에 연결합니다.
참고 자료
- Drizzle ORM 공식 문서
- Drizzle ORM - Relational Query Builder
- Drizzle ORM - Migrations 공식 가이드
- Drizzle ORM - drizzle-kit push vs generate
- Drizzle ORM - Cloudflare D1 연결
- Drizzle vs Prisma 2026 — Encore
- Drizzle vs Prisma 2026 — Bytebase
- NestJS & DrizzleORM: A Great Match — Trilon Consulting
- Cloudflare D1 + Drizzle ORM at the Edge — DEV Community
- GitHub - drizzle-team/drizzle-orm