Drizzle ORM 실전: SQL에 가까운 TypeScript 쿼리, 마이그레이션, 서버리스·Edge에서 막히는 지점

이 글의 핵심

Drizzle은 TypeScript로 스키마를 정의하고 SQL과 거의 같은 모양으로 쿼리를 쓰는 ORM입니다. 이 글은 CRUD·조인·관계 쿼리·트랜잭션·drizzle-kit 마이그레이션을 예제로 정리하고, v1 베타·RC에서 바뀐 Relational Queries v2, Prisma 7이 Rust 엔진을 없앤 뒤 달라진 Drizzle vs Prisma 비교, 그리고 PgBouncer·Neon HTTP·D1 같은 서버리스 환경에서 트랜잭션과 prepared statement가 막히는 지점을 다룹니다.

Drizzle ORM은 TypeScript로 스키마를 정의하고, SQL과 거의 같은 모양의 쿼리 빌더로 타입 안전하게 쿼리를 쓰는 ORM입니다. 인터넷에서 흔히 보는 “Prisma보다 몇 배 빠르다”, “번들이 10분의 1”류의 비교는 Prisma가 Rust 엔진을 쓰던 시절 이야기이고, 대부분 출처가 불분명한 숫자입니다. Prisma 7이 Rust 엔진을 기본에서 제거한 지금, 두 도구를 가르는 진짜 차이는 쿼리를 SQL처럼 쓰고 읽고 싶은가입니다. 이 글은 그 관점에서 Drizzle의 기본 사용법을 정리하고, 서버리스·Edge 환경에서 실제로 막히는 지점을 함께 다룹니다.

Drizzle ORM이란?

Drizzle ORM은 2022년 출시된 TypeScript 우선 ORM으로, SQL의 단순함과 TypeScript의 타입 안전성을 결합합니다.

핵심 철학과 아키텍처

Drizzle이 추구하는 방향은 한 문장으로 요약할 수 있습니다. “ORM이 SQL을 가리지 않는다”는 것입니다. Prisma는 스키마·클라이언트·마이그레이션을 한 생태계로 묶어 생산성을 높이고, TypeORM은 데코레이터와 리포지토리 패턴으로 객체 지향 쪽에 가깝습니다. 반면 Drizzle은 빌드 타임에 TypeScript로 스키마를 기술하고, 런타임에는 쿼리 빌더가 SQL에 최대한 가깝게 매핑하는 구조입니다. 덕분에 디버깅할 때 “실제로 어떤 SQL이 나갔는지”를 추적하기 쉽고, 복잡한 리포트 쿼리·윈도 함수·DB 특화 문법을 포기할 이유가 줄어듭니다.

아키텍처 측면에서 Drizzle은 드라이버 어댑터와 코어(스키마·drizzle-orm API)를 분리해 두었습니다. drizzle-orm/node-postgres, drizzle-orm/better-sqlite3, drizzle-orm/d1처럼 엔트리가 나뉘는 이유가 바로 이것입니다. 애플리케이션 코드는 동일한 db.select().from() 패턴을 유지하되, 배포 대상(전통 Node 서버, serverless, Edge, 임베디드 SQLite)에 맞는 얇은 연결층만 갈아 끼웁니다. 네이티브 바이너리 의존을 피하는 설계는 Cloudflare D1·Turso 같은 Edge·LibSQL 시나리오와 궁합이 좋습니다. 다만 “올인원” 경험 면에서는 Prisma에 비해 도구 체인을 직접 이어 붙이는 느낌이 남습니다. 이는 단점이자 유연성이기도 합니다.

핵심 특징

1. SQL과 유사한 문법

// Drizzle (SQL과 거의 동일)
const users = await db
  .select()
  .from(usersTable)
  .where(eq(usersTable.age, 25));

// 실제 SQL
SELECT * FROM users WHERE age = 25;

2. 스키마에서 바로 나오는 타입 추론

// 타입이 자동으로 추론됨
const user = await db.select().from(users).limit(1);
// user는 { id: number; name: string; email: string }[]

3. 런타임 의존성이 작음

Drizzle은 코드 생성 단계나 별도 엔진 없이 순수 TypeScript 라이브러리로 동작하고, 실제 DB 통신은 사용자가 고른 드라이버(postgres, pg, mysql2, better-sqlite3, @libsql/client, D1 바인딩 등)가 합니다. prisma generate 같은 빌드 단계가 없어서 스키마를 고치면 타입이 바로 따라오는 것이 체감상 가장 큰 차이입니다. 반면 쿼리 성능은 대부분 DB와 인덱스, 네트워크 왕복이 결정하므로 ORM 교체만으로 API가 빨라지기를 기대하면 실망하기 쉽습니다.

