Drizzle ORM - 타입스크립트 우선 SQL 툴킷

DrizzleORMTypeScriptPostgreSQL데이터베이스

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 Config
npx drizzle-kit generate   # 스키마 변경분을 SQL 파일로 생성
npx drizzle-kit migrate    # 생성된 마이그레이션 적용
npx drizzle-kit studio     # 브라우저에서 데이터 확인

생성물이 읽을 수 있는 순수 SQL 파일이라는 점이 중요합니다. 적용 전에 검토할 수 있고, 필요하면 손으로 고칠 수 있으며, 나중에 Drizzle을 걷어내도 마이그레이션 기록은 그대로 남습니다.

컬럼 이름을 바꿀 때

도구가 판단할 수 없는 대표적인 경우입니다. 스키마에서 namefull_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이 최근 주목받는 가장 큰 이유입니다.

PrismaDrizzle
런타임별도 쿼리 엔진 바이너리순수 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

쿼리는 손으로 옮겨야 합니다. 사고방식이 다르기 때문입니다.

PrismaDrizzle
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이 잘 맞고, 후자라면 추상화가 두꺼운 도구가 낫습니다. 어느 쪽도 틀린 답이 아닙니다.

궁금한 점이 있으신가요?

협업·의뢰는 아래로, 가벼운 소통은 인스타그램 @bluefox._.hi도 환영이에요.

비용이 궁금하면 외주 계산기로 먼저 확인해보세요.