Cloudflare D1과 Drizzle ORM으로 엣지 네이티브 SQLite 백엔드 구축하기
서버를 따로 관리하지 않으면서도 전 세계 사용자에게 낮은 읽기 레이턴시를 제공하는 데이터베이스가 필요하다면, 그 조합이 지금 꽤 현실적인 선택지가 됐습니다. Cloudflare D1은 2025년 4월 글로벌 읽기 복제(Global Read Replication)를 공개 베타로 출시하면서 전 세계 300개 이상의 Cloudflare 데이터센터에서 SQLite 쿼리를 실행하는 길을 열었습니다. 서울 사용자는 서울 복제본에서, 도쿄 사용자는 도쿄 복제본에서 읽습니다. 별도 설정 없이요.
이 글에서는 D1의 아키텍처와 일관성 모델, Drizzle ORM을 통한 타입 세이프 쿼리 설계, 로컬 개발부터 프로덕션 마이그레이션까지의 전체 워크플로우를 함께 살펴봅니다. "쓰기 병목은 어떻게 다루나", "행 스캔 과금에서 어떻게 살아남나", "방금 쓴 데이터를 읽기 복제본에서 어떻게 보장하나" 같은 현실적인 질문에 초점을 맞춥니다.
D1 아키텍처와 일관성 모델
읽기 복제의 작동 방식
전통적인 중앙화 데이터베이스는 물리적으로 특정 리전에 존재합니다. 서울 사용자가 버지니아 리전의 PostgreSQL에 쿼리를 보내면, 광케이블을 왕복하는 동안 수십~수백 ms가 지납니다. D1은 읽기 복제본을 전 세계에 자동 분산해 이 구조를 바꿉니다.
쓰기는 단일 Primary 노드를 통과합니다. 읽기는 요청을 보낸 사용자와 가장 가까운 복제본에서 처리됩니다. Cloudflare가 라우팅을 자동으로 담당하며, 글로벌 읽기 복제는 Cloudflare 대시보드의 해당 D1 데이터베이스 설정 페이지에서 활성화합니다.
Workers는 env.DB 바인딩 하나로 이 데이터베이스에 접근합니다. TCP 연결도, 커넥션 풀도, 드라이버 설치도 필요 없습니다. V8 Isolates 아키텍처 덕분에 콜드 스타트가 없고, 바인딩을 통한 D1 접근은 같은 Cloudflare 인프라 위에서 이루어지므로 네트워크 홉도 최소화됩니다.
Sessions API와 read-after-write 일관성
글로벌 복제가 생기면서 새로운 문제가 생겼습니다. 사용자가 데이터를 쓴 직후 읽으면, 복제 지연 때문에 방금 쓴 데이터가 보이지 않을 수 있습니다. 이를 read-after-write 불일치, 또는 read-your-own-writes 실패라고 합니다. D1의 Sessions API는 이 문제를 해결합니다.
Sessions API를 사용하면 쓰기 완료 후 **북마크(bookmark)**를 받아 다음 읽기 요청에 첨부합니다. 복제본은 이 북마크를 기준으로 "적어도 이만큼 최신" 상태의 데이터를 반환하도록 보장합니다. 소셜 피드, 사용자 설정 변경, 쇼핑카트처럼 "내가 저장한 건 바로 보여야 하는" 시나리오에서 특히 중요합니다.
Drizzle이 Workers 환경에 맞는 이유
Workers 환경에서 ORM을 고를 때 Drizzle이 유리한 이유는 세 가지입니다.
번들 크기: Prisma는 별도 바이너리를 필요로 해 Workers에서 작동하지 않거나 복잡한 우회가 필요합니다. Drizzle은 순수 TypeScript라 그냥 동작합니다.
SQL 투명성: Drizzle의 철학은 SQL을 감추지 않고 TypeScript로 표현하는 것입니다. ORM이 생성하는 쿼리가 예측 가능해서 D1 같은 특수 환경에서도 디버깅이 쉽습니다.
D1 네이티브 드라이버: drizzle-orm/d1를 공식 지원합니다. 어댑터를 직접 만들 필요 없이 공식 경로로 연결됩니다.
프로젝트 설정
패키지 설치와 D1 생성
bun create cloudflare@latest my-app --template=hono
cd my-app
bun add drizzle-orm
bun add -d drizzle-kit wrangler
# D1 데이터베이스 생성 — 출력된 database_id를 wrangler.toml에 붙여넣기
wrangler d1 create my-app-dbwrangler.toml에 D1 바인딩을 추가합니다.
name = "my-app"
compatibility_date = "2024-09-23"
[[d1_databases]]
binding = "DB"
database_name = "my-app-db"
database_id = "wrangler-d1-create-결과로-받은-id"스키마 정의
src/db/schema.ts에 Drizzle 스키마를 작성합니다. D1이 SQLite 기반이므로 drizzle-orm/sqlite-core를 사용합니다.
import { sqliteTable, text, integer, index } from 'drizzle-orm/sqlite-core'
export const users = sqliteTable('users', {
id: integer('id').primaryKey({ autoIncrement: true }),
email: text('email').notNull().unique(),
name: text('name').notNull(),
postCount: integer('post_count').notNull().default(0),
})
export const posts = sqliteTable('posts', {
id: integer('id').primaryKey({ autoIncrement: true }),
title: text('title').notNull(),
slug: text('slug').notNull().unique(),
content: text('content'),
authorId: integer('author_id').notNull(),
publishedAt: integer('published_at', { mode: 'timestamp' }),
createdAt: integer('created_at', { mode: 'timestamp' })
.notNull()
.$defaultFn(() => new Date()),
}, (table) => ({
authorIdx: index('idx_posts_author_id').on(table.authorId),
}))drizzle.config.ts는 drizzle-kit push, drizzle-kit studio 등 drizzle-kit이 D1에 직접 접근할 때 필요합니다. 마이그레이션 파일 생성(generate)만 한다면 이 자격증명은 쓰이지 않습니다.
import type { Config } from 'drizzle-kit'
export default {
schema: './src/db/schema.ts',
out: './migrations',
dialect: 'sqlite',
driver: 'd1-http',
dbCredentials: {
accountId: process.env.CLOUDFLARE_ACCOUNT_ID!,
databaseId: process.env.CLOUDFLARE_D1_DATABASE_ID!,
token: process.env.CLOUDFLARE_D1_TOKEN!,
},
} satisfies ConfigHono와 연결하기
drizzle(c.env.DB, { schema })로 인스턴스를 만들 때 schema를 함께 전달해야 Drizzle의 관계 쿼리(RQB) 기능을 온전히 사용할 수 있습니다. schema 없이 초기화하면 기본 CRUD는 동작하지만, db.query.* 형태의 관계형 쿼리는 쓸 수 없게 됩니다.
import { Hono } from 'hono'
import { drizzle } from 'drizzle-orm/d1'
import * as schema from './db/schema'
import { posts, users } from './db/schema'
import { eq, desc, sql } from 'drizzle-orm'
type Env = {
DB: D1Database
}
const app = new Hono<{ Bindings: Env }>()
app.get('/posts', async (c) => {
const db = drizzle(c.env.DB, { schema })
const result = await db
.select({
id: posts.id,
title: posts.title,
slug: posts.slug,
authorName: users.name,
})
.from(posts)
.leftJoin(users, eq(posts.authorId, users.id))
.orderBy(desc(posts.createdAt))
.limit(20)
return c.json(result)
})
export default app반환 타입은 별도 선언 없이 TypeScript가 완전하게 추론합니다. result 위에 마우스를 올리면 { id: number; title: string; slug: string; authorName: string | null }[]가 자동으로 맞춰져 있습니다.
핵심 패턴
Sessions API 활용하기
글로벌 읽기 복제 환경에서 쓰기 직후 읽기가 필요한 엔드포인트는 Sessions API를 사용합니다. env.DB.withSession()으로 세션을 시작하고, 쓰기 완료 후 session.getBookmark()로 현재 복제 위치를 북마크로 가져옵니다. D1DatabaseSession은 D1Database를 구현하므로 drizzle(session, { schema })로 그대로 전달할 수 있습니다.
// POST /settings — 쓰기 후 북마크 반환
app.post('/settings', async (c) => {
const body = await c.req.json<{ userId: number; name: string }>()
const session = c.env.DB.withSession('first-unconstrained')
const db = drizzle(session, { schema })
await db.update(users)
.set({ name: body.name })
.where(eq(users.id, body.userId))
// 이 시점 이후 복제 상태를 나타내는 불투명한 문자열
const bookmark = session.getBookmark()
return c.json({ ok: true }, 200, {
'x-d1-bookmark': bookmark ?? '',
})
})
// GET /settings/:userId — 북마크 기반 읽기
app.get('/settings/:userId', async (c) => {
// 클라이언트가 이전 응답에서 받은 북마크를 헤더로 전달
const bookmark = c.req.header('x-d1-bookmark') ?? 'first-unconstrained'
const session = c.env.DB.withSession(bookmark)
const db = drizzle(session, { schema })
const [user] = await db.select()
.from(users)
.where(eq(users.id, Number(c.req.param('userId'))))
.limit(1)
return c.json(user ?? null)
})북마크는 파싱하거나 직접 생성할 필요가 없는 불투명한 문자열입니다. 강한 일관성이 필요한 경우에는 'first-unconstrained' 대신 'first-primary'를 전달하면 항상 Primary에서 읽도록 강제할 수 있습니다.
batch()로 다중 쓰기 처리하기
D1은 Workers 바인딩 위에서 BEGIN / COMMIT 방식의 인터랙티브 트랜잭션을 지원하지 않습니다. 다중 쓰기를 원자적으로 처리하는 수단은 db.batch()가 유일합니다. 배열 안의 모든 구문은 하나의 단위로 실행되며, 하나라도 실패하면 전체가 롤백됩니다.
app.post('/posts', async (c) => {
const db = drizzle(c.env.DB, { schema })
const body = await c.req.json<{
title: string
slug: string
content: string
authorId: number
}>()
// 포스트 삽입 + 작성자 통계 업데이트를 원자적으로 처리
const [createdPosts] = await db.batch([
db.insert(posts).values({
title: body.title,
slug: body.slug,
content: body.content,
authorId: body.authorId,
}).returning(),
db.update(users)
.set({ postCount: sql`${users.postCount} + 1` })
.where(eq(users.id, body.authorId)),
] as const)
return c.json(createdPosts[0], 201)
})batch()는 각 구문의 결과를 배열로 반환합니다. as const로 타입을 고정해야 TypeScript가 각 요소를 정확하게 추론합니다. 첫 번째 구문(INSERT … RETURNING)의 결과가 createdPosts가 되고, 두 번째 구문(UPDATE)의 결과는 무시합니다.
마이그레이션 워크플로우
스키마를 변경한 뒤 마이그레이션 파일을 생성하고, 로컬에서 검증한 다음 프로덕션에 적용하는 흐름입니다.
# 1. 스키마 변경 후 마이그레이션 SQL 파일 생성
bunx drizzle-kit generate
# 2. 로컬에서 적용해보기 (miniflare 내장 SQLite 사용)
wrangler d1 execute my-app-db --local --file=./migrations/0001_add_post_count.sql
# 3. 프로덕션에 적용
wrangler d1 migrations apply my-app-db --remoteD1은 내부적으로 d1_migrations 테이블을 자동으로 관리해 어떤 마이그레이션이 적용됐는지 추적합니다. 마이그레이션 파일은 .sql 형식이라 Git으로 그대로 버전 관리됩니다.
스테이징 환경이 필요하다면 wrangler.toml에 [env.staging] 블록을 추가하고 별도의 D1 데이터베이스를 바인딩한 뒤, wrangler d1 migrations apply my-app-db --env staging --remote로 적용할 수 있습니다.
행 스캔 과금 대응
D1은 반환되는 행이 아니라 스캔하는 행 수를 기준으로 과금합니다. 인덱스 없이 WHERE author_id = 5를 실행하면 10건을 반환하더라도 테이블 전체를 스캔해 수천 행을 읽은 것으로 집계될 수 있습니다.
쿼리 실행 계획은 EXPLAIN QUERY PLAN으로 확인합니다.
EXPLAIN QUERY PLAN SELECT * FROM posts WHERE author_id = 5;
-- SCAN posts 라고 나오면 인덱스 없음 → 전체 스캔
-- SEARCH posts USING INDEX idx_posts_author_id 가 나와야 합니다앞서 스키마에서 보여드린 것처럼, Drizzle 스키마 정의 안에서 index()로 인덱스를 선언하면 마이그레이션 파일에 CREATE INDEX가 자동으로 포함됩니다. WHERE, JOIN, ORDER BY에 쓰이는 컬럼에는 스키마 설계 단계에서 미리 인덱스를 달아두는 것이 좋습니다.
장단점 정리
이 조합이 잘 맞는 경우
| 항목 | 설명 |
|---|---|
| 제로 콜드 스타트 | V8 Isolates 아키텍처 덕분에 함수 기동 지연 없음 |
| 글로벌 저레이턴시 읽기 | 가장 가까운 복제본에서 처리. 읽기 중심 워크로드에 유리 |
| 운영 부담 없음 | DB 서버, 커넥션 풀, 백업, 패치 모두 Cloudflare가 관리 |
| 넉넉한 무료 티어 | 저트래픽 서비스는 사실상 비용 없음 (정확한 수치는 limits 문서 확인) |
| SQLite 내장 기능 | FTS5 전문 검색, JSON 함수, 표준 SQL 문법 그대로 사용 |
| 타입 안전성 | Drizzle + TypeScript로 스키마부터 쿼리까지 완전한 타입 추론 |
| 멀티테넌트 SaaS | DB 수 무제한. 테넌트당 독립 DB로 파일 단위 데이터 격리 가능 |
이 조합이 맞지 않는 경우
| 항목 | 설명 |
|---|---|
| 10GB 스토리지 상한 | 데이터베이스당 최대 10GB. 대용량 데이터에 부적합 |
| 쓰기 처리량 제한 | 초당 500~2,000건 수준. 고빈도 이벤트 로깅이나 실시간 결제 처리에는 부족 |
| Cloudflare 종속 | Workers 바인딩으로만 접근 가능. 플랫폼 외부에서 직접 사용 불가 |
| 고급 SQL 기능 부재 | ENUM, 복잡한 윈도우 함수 등 PostgreSQL 수준의 기능은 없음 |
| 행 스캔 과금 | 인덱스 없는 쿼리는 스캔 행 수 기준 과금으로 비용이 급증할 수 있음 |
자주 하는 실수 세 가지
1. 인덱스 없이 프로덕션 배포하기
개발 중에는 데이터가 적어서 티가 안 나다가, 프로덕션 데이터가 쌓이면서 비용이 오르는 패턴입니다. EXPLAIN QUERY PLAN을 마이그레이션 검토 루틴에 넣어두면 초기에 잡을 수 있습니다.
2. db.batch() 대신 인터랙티브 트랜잭션 시도하기
D1 Workers 바인딩에서는 BEGIN/COMMIT 방식이 작동하지 않습니다. 다중 쓰기의 원자성이 필요하면 db.batch()가 유일한 수단입니다. "지원은 되지만 위험하다"가 아니라, 애초에 지원하지 않습니다.
3. 글로벌 읽기 복제 활성화 후 Sessions API 빠뜨리기
읽기 복제를 켜고 Sessions API 없이 쓰기 직후 읽으면 방금 쓴 데이터가 안 보이는 상황을 만납니다. 사용자 입장에서는 버그처럼 보입니다. 쓰기 후 즉시 읽어야 하는 플로우에는 Sessions API를 함께 적용하세요.
마무리
D1 + Drizzle ORM 조합이 엣지 환경에서 실용적인 이유는 명확합니다. 글로벌 읽기 복제로 지리적 레이턴시 문제를 다루고, Sessions API로 read-after-write 일관성을 유지하며, Drizzle로 타입 안전한 쿼리를 작성합니다. 운영 부담도 낮습니다.
단, 쓰기 처리량(초당 500~2,000건 수준)과 10GB 스토리지 상한은 현실적인 제약입니다. 쓰기 집약적 워크로드나 대용량 데이터가 필요하다면 Neon(서버리스 PostgreSQL)이나 Turso(libSQL 기반 엣지 SQLite)를 비교해보세요. D1은 "읽기가 압도적으로 많고, 쓰기는 중간 수준, 서버 없이 글로벌하게"라는 조건에서 강점을 보입니다.
시작한다면 이 순서가 현실적입니다.
1단계 — wrangler d1 create로 D1 데이터베이스를 만들고, drizzle-kit generate로 첫 마이그레이션 파일을 생성합니다. --local 옵션으로 로컬 SQLite에서 쿼리가 제대로 동작하는지 먼저 확인하세요.
2단계 — Hono + Drizzle로 API 엔드포인트를 작성합니다. 다중 쓰기에는 db.batch()를 사용하고, 자주 필터링하는 컬럼에는 스키마 정의 단계에서 인덱스를 선언합니다.
3단계 — wrangler d1 migrations apply --remote로 프로덕션에 배포하고, Cloudflare 대시보드에서 글로벌 읽기 복제를 활성화합니다. 쓰기 후 즉시 읽는 플로우가 있다면 Sessions API를 함께 적용합니다.
참고 자료
- Cloudflare D1 공식 개요 문서
- D1 글로벌 읽기 복제 공식 문서
- D1 마이그레이션 공식 문서
- D1 베스트 프랙티스 공식 문서
- D1 제한 공식 문서
- Cloudflare 블로그: D1 글로벌 읽기 복제 공개 베타 발표
- Cloudflare 블로그: D1 글로벌 데이터베이스 설계
- Drizzle ORM 공식 D1 연동 가이드
- Drizzle ORM D1 시작하기
- Drizzle ORM D1 HTTP API + drizzle-kit 가이드
- InfoQ: Cloudflare D1 글로벌 복제 업그레이드 분석
- DEV Community: Cloudflare D1 + Drizzle ORM 실전 가이드
- Hono + D1 + Drizzle 설정 튜토리얼
- Cloudflare D1 글로벌 복제 실전 분석
- Drizzle ORM 2026 동향 분석
- Turso vs Cloudflare D1 비교 2026
- SQLite at the Edge 2026: Database Renaissance
- Durable Objects + D1 + Drizzle 조합 예제