4. 멀티 데이터베이스

  • PostgreSQL
  • MySQL
  • SQLite
  • Cloudflare D1
  • Turso (LibSQL)

Drizzle 시작하기

1. 설치

# Drizzle ORM + PostgreSQL 드라이버
npm install drizzle-orm postgres
npm install -D drizzle-kit

# 또는 다른 DB
npm install drizzle-orm better-sqlite3  # SQLite
npm install drizzle-orm mysql2          # MySQL

2. 스키마 정의

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

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

export const posts = pgTable('posts', {
  id: serial('id').primaryKey(),
  title: text('title').notNull(),
  content: text('content'),
  authorId: integer('author_id')
    .notNull()
    .references(() => users.id, { onDelete: 'cascade' }),
  createdAt: timestamp('created_at').defaultNow(),
});

3. 데이터베이스 연결

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

const connectionString = process.env.DATABASE_URL!;
const client = postgres(connectionString);

export const db = drizzle(client, { schema });

CRUD 작업

Create (INSERT)

// 단일 삽입
const newUser = await db.insert(users).values({
  name: 'Alice',
  email: '[email protected]',
  age: 25,
}).returning();

console.log(newUser); // [{ id: 1, name: 'Alice', ... }]

// 여러 행 삽입
await db.insert(users).values([
  { name: 'Bob', email: '[email protected]', age: 30 },
  { name: 'Charlie', email: '[email protected]', age: 35 },
]);

Read (SELECT)

// 모든 사용자 조회
const allUsers = await db.select().from(users);

// WHERE 조건
import { eq, gt, like, and, or } from 'drizzle-orm';

const adults = await db
  .select()
  .from(users)
  .where(gt(users.age, 18));

// 여러 조건
const result = await db
  .select()
  .from(users)
  .where(
    and(
      gt(users.age, 18),
      like(users.email, '%@gmail.com')
    )
  );

// 특정 필드만 선택
const names = await db
  .select({ name: users.name, email: users.email })
  .from(users);

// 정렬 및 페이징
const page = await db
  .select()
  .from(users)
  .orderBy(users.createdAt)
  .limit(10)
  .offset(20);

Update (UPDATE)

// 단일 업데이트
await db
  .update(users)
  .set({ age: 26 })
  .where(eq(users.id, 1));

// 여러 필드 업데이트
await db
  .update(users)
  .set({ 
    name: 'Alice Smith',
    age: 26,
  })
  .where(eq(users.email, '[email protected]'));

Delete (DELETE)

// 조건부 삭제
await db
  .delete(users)
  .where(eq(users.id, 1));

// 여러 행 삭제
await db
  .delete(users)
  .where(gt(users.age, 100));

관계 (Relations)

스키마에서 관계 정의

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

export const users = pgTable('users', {
  id: serial('id').primaryKey(),
  name: text('name').notNull(),
});

export const posts = pgTable('posts', {
  id: serial('id').primaryKey(),
  title: text('title').notNull(),
  authorId: integer('author_id')
    .notNull()
    .references(() => users.id),
  createdAt: timestamp('created_at').defaultNow(),
});

// 관계 정의
export const usersRelations = relations(users, ({ many }) => ({
  posts: many(posts),
}));

export const postsRelations = relations(posts, ({ one }) => ({
  author: one(users, {
    fields: [posts.authorId],
    references: [users.id],
  }),
}));

관계 쿼리

import { desc, eq } from 'drizzle-orm';

// 사용자와 게시글 함께 조회
const usersWithPosts = await db.query.users.findMany({
  with: {
    posts: true,
  },
});

console.log(usersWithPosts);
// [
//   {
//     id: 1,
//     name: 'Alice',
//     posts: [
//       { id: 1, title: 'First Post', authorId: 1 },
//       { id: 2, title: 'Second Post', authorId: 1 },
//     ]
//   }
// ]

// 필터링된 관계
const userWithRecentPosts = await db.query.users.findFirst({
  where: eq(users.id, 1),
  with: {
    posts: {
      limit: 5,
      orderBy: [desc(posts.createdAt)],
    },
  },
});

JOIN 쿼리

// INNER JOIN
const result = await db
  .select({
    userId: users.id,
    userName: users.name,
    postTitle: posts.title,
  })
  .from(users)
  .innerJoin(posts, eq(users.id, posts.authorId));

