Drizzle ORM + Cloudflare D1이란?
Cloudflare D1은 Cloudflare Workers 엣지 네트워크에서 실행되는 서버리스 SQLite 데이터베이스입니다. Drizzle ORM과 조합하면 타입 안전한 SQL 쿼리를 전 세계 엣지에서 초저지연으로 실행할 수 있습니다. 기존 Drizzle + Turso(libSQL) 조합과 달리, D1은 Cloudflare 인프라에 완전히 통합되어 Workers에서 바인딩으로 직접 접근합니다.
Turso vs D1 비교
| 비교 항목 | Drizzle + Turso | Drizzle + D1 |
|---|---|---|
| 프로토콜 | libSQL HTTP/WebSocket | Workers 바인딩 (네트워크 불필요) |
| 레이턴시 | 리전 기반 ~10ms | 같은 PoP 내 ~1ms |
| 복제 | Embedded Replica | 자동 글로벌 읽기 복제 |
| 런타임 | Node.js, Edge 모두 | Cloudflare Workers 전용 |
| 가격 | 읽기/쓰기 행 기반 | 읽기 500만 무료/일 |
| 적합한 상황 | 멀티 런타임, Vercel Edge | Cloudflare 풀스택 |
프로젝트 초기 설정
# Cloudflare Workers + Drizzle 프로젝트 생성
npm create cloudflare@latest my-api -- --template worker
cd my-api
npm install drizzle-orm
npm install -D drizzle-kit @cloudflare/workers-types
# wrangler.toml — D1 바인딩
[[d1_databases]]
binding = "DB"
database_name = "my-app-db"
database_id = "xxxx-xxxx-xxxx"
# 프로젝트 구조
├── src/
│ ├── index.ts # Worker 엔트리
│ ├── db/
│ │ ├── schema.ts # Drizzle 스키마
│ │ ├── relations.ts # 관계 정의
│ │ └── index.ts # DB 인스턴스 생성
│ └── routes/
│ ├── users.ts
│ └── posts.ts
├── drizzle/
│ └── migrations/ # 마이그레이션 파일
├── drizzle.config.ts
└── wrangler.toml
스키마 정의: SQLite 특화
D1은 SQLite 기반이므로 Drizzle의 SQLite 전용 스키마 빌더를 사용합니다.
// src/db/schema.ts
import { sqliteTable, text, integer, real, index, uniqueIndex } from 'drizzle-orm/sqlite-core';
import { sql } from 'drizzle-orm';
export const users = sqliteTable('users', {
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
email: text('email').notNull(),
name: text('name').notNull(),
role: text('role', { enum: ['admin', 'user', 'editor'] })
.notNull().default('user'),
metadata: text('metadata', { mode: 'json' }).$type<{
avatar?: string;
locale?: string;
preferences?: Record<string, unknown>;
}>(),
createdAt: text('created_at').notNull()
.default(sql`(datetime('now'))`),
updatedAt: text('updated_at').notNull()
.default(sql`(datetime('now'))`),
}, (table) => ({
emailIdx: uniqueIndex('email_idx').on(table.email),
roleIdx: index('role_idx').on(table.role),
}));
export const posts = sqliteTable('posts', {
id: integer('id').primaryKey({ autoIncrement: true }),
title: text('title').notNull(),
slug: text('slug').notNull(),
content: text('content').notNull(),
status: text('status', { enum: ['draft', 'published', 'archived'] })
.notNull().default('draft'),
authorId: text('author_id').notNull()
.references(() => users.id, { onDelete: 'cascade' }),
viewCount: integer('view_count').notNull().default(0),
publishedAt: text('published_at'),
createdAt: text('created_at').notNull()
.default(sql`(datetime('now'))`),
}, (table) => ({
slugIdx: uniqueIndex('slug_idx').on(table.slug),
authorIdx: index('author_idx').on(table.authorId),
statusIdx: index('status_idx').on(table.status),
}));
export const comments = sqliteTable('comments', {
id: integer('id').primaryKey({ autoIncrement: true }),
body: text('body').notNull(),
postId: integer('post_id').notNull()
.references(() => posts.id, { onDelete: 'cascade' }),
authorId: text('author_id').notNull()
.references(() => users.id),
parentId: integer('parent_id'), // 대댓글
createdAt: text('created_at').notNull()
.default(sql`(datetime('now'))`),
});
관계 정의와 Relational Query
// src/db/relations.ts
import { relations } from 'drizzle-orm';
import { users, posts, comments } from './schema';
export const usersRelations = relations(users, ({ many }) => ({
posts: many(posts),
comments: many(comments),
}));
export const postsRelations = relations(posts, ({ one, many }) => ({
author: one(users, {
fields: [posts.authorId],
references: [users.id],
}),
comments: many(comments),
}));
export const commentsRelations = relations(comments, ({ one }) => ({
post: one(posts, {
fields: [comments.postId],
references: [posts.id],
}),
author: one(users, {
fields: [comments.authorId],
references: [users.id],
}),
parent: one(comments, {
fields: [comments.parentId],
references: [comments.id],
relationName: 'replies',
}),
}));
// Relational Query 사용
const postsWithComments = await db.query.posts.findMany({
where: eq(posts.status, 'published'),
with: {
author: { columns: { id: true, name: true } },
comments: {
with: { author: { columns: { name: true } } },
orderBy: [desc(comments.createdAt)],
limit: 10,
},
},
orderBy: [desc(posts.publishedAt)],
limit: 20,
});
Worker에서 Drizzle 인스턴스 생성
D1 바인딩을 Drizzle에 연결하는 방법입니다.
// src/db/index.ts
import { drizzle } from 'drizzle-orm/d1';
import * as schema from './schema';
import * as relations from './relations';
export function createDb(d1: D1Database) {
return drizzle(d1, {
schema: { ...schema, ...relations },
logger: true, // 개발 시 SQL 로깅
});
}
export type Database = ReturnType<typeof createDb>;
// src/index.ts — Worker 엔트리
export interface Env {
DB: D1Database;
ENVIRONMENT: string;
}
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const db = createDb(env.DB);
const url = new URL(request.url);
try {
if (url.pathname === '/api/posts' && request.method === 'GET') {
return handleGetPosts(db, url);
}
if (url.pathname === '/api/posts' && request.method === 'POST') {
return handleCreatePost(db, request);
}
return new Response('Not Found', { status: 404 });
} catch (error) {
return new Response(
JSON.stringify({ error: 'Internal Server Error' }),
{ status: 500, headers: { 'Content-Type': 'application/json' } }
);
}
},
};
// 라우트 핸들러
async function handleGetPosts(db: Database, url: URL) {
const status = url.searchParams.get('status') || 'published';
const page = parseInt(url.searchParams.get('page') || '1');
const limit = 20;
const results = await db.query.posts.findMany({
where: eq(posts.status, status),
with: {
author: { columns: { id: true, name: true } },
},
orderBy: [desc(posts.publishedAt)],
limit,
offset: (page - 1) * limit,
});
return Response.json(results, {
headers: { 'Cache-Control': 'public, max-age=60' },
});
}
마이그레이션 운영
D1 마이그레이션은 Drizzle Kit으로 생성하고 Wrangler로 적용합니다.
// drizzle.config.ts
import { defineConfig } from 'drizzle-kit';
export default defineConfig({
schema: './src/db/schema.ts',
out: './drizzle/migrations',
dialect: 'sqlite',
});
# 마이그레이션 생성
npx drizzle-kit generate
# 로컬 D1에 적용 (개발)
npx wrangler d1 migrations apply my-app-db --local
# 프로덕션 D1에 적용
npx wrangler d1 migrations apply my-app-db --remote
# CI/CD 파이프라인 예시 (GitHub Actions)
# - name: Apply D1 Migrations
# run: npx wrangler d1 migrations apply my-app-db --remote
# env:
# CLOUDFLARE_API_TOKEN: ${{ secrets.CF_API_TOKEN }}
# - name: Deploy Worker
# run: npx wrangler deploy
D1 배치 API로 트랜잭션 처리
D1은 전통적인 BEGIN/COMMIT 트랜잭션 대신 배치 API를 사용합니다. Drizzle에서 활용하는 방법입니다.
// D1 배치: 여러 쿼리를 원자적으로 실행
async function createPostWithTags(
db: Database,
input: CreatePostInput,
) {
// Drizzle의 batch API 활용
const results = await db.batch([
db.insert(posts).values({
title: input.title,
slug: input.slug,
content: input.content,
authorId: input.authorId,
status: 'draft',
}).returning(),
...input.tagIds.map(tagId =>
db.insert(postTags).values({
postId: sql`last_insert_rowid()`,
tagId,
})
),
]);
return results[0][0]; // 생성된 post
}
// 조건부 업데이트 + 카운터 증가
async function publishPost(db: Database, postId: number) {
const result = await db.batch([
db.update(posts)
.set({
status: 'published',
publishedAt: sql`datetime('now')`,
})
.where(and(
eq(posts.id, postId),
eq(posts.status, 'draft'),
))
.returning(),
db.update(users)
.set({
metadata: sql`json_set(metadata, '$.postCount',
json_extract(metadata, '$.postCount') + 1)`,
})
.where(eq(users.id, sql`(
SELECT author_id FROM posts WHERE id = ${postId}
)`)),
]);
if (result[0].length === 0) {
throw new Error('게시글을 찾을 수 없거나 이미 발행됨');
}
return result[0][0];
}
Hono 프레임워크 통합
Cloudflare Workers에서 가장 많이 사용되는 Hono 프레임워크와 Drizzle을 통합하는 패턴입니다.
// src/index.ts — Hono + Drizzle + D1
import { Hono } from 'hono';
import { drizzle } from 'drizzle-orm/d1';
import * as schema from './db/schema';
type Bindings = { DB: D1Database };
const app = new Hono<{ Bindings: Bindings }>();
// 미들웨어: 요청마다 DB 인스턴스 생성
app.use('*', async (c, next) => {
c.set('db', drizzle(c.env.DB, { schema }));
await next();
});
// GET /api/posts?status=published&cursor=xxx
app.get('/api/posts', async (c) => {
const db = c.get('db');
const status = c.req.query('status') || 'published';
const cursor = c.req.query('cursor');
const limit = 20;
let query = db.select()
.from(posts)
.where(eq(posts.status, status))
.orderBy(desc(posts.createdAt))
.limit(limit + 1); // 다음 페이지 존재 여부 확인
if (cursor) {
query = query.where(lt(posts.createdAt, cursor));
}
const results = await query;
const hasNext = results.length > limit;
const items = hasNext ? results.slice(0, -1) : results;
return c.json({
data: items,
nextCursor: hasNext ? items[items.length - 1].createdAt : null,
});
});
// POST /api/posts
app.post('/api/posts', async (c) => {
const db = c.get('db');
const body = await c.req.json();
const [post] = await db.insert(posts)
.values({
title: body.title,
slug: body.slug,
content: body.content,
authorId: body.authorId,
})
.returning();
return c.json(post, 201);
});
export default app;
성능 최적화 패턴
D1 엣지 환경에서의 Drizzle 쿼리 최적화 기법입니다.
// 1. Prepared Statement 캐싱
const getPostBySlug = db.query.posts.findFirst({
where: eq(posts.slug, sql.placeholder('slug')),
with: { author: true },
}).prepare();
// 재사용: 파싱 오버헤드 제거
const post = await getPostBySlug.execute({ slug: 'my-post' });
// 2. 선택적 컬럼 조회 (네트워크 페이로드 최소화)
const postList = await db.select({
id: posts.id,
title: posts.title,
slug: posts.slug,
publishedAt: posts.publishedAt,
authorName: users.name,
}).from(posts)
.innerJoin(users, eq(posts.authorId, users.id))
.where(eq(posts.status, 'published'));
// 3. SQLite JSON 함수 활용
const usersWithPrefs = await db.select({
id: users.id,
name: users.name,
locale: sql<string>`json_extract(${users.metadata}, '$.locale')`,
theme: sql<string>`json_extract(${users.metadata}, '$.preferences.theme')`,
}).from(users)
.where(sql`json_extract(${users.metadata}, '$.locale') = 'ko'`);
// 4. 집계 + 서브쿼리
const authorStats = await db.select({
authorId: posts.authorId,
authorName: users.name,
totalPosts: sql<number>`count(*)`,
totalViews: sql<number>`sum(${posts.viewCount})`,
latestPost: sql<string>`max(${posts.publishedAt})`,
}).from(posts)
.innerJoin(users, eq(posts.authorId, users.id))
.where(eq(posts.status, 'published'))
.groupBy(posts.authorId)
.orderBy(desc(sql`count(*)`));
프로덕션 체크리스트
- 마이그레이션 순서: 반드시
wrangler d1 migrations apply→wrangler deploy순서로 배포 - D1 크기 제한: 단일 DB 최대 10GB, 행당 최대 1MB. 대용량 데이터는 R2와 조합
- 쓰기 제한: D1은 단일 리전 쓰기. 쓰기 빈도가 높으면 PostgreSQL 고려
- 에러 핸들링: D1은 SQLite 에러 코드 반환. UNIQUE 제약 위반 등 처리 필수
- 로컬 개발:
wrangler dev --local로 로컬 D1 사용. 프로덕션 데이터와 분리 - 백업:
wrangler d1 export로 정기 백업 자동화
Drizzle + D1 조합은 읽기 중심 API에서 글로벌 초저지연을 달성할 수 있는 강력한 스택입니다. Drizzle ORM의 스키마 설계 패턴은 Drizzle ORM Schema 설계 심화 가이드를, 마이그레이션 운영은 Drizzle Kit Migration 운영 글을 참고하세요.