Drizzle ORM + Cloudflare D1 엣지

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(*)`));

프로덕션 체크리스트

  1. 마이그레이션 순서: 반드시 wrangler d1 migrations applywrangler deploy 순서로 배포
  2. D1 크기 제한: 단일 DB 최대 10GB, 행당 최대 1MB. 대용량 데이터는 R2와 조합
  3. 쓰기 제한: D1은 단일 리전 쓰기. 쓰기 빈도가 높으면 PostgreSQL 고려
  4. 에러 핸들링: D1은 SQLite 에러 코드 반환. UNIQUE 제약 위반 등 처리 필수
  5. 로컬 개발: wrangler dev --local로 로컬 D1 사용. 프로덕션 데이터와 분리
  6. 백업: wrangler d1 export로 정기 백업 자동화

Drizzle + D1 조합은 읽기 중심 API에서 글로벌 초저지연을 달성할 수 있는 강력한 스택입니다. Drizzle ORM의 스키마 설계 패턴은 Drizzle ORM Schema 설계 심화 가이드를, 마이그레이션 운영은 Drizzle Kit Migration 운영 글을 참고하세요.

위로 스크롤
WordPress Appliance - Powered by TurnKey Linux