// LEFT JOIN
const allUsersWithPosts = await db
  .select()
  .from(users)
  .leftJoin(posts, eq(users.id, posts.authorId));

// 여러 테이블 JOIN
const comments = pgTable('comments', {
  id: serial('id').primaryKey(),
  content: text('content').notNull(),
  postId: integer('post_id').references(() => posts.id),
  userId: integer('user_id').references(() => users.id),
});

const fullData = await db
  .select()
  .from(posts)
  .innerJoin(users, eq(posts.authorId, users.id))
  .leftJoin(comments, eq(posts.id, comments.postId));

쿼리 빌더 심화: 집계·서브쿼리·조인

조인이 SQL과 비슷한 흐름으로 읽힌다는 것이 Drizzle의 강점입니다. 여기에 집계 함수(count, sum, avg, min, max)와 groupBy, having을 얹으면 대시보드·리포트 쿼리를 ORM 안에서 끝내기 쉽습니다.

import { count, desc, sql as dsql } from 'drizzle-orm';

// 게시글 수를 작성자별로 집계
const postsPerAuthor = await db
  .select({
    authorId: posts.authorId,
    postCount: count(posts.id),
  })
  .from(posts)
  .groupBy(posts.authorId)
  .orderBy(desc(count(posts.id)));

// HAVING: 게시글 5개 이상인 작성자만
const prolific = await db
  .select({ authorId: posts.authorId, c: count(posts.id) })
  .from(posts)
  .groupBy(posts.authorId)
  .having(dsql`${count(posts.id)} >= 5`);

