개인정보처리방침© 2026 DEV BAK - 기술블로그. All rights reserved.
DEV BAK - 기술블로그
포스트 검색
Database

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 데이터베이스 설정 페이지에서 활성화합니다.

다이어그램 1

Workers는 env.DB 바인딩 하나로 이 데이터베이스에 접근합니다. TCP 연결도, 커넥션 풀도, 드라이버 설치도 필요 없습니다. V8 Isolates 아키텍처 덕분에 콜드 스타트가 없고, 바인딩을 통한 D1 접근은 같은 Cloudflare 인프라 위에서 이루어지므로 네트워크 홉도 최소화됩니다.

Sessions API와 read-after-write 일관성

글로벌 복제가 생기면서 새로운 문제가 생겼습니다. 사용자가 데이터를 쓴 직후 읽으면, 복제 지연 때문에 방금 쓴 데이터가 보이지 않을 수 있습니다. 이를 read-after-write 불일치, 또는 read-your-own-writes 실패라고 합니다. D1의 Sessions API는 이 문제를 해결합니다.

다이어그램 2

Sessions API를 사용하면 쓰기 완료 후 **북마크(bookmark)**를 받아 다음 읽기 요청에 첨부합니다. 복제본은 이 북마크를 기준으로 "적어도 이만큼 최신" 상태의 데이터를 반환하도록 보장합니다. 소셜 피드, 사용자 설정 변경, 쇼핑카트처럼 "내가 저장한 건 바로 보여야 하는" 시나리오에서 특히 중요합니다.

Drizzle이 Workers 환경에 맞는 이유

Workers 환경에서 ORM을 고를 때 Drizzle이 유리한 이유는 세 가지입니다.

번들 크기: Prisma는 별도 바이너리를 필요로 해 Workers에서 작동하지 않거나 복잡한 우회가 필요합니다. Drizzle은 순수 TypeScript라 그냥 동작합니다.

SQL 투명성: Drizzle의 철학은 SQL을 감추지 않고 TypeScript로 표현하는 것입니다. ORM이 생성하는 쿼리가 예측 가능해서 D1 같은 특수 환경에서도 디버깅이 쉽습니다.

D1 네이티브 드라이버: drizzle-orm/d1를 공식 지원합니다. 어댑터를 직접 만들 필요 없이 공식 경로로 연결됩니다.


프로젝트 설정

패키지 설치와 D1 생성

bash
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-db

wrangler.toml에 D1 바인딩을 추가합니다.

toml
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를 사용합니다.

typescript
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)만 한다면 이 자격증명은 쓰이지 않습니다.

typescript
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 Config

Hono와 연결하기

drizzle(c.env.DB, { schema })로 인스턴스를 만들 때 schema를 함께 전달해야 Drizzle의 관계 쿼리(RQB) 기능을 온전히 사용할 수 있습니다. schema 없이 초기화하면 기본 CRUD는 동작하지만, db.query.* 형태의 관계형 쿼리는 쓸 수 없게 됩니다.

typescript
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 })로 그대로 전달할 수 있습니다.

typescript
// 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()가 유일합니다. 배열 안의 모든 구문은 하나의 단위로 실행되며, 하나라도 실패하면 전체가 롤백됩니다.

typescript
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)의 결과는 무시합니다.

마이그레이션 워크플로우

스키마를 변경한 뒤 마이그레이션 파일을 생성하고, 로컬에서 검증한 다음 프로덕션에 적용하는 흐름입니다.

다이어그램 3

bash
# 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 --remote

D1은 내부적으로 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으로 확인합니다.

sql
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 조합 예제
#Cloudflare D1#Drizzle ORM#SQLite#Cloudflare Workers#Edge Computing#TypeScript
공유하기

목차

D1 아키텍처와 일관성 모델읽기 복제의 작동 방식Sessions API와 read-after-write 일관성Drizzle이 Workers 환경에 맞는 이유프로젝트 설정패키지 설치와 D1 생성스키마 정의Hono와 연결하기핵심 패턴Sessions API 활용하기batch()로 다중 쓰기 처리하기마이그레이션 워크플로우행 스캔 과금 대응장단점 정리이 조합이 잘 맞는 경우이 조합이 맞지 않는 경우자주 하는 실수 세 가지마무리참고 자료

추천 포스트