서브쿼리는 db.select().from(...).as('alias') 패턴으로 별칭을 만들고, 바깥 쿼리의 from/innerJoin에 끼워 넣는 방식이 흔합니다. 복잡한 경우 Raw SQL 조각(sql\…`)과 병용하는 전략도 실무에서 자주 쓰입니다. Drizzle은 “전부 빌더로만”을 강요하지 않는 편이라, 먼저 빌더로 시도하고 한계가 보이면 sql` 태그로 남은 부분만 덧붙이는 절충이 현실적입니다.

import { sql } from 'drizzle-orm';

// 예: 최근 7일간 일별 게시글 수 (DB에 따라 date_trunc 문법 조정)
const daily = await db.execute(sql`
  select date_trunc('day', ${posts.createdAt}) as day, count(*)::int as cnt
  from ${posts}
  where ${posts.createdAt} > now() - interval '7 days'
  group by 1
  order by 1
`);

sql 태그 안의 ${...}는 문자열로 이어 붙여지는 것이 아니라 바인딩 파라미터로 넘어가므로, 사용자 입력을 넣어도 SQL 인젝션이 생기지 않습니다. 반대로 sql.raw(...)는 값을 그대로 SQL 문자열에 끼워 넣으므로, 정렬 컬럼처럼 식별자를 동적으로 바꿔야 할 때만 허용 목록으로 검증한 값에 한해 써야 합니다. 결과 행의 타입은 db.execute<{ id: number; name: string }>(sql\…`)`처럼 제네릭으로 알려 줄 수 있지만, 이는 컴파일러에게 하는 약속일 뿐 런타임 검증은 아니라는 점도 기억해 두세요.

db.query API는 중첩된 관계를 한 번의 SQL로 가져옵니다(PostgreSQL에서는 JSON 집계를 쓰는 서브쿼리로 만듭니다). 그래서 흔히 말하는 N+1 문제는 생기지 않지만, 관계를 깊게 중첩하거나 with 안에 큰 목록을 끌어오면 SQL 한 방이 매우 무거워집니다. 목록 화면처럼 행 수가 많은 곳에서는 db.query가 만드는 SQL을 로그(drizzle(client, { logger: true }))로 확인하고, 필요하면 명시적 조인이나 id를 모아 inArray로 한 번 더 조회하는 방식으로 바꾸는 것이 좋습니다.

Relational Queries v2 (drizzle-orm v1)

위 예제는 0.x 계열의 relations() 방식입니다. drizzle-orm v1(2025년부터 베타, 이후 RC)에서는 Relational Queries v2가 기본이 되면서 관계 정의 방식이 바뀌었습니다. 테이블마다 relations()를 부르던 것을 defineRelations 하나로 모으고, drizzle()에는 schema 대신 relations를 넘깁니다.

// drizzle-orm v1 (Relational Queries v2)
import { defineRelations } from 'drizzle-orm';
import * as schema from './schema';

export const relations = defineRelations(schema, (r) => ({
  users: {
    posts: r.many.posts(),
  },
  posts: {
    author: r.one.users({
      from: r.posts.authorId,
      to: r.users.id,
    }),
  },
}));

// db = drizzle(client, { relations });

바뀐 점은 fields/references가 from/to로, relationName이 alias로 바뀐 것, 그리고 기존 방식(Relational Queries v1)에서는 many 쪽만 필요해도 반대편에 one을 선언해야 했던 제약이 없어진 것입니다. where 필터도 콜백 대신 객체 문법(where: { id: 1 })을 쓸 수 있게 바뀌었습니다. 기존 코드를 옮길 때는 공식 문서의 “Relational Queries v1 to v2” 가이드를 따라가면 되고, 0.x에 머무는 동안은 위의 relations() 방식이 그대로 동작합니다. 인터넷의 Drizzle 예제는 두 문법이 섞여 있어서, 복사한 코드가 타입 에러를 내면 먼저 설치된 버전(npm ls drizzle-orm)부터 확인하세요.


마이그레이션

스키마 정의와 마이그레이션 전략

프로덕션에서는 “스키마를 코드로만 관리할지”, “SQL 마이그레이션 파일을 리뷰할지”를 먼저 정하는 것이 안전합니다. Drizzle Kit은 둘 다 지원합니다. 팀이 작고 스테이징 DB에서 실수해도 괜찮다면 drizzle-kit push로 스키마를 빠르게 맞출 수 있고, 규모가 커지면 generate로 SQL을 뽑아 PR에 올리고 CI에서 적용·검증하는 흐름이 일반적입니다. 운영 DB에 직접 push하는 습관은 롤백·감사 추적을 어렵게 하므로 피하는 편이 좋습니다.

스키마 파일은 한 파일에 몰아넣기보다 도메인별로 쪼갠 뒤(schema/users.ts, schema/posts.ts) index.ts에서 export하는 패턴이 유지보수에 유리합니다. 외래키·인덱스·부분 유니크 제약까지 TypeScript로 표현할 수 있어 “문서와 실제 DB가 어긋나는” 문제를 줄일 수 있습니다. 다만 PostgreSQL 전용 기능(예: pgEnum, 확장)을 쓰면 드라이버별 API를 익혀야 하므로, 멀티 DB를 지원하는 서비스라면 공통 추상화 범위를 미리 정해 두는 것이 좋습니다.

Drizzle Kit 설정

// drizzle.config.ts (dialect 필드를 쓰는 drizzle-kit 0.21 이후 형식)
import { defineConfig } from 'drizzle-kit';

export default defineConfig({
  schema: './src/db/schema/**/*.ts',
  out: './drizzle',
  dialect: 'postgresql',
  dbCredentials: {
    url: process.env.DATABASE_URL!,
  },
});

마이그레이션 생성 및 실행

# 스키마 변경 → SQL 마이그레이션 생성
npx drizzle-kit generate

# 생성된 마이그레이션을 DB에 적용 (마이그레이션 러너 또는 drizzle-kit migrate)
npx drizzle-kit migrate

# 개발 환경에서 스키마만 빠르게 동기화 (주의: 운영에는 비권장)
npx drizzle-kit push

# 스키마 검증
npx drizzle-kit check

# Drizzle Studio (GUI)
npx drizzle-kit studio

명령 이름은 Drizzle Kit 버전에 따라 다를 수 있습니다. 저장소의 package.json과 공식 마이그레이션 문서를 기준으로 맞추면 됩니다. 팀 표준으로 migrate 스크립트를 고정해 두면 온보딩 비용이 줄어듭니다.


트랜잭션

db.transaction에 넘기는 콜백의 tx는 db와 동일한 API를 제공합니다. 중요한 점은 같은 트랜잭션 안의 모든 읽기·쓰기가 tx를 경유해야 격리가 보장된다는 것입니다. 실수로 콜백 밖의 db를 섞으면 데드락이나 일관성 깨짐이 생기기 쉽습니다.

격리 수준은 드라이버·DB에 따라 db.transaction 옵션으로 넘깁니다(예: PostgreSQL isolationLevel: 'serializable'). 결제·재고같이 경쟁 조건이 큰 도메인에서는 DB 기본값(read committed)만 믿지 말고, 애플리케이션 락·유니크 제약·SELECT FOR UPDATE 등과 함께 설계하는 것이 안전합니다. Drizzle이 해결해 주는 것은 “SQL을 타입 있게 쓰게 해 주는 것”이지, 비즈니스 정합성까지 대신 증명해 주지는 않습니다.

import { eq } from 'drizzle-orm';

// 일반: 예외가 나가면 자동 롤백
await db.transaction(async (tx) => {
  const [user] = await tx.insert(users).values({
    name: 'Alice',
    email: '[email protected]',
  }).returning();

  await tx.insert(posts).values({
    title: 'First Post',
    content: 'Hello World',
    authorId: user.id,
  });
});

// 명시적 롤백이 필요한 분기
await db.transaction(async (tx) => {
  await tx.insert(users).values({
    name: 'Bob',
    email: '[email protected]',
  });
  if (/* 비즈니스 조건 */ false) {
    return tx.rollback();
  }
});

서버리스·짧은 연결 풀 환경에서는 트랜잭션이 오래 열릴수록 커넥션 고갈 위험이 커집니다. 트랜잭션 안에 외부 HTTP 호출이나 느린 작업을 넣지 않는 것이 기본 수칙입니다.

드라이버에 따라 트랜잭션이 안 되는 경우

db.transaction은 드라이버가 하나의 연결을 트랜잭션 동안 붙잡고 있을 수 있어야 동작합니다. 그래서 요청마다 독립적인 HTTP 호출로 쿼리를 보내는 드라이버에서는 쓸 수 없습니다.

  • Neon HTTP 드라이버(drizzle-orm/neon-http): 대화형 트랜잭션을 지원하지 않습니다. 여러 쿼리를 원자적으로 실행하려면 db.batch([...])를 쓰고, 조회 결과에 따라 분기하는 트랜잭션이 필요하면 WebSocket 기반 drizzle-orm/neon-serverless(Pool)로 바꿔야 합니다.
  • Cloudflare D1(drizzle-orm/d1): 마찬가지로 db.transaction 대신 db.batch([...])를 씁니다. 배치 안의 문장은 하나의 트랜잭션으로 실행되지만, 앞 쿼리의 결과를 보고 다음 쿼리를 정하는 로직은 넣을 수 없습니다.

로컬에서는 postgres나 better-sqlite3로 개발하다가 배포할 때만 Neon HTTP나 D1으로 바꾸는 구성에서 이 차이가 늦게 드러납니다. 로컬 테스트는 다 통과했는데 배포 직후 트랜잭션을 쓰는 API만 에러가 나는 식이라, 처음부터 운영과 같은 드라이버로 통합 테스트를 돌리는 편이 안전합니다.


타입 안정성을 실제로 활용하기

Drizzle의 InferSelectModel, InferInsertModel은 스키마 한 곳이 진실의 원천이 되게 해 줍니다. DTO·API 응답 타입을 수동으로 중복 정의하다가 어긋나는 문제를 줄일 수 있습니다.

import { type InferSelectModel, type InferInsertModel } from 'drizzle-orm';

type User = InferSelectModel<typeof users>;
type NewUser = InferInsertModel<typeof users>;

function toPublicUser(u: User) {
  return { id: u.id, name: u.name }; // 민감 필드 제외 시 pick/omit과 조합
}

같은 타입은 typeof users.$inferSelect, typeof users.$inferInsert처럼 테이블에서 바로 꺼낼 수도 있습니다. 부분 컬럼만 고르는 select({ ... })의 결과 타입은 Drizzle이 호출 지점에서 따로 추론해 주므로, 별도 타입을 만들 필요 없이 Awaited<ReturnType<...>>로 가져다 쓰면 as any를 줄일 수 있습니다. 다만 동적 sql 조각이 많아지면 추론이 끊기므로, 복잡한 리포트 쿼리는 Zod로 런타임 검증을 병행하는 팀이 많습니다. 타입이 모든 것을 대신하지는 않는다는 점을 짚고 넘어가겠습니다.


성능: prepared statement와 커넥션 풀

Drizzle 자체는 가볍지만, 병목은 항상 DB와 풀 쪽에 있습니다. postgres.js·node-postgres 등 어댑터는 prepared statement·파이프라이닝 옵션을 드라이버 문서대로 켤 수 있습니다. 같은 형태의 쿼리를 반복하는 핫 패스(세션 조회, 권한 체크)에서는 prepared statement가 유리한 경우가 많습니다.

import { eq } from 'drizzle-orm';
import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
import * as schema from './schema';

const client = postgres(process.env.DATABASE_URL!, { max: 10 }); // 풀 크기
export const db = drizzle(client, { schema });

// 자주 쓰는 쿼리를 함수로 캡슐화 → 실행 계획 캐시와 궁합
export async function getUserById(id: number) {
  return db.select().from(schema.users).where(eq(schema.users.id, id)).limit(1);
}

Drizzle에서 명시적으로 prepared statement를 쓰려면 .prepare('이름')과 sql.placeholder를 씁니다.

import { sql } from 'drizzle-orm';

const userById = db
  .select()
  .from(schema.users)
  .where(eq(schema.users.id, sql.placeholder('id')))
  .prepare('user_by_id');

const [user] = await userById.execute({ id: 42 });

PgBouncer·Supabase 풀러 주의: postgres.js는 기본적으로 쿼리를 이름 있는 prepared statement로 보내는데, PgBouncer의 트랜잭션 풀링 모드나 Supabase의 트랜잭션 모드 풀러(6543 포트) 뒤에서는 연결이 트랜잭션마다 바뀌어서 prepared statement "..." already exists나 does not exist 에러가 간헐적으로 납니다. 부하가 낮은 개발 환경에서는 잘 안 보이다가 동시 요청이 늘면 튀어나오는 종류라 원인을 찾기 까다롭습니다. postgres(url, { prepare: false })로 prepared statement를 끄면 해결됩니다. 이 경우 위의 .prepare()도 서버 측 prepared statement로 동작하지 않는다는 점은 감안해야 합니다. PgBouncer 1.21부터는 max_prepared_statements 설정으로 트랜잭션 모드에서도 프로토콜 수준 prepared statement를 추적할 수 있으므로, 직접 운영하는 PgBouncer라면 버전과 설정을 먼저 확인해 보세요.

커넥션 풀은 “서버리스 함수마다 풀을 새로” 만들면 오히려 역효과가 날 수 있습니다. Vercel·Lambda에서는 런타임 인스턴스당 풀 크기와 최대 동시 실행 수의 균형을 잡아야 하며, 가능하면 Supabase/Neon 같은 풀러 프록시를 쓰는 선택지도 있습니다. Edge(D1)는 모델이 달라서, Node에서의 풀 튜닝과 같은 가정을 하면 안 됩니다.


Drizzle Studio (GUI)

# Drizzle Studio 실행
npx drizzle-kit studio

# 로컬 서버(기본 포트 4983)가 뜨고, 브라우저에서 https://local.drizzle.studio 로 접속

기능

  • 데이터베이스 브라우징
  • CRUD 작업
  • SQL 쿼리 실행
  • 스키마 시각화

Edge Runtime 지원 (Cloudflare D1, Turso, Vercel)

Edge에서 Drizzle이 자주 언급되는 이유는 런타임이 제한된 환경에서도 “그냥 import해서” 쓸 수 있기 때문입니다. Prisma는 Rust 엔진 바이너리 때문에 오랫동안 Workers 배포에 부담이 있었고, 그사이 Drizzle·Kysely 계열이 “SQL + TS 타입” 쪽 대안으로 자리 잡았습니다. Prisma 7부터는 Rust 엔진 없이 TypeScript + WASM 쿼리 컴파일러와 드라이버 어댑터로 동작해 Edge에서도 쓸 수 있게 되었으므로, “Edge라서 Drizzle”이라는 논리는 예전만큼 강하지 않습니다.

Cloudflare D1

D1은 SQLite 호환 API를 Workers에 붙인 서비스입니다. Drizzle은 drizzle-orm/d1 엔트리로 D1을 감쌉니다. 주의할 점은 (1) SQLite 문법·제약, (2) D1의 읽기 일관성 지연과 한계, (3) 마이그레이션을 로컬 wrangler로 돌릴지 CI에서 돌릴지에 대한 운영 설계입니다. D1은 “소규모·엣지 쪽 캐시·설정”에는 강하지만, 복잡한 트랜잭션·고부하 OLTP를 그대로 올리기에는 Node+관리형 Postgres 조합과 비교해 트레이드오프가 있습니다.

// worker.ts
import { drizzle } from 'drizzle-orm/d1';
import * as schema from './schema';

interface Env {
  DB: D1Database;
}

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const db = drizzle(env.DB, { schema });

    const users = await db.select().from(schema.users);

    return Response.json(users);
  },
};

Turso (LibSQL)와 @libsql/client

Turso는 LibSQL을 기반으로 한 원격·복제형 SQLite입니다. 드라이버로 @libsql/client를 쓰며, Drizzle의 drizzle-orm/libsql로 연결하는 패턴이 흔합니다. 로컬 개발·테스트는 임베디드로, 스테이징/프로덕션은 URL로 붙이는 흐름이 자연스럽습니다. 다만 “Postgres에 익숙한 팀”이 SQLite로 옮겨 올 때 동시 쓰기·잠금·제약 차이를 과소평가하기 쉬우니, 스키마 설계를 처음부터 Edge용으로 가져갈지 결정해야 합니다.

import { drizzle } from 'drizzle-orm/libsql';
import { createClient } from '@libsql/client';
import * as schema from './schema';

const client = createClient({ url: process.env.TURSO_URL!, authToken: process.env.TURSO_AUTH_TOKEN });
export const db = drizzle(client, { schema });

Vercel·Edge에서 Postgres (Neon)

Vercel Postgres는 2024년 말 Neon으로 이전되었고 @vercel/postgres 패키지도 더는 권장되지 않습니다. 지금은 Neon의 서버리스 드라이버를 직접 쓰는 것이 일반적입니다. DB가 Edge에 “가깝다”는 것과 “Edge 함수가 늘 빠르다”는 것은 별개이므로, 쿼리 지연과 콜드 스타트는 실제 배포 환경에서 직접 확인해야 합니다. Edge 함수가 사용자 근처에서 실행되더라도 DB가 한 리전에 있으면 쿼리마다 먼 왕복이 생기므로, 쿼리를 여러 번 순차로 보내는 API는 오히려 리전 고정 함수보다 느릴 수 있습니다.