Kafka 이벤트를 서브초 쿼리로 — ClickHouse Materialized View·Projections로 초당 수만 건 집계 파이프라인 설계하기
Database

Kafka 이벤트를 서브초 쿼리로 — ClickHouse Materialized View·Projections로 초당 수만 건 집계 파이프라인 설계하기

실시간 대시보드를 처음 붙였을 때 가장 많이 듣는 말이 있습니다. "쿼리가 왜 이렇게 느려요?" 초당 수만 건의 이벤트를 Kafka에서 받아 저장해두고 보니, 대시보드 SELECT 한 번에 10초가 걸리는 현실을 마주하게 되죠. 인덱스를 더 추가하고 집계 쿼리를 최적화…

2026년 07월 13일읽는 데 23분
ClickHouse를 Node.js 백엔드에 연결하기 — 수억 건 이벤트 실시간 집계 API 실전 가이드
Database

ClickHouse를 Node.js 백엔드에 연결하기 — 수억 건 이벤트 실시간 집계 API 실전 가이드

어느 날 팀 채널에 알림이 울립니다. "대시보드 로딩이 30초 넘어요." 이벤트 테이블에 쌓인 행이 5억을 넘어가면서 PostgreSQL의 GROUP BY 쿼리가 타임아웃을 내기 시작한 겁니다. 인덱스도 달아보고, 파티셔닝도 해봤지만 데이터는 계속 쌓이고 쿼리는 점점…

2026년 07월 13일읽는 데 21분
PostgreSQL 스키마를 Git처럼 관리하기 — Atlas와 GitHub Actions로 선언적 Diff, 린팅, Drift 감지, 자동 롤백까지
Database

PostgreSQL 스키마를 Git처럼 관리하기 — Atlas와 GitHub Actions로 선언적 Diff, 린팅, Drift 감지, 자동 롤백까지

"야, 누가 프로덕션 DB에서 컬럼 직접 지웠어?" — 팀 슬랙에 이런 메시지가 뜨는 순간, 그날의 배포 계획은 전부 날아갑니다. 마이그레이션 파일은 충실히 쌓여 있는데, 누군가 핫픽스랍시고 프로덕션 DB에서 직접 ALTER TABLE 을 날리면 파일과 실제 스키마가…

2026년 07월 13일읽는 데 22분
PostgreSQL 분석 집계 병목, ClickHouse + Node.js 파이프라인으로 최대 100배 빠르게 해소하기
Database

PostgreSQL 분석 집계 병목, ClickHouse + Node.js 파이프라인으로 최대 100배 빠르게 해소하기

페이지뷰·버튼 클릭·구매 퍼널을 PostgreSQL RDS에 쌓다 보면, 일 단위 집계 쿼리가 30초를 넘기는 시점이 반드시 옵니다. 인덱스를 추가하고 파티셔닝을 적용해도, 행이 수억 건을 넘어서면 GROUP BY 쿼리의 응답 시간은 단순 최적화로는 더 이상 눌러지지…

2026년 07월 13일읽는 데 22분
브라우저 속 PostgreSQL: PGlite 실전 가이드
Database

브라우저 속 PostgreSQL: PGlite 실전 가이드

인터넷 연결 없이도 데이터를 온전히 다루는 앱을 만들고 싶었던 적이 있으신가요? 저도 처음엔 서버 API 호출 실패를 어떻게 처리할까 고민하다가, 결국 복잡한 캐싱 레이어를 끼워 넣고 "오프라인 지원"이라는 이름을 붙인 적이 있습니다. 그 방식의 문제는, 네트워크가 끊…

2026년 07월 12일읽는 데 22분
Drizzle ORM으로 타입 안전한 복잡 쿼리 작성하기 — 관계형 쿼리, 마이그레이션, Edge 런타임 대응까지
Database

Drizzle ORM으로 타입 안전한 복잡 쿼리 작성하기 — 관계형 쿼리, 마이그레이션, Edge 런타임 대응까지

Prisma를 쓰다 보면 불편함이 생기는 지점이 있습니다. 여러 테이블에 걸친 복잡한 include 체인을 쓸 때 N+1 쿼리 문제나 비효율적인 JOIN 전략이 발생해도 Prisma가 생성하는 SQL을 직접 조정하기 어렵고, Cloudflare Workers 같은 Ed…

2026년 07월 12일읽는 데 19분