// app/api/users/route.ts
import { drizzle } from 'drizzle-orm/neon-http';
import { neon } from '@neondatabase/serverless';
import * as schema from '@/db/schema';

export const runtime = 'edge';

const db = drizzle(neon(process.env.DATABASE_URL!), { schema });

export async function GET() {
  const users = await db.select().from(schema.users);
  return Response.json(users);
}

앞에서 설명했듯 neon-http는 대화형 트랜잭션을 지원하지 않습니다.


Prisma·TypeORM과의 비교

축DrizzlePrismaTypeORM
SQL 가시성높음 (빌더가 SQL에 가까움)중간 (런타임·쿼리 계획이 숨는 편)낮~중 (Repository·QB 추상)
온보딩·DXSQL 친화 팀에 유리문서·스튜디오·생태계가 두터움데코레이터·NestJS와 궁합
번들/Edge가벼움, 코드 생성 없음Prisma 7부터 Rust 엔진 제거, 드라이버 어댑터로 Edge 가능Node 중심, Edge는 부담
마이그레이션Drizzle Kit, SQL 파일 중심Prisma Migrate가 성숙typeorm migration / cli
리팩터링스키마 TS가 진실schema.prisma + client 생성엔티티 데코레이터 + 메타데이터

Prisma는 prisma generate·relation API·PrismaClient로 이어지는 일관된 스토리가 강점입니다. 대규모 팀에서 “DB 담당·백엔드·프론트”가 모두 Prisma에 익숙하다면 속도가 납니다. 반면 복잡한 raw SQL이 잦고 Edge 배포가 필수라면 Drizzle 쪽의 설득력이 올라갑니다.

TypeORM은 NestJS와 함께 쓰는 레거시·엔터프라이즈 코드가 많습니다. ActiveRecord/Repository 패턴에 익숙하다면 편하지만, 타입이 끊기기 쉬운 부분과 런타임 데코레이터 의존을 부담스러워하는 팀도 있습니다. “점진적 교체”를 하려면 새 모듈만 Drizzle로 쓰는 방식이 현실적입니다.

Drizzle을 만능이라고 말하고 싶지는 않습니다. 관계 로딩·시드·백오피스 도구까지 한 번에 해결하려는 팀에게는 Prisma가 여전히 편할 수 있습니다. Drizzle의 이점은 쿼리가 길어져도 “그것이 곧 SQL”이라 팀이 함께 읽을 수 있다는 점, 그리고 Edge·SQLite·LibSQL과의 조합에 있습니다.

Prisma에서 Drizzle로 옮길 때의 순서

한 번에 전부 바꾸는 방식은 권하지 않습니다. DB는 그대로 두고 데이터 접근 레이어만 단계적으로 교체하는 편이 안전합니다. 무리가 적은 순서는 다음과 같습니다.

  1. Drizzle 스키마를 새 기준으로 정합니다. schema.prisma와 schema.ts를 동시에 고치기 시작하면 둘이 금방 어긋납니다. 옮기기로 한 시점부터 schema.prisma는 참고용으로만 두고, 테이블 정의는 실제 DB 상태를 기준으로 옮깁니다(drizzle-kit pull로 기존 DB에서 스키마를 뽑아 시작할 수도 있습니다).
  2. 읽기 경로부터 옮깁니다. 복잡한 조회부터 Drizzle select나 db.query로 바꾸되, 응답 형태(DTO)는 기존과 똑같이 맞춰 프론트엔드와 테스트가 영향을 받지 않게 합니다.
  3. 쓰기와 트랜잭션은 다시 점검하며 옮깁니다. Prisma가 암묵적으로 처리해 주던 부분(유니크 충돌 에러 형태, 중첩 쓰기)이 Drizzle에서는 명시적인 코드가 되므로, 에러 처리와 멱등성을 이 단계에서 다시 확인합니다.
  4. 마이그레이션 도구를 하나로 정합니다. Prisma Migrate와 drizzle-kit이 같은 DB에 번갈아 마이그레이션을 쓰면 이력이 꼬입니다. 전환 시점을 정해 그 이후로는 drizzle-kit generate → 스테이징 적용 → 프로덕션 순서만 씁니다.
  5. 마지막에 Prisma 의존성을 제거합니다. import가 남은 곳을 줄여 가다가 패키지를 지웁니다.

문법이 비슷해 보여도 트랜잭션, 관계 로딩, 에러의 모양이 다르기 때문에 “라이브러리 교체”보다는 “데이터 레이어 재작성”에 가깝다고 보고 일정을 잡는 편이 맞습니다.


도입 후 자주 부딪히는 점

Drizzle을 새로 도입하면 비슷한 지점에서 마찰이 생깁니다. 첫째, db.query로 관계를 끌어오는 경로와 select+join을 직접 쓰는 경로가 공존하기 때문에, 어느 쪽을 언제 쓸지 팀 규칙이 없으면 쿼리 스타일이 사람마다 갈라집니다. 둘째, 성능 문제는 ORM보다 인덱스 누락, 과하게 중첩한 관계 로딩, 커넥션 풀 설정에서 오는 경우가 대부분이며, SQL이 드러나는 구조라 EXPLAIN으로 확인하기는 쉽습니다. 셋째, drizzle-kit은 0.21 무렵 generate:pg 같은 방언별 명령을 generate 하나로 합치는 등 CLI가 바뀐 적이 있으므로, package.json에서 버전을 고정하고 업그레이드는 변경 로그를 확인한 뒤 하는 편이 안전합니다. 넷째, Edge+D1 조합은 트랜잭션이 배치로 제한되므로, 강한 일관성이 필요한 결제 같은 흐름은 별도의 설계(단일 쓰기 DB, 아웃박스 패턴 등)를 함께 고려해야 합니다.

정리하면 Drizzle은 SQL을 숨기지 않으므로 SQL에 익숙하지 않은 팀에게는 오히려 비용이 될 수 있고, SQL·인덱스·실행 계획을 일상적으로 이야기하는 팀이라면 타입과의 결합이 큰 이득이 됩니다.


실전 프로젝트: 블로그 API

스키마 정의

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

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

export const posts = pgTable('posts', {
  id: serial('id').primaryKey(),
  title: text('title').notNull(),
  slug: text('slug').notNull().unique(),
  content: text('content').notNull(),
  published: boolean('published').default(false),
  authorId: integer('author_id')
    .notNull()
    .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')
    .notNull()
    .references(() => posts.id, { onDelete: 'cascade' }),
  userId: integer('user_id')
    .notNull()
    .references(() => users.id),
  createdAt: timestamp('created_at').defaultNow(),
});

API 라우트

// app/api/posts/route.ts
import { db } from '@/db';
import { posts, users } from '@/db/schema';
import { eq } from 'drizzle-orm';

// GET /api/posts
export async function GET() {
  const allPosts = await db
    .select({
      id: posts.id,
      title: posts.title,
      slug: posts.slug,
      author: users.username,
      createdAt: posts.createdAt,
    })
    .from(posts)
    .innerJoin(users, eq(posts.authorId, users.id))
    .where(eq(posts.published, true))
    .orderBy(posts.createdAt);

  return Response.json(allPosts);
}

// POST /api/posts
export async function POST(request: Request) {
  const body = await request.json();

  const [post] = await db.insert(posts).values({
    title: body.title,
    slug: body.slug,
    content: body.content,
    authorId: body.authorId,
  }).returning();

  return Response.json(post, { status: 201 });
}

같이 보면 좋은 글