Cloudflare D1으로 서버리스 데이터베이스 시작하기

Cloudflare D1으로 서버리스 데이터베이스 시작하기

Cloudflare Workers로 서버리스 API를 만들다 보면 결국 데이터를 어딘가에 저장해야 하는 순간이 옵니다. KV는 단순한 키-값 저장에는 좋지만 관계형 쿼리가 필요해지면 한계가 드러나죠. 그렇다고 외부 데이터베이스를 연결하면 엣지에서 실행되는 Workers의 속도 이점이 사라집니다.

D1은 이 문제를 해결하기 위해 Cloudflare가 만든 서버리스 SQL 데이터베이스예요. SQLite를 기반으로 Workers에서 바인딩 하나로 바로 접근할 수 있고, 별도의 서버 관리나 연결 풀링 없이 SQL 쿼리를 실행할 수 있습니다.

D1이란

D1은 Cloudflare의 엣지 네트워크에서 실행되는 서버리스 관계형 데이터베이스입니다. 내부적으로 SQLite를 사용하기 때문에 SQLite의 SQL 문법을 그대로 쓸 수 있어요.

기존 클라우드 데이터베이스와 가장 큰 차이점은 Workers와의 통합 방식입니다. TCP 연결이나 연결 문자열 같은 게 필요 없고 Workers의 환경 바인딩으로 마치 로컬 함수를 호출하듯 데이터베이스에 접근합니다. 네트워크 라운드트립이 최소화되니까 쿼리 응답이 빠릅니다.

SQLite 기반이라서 PostgreSQL이나 MySQL 같은 범용 RDBMS보다 기능이 제한적인 건 사실이에요. 저장 프로시저나 복잡한 권한 관리 같은 건 지원하지 않습니다. 하지만 대부분의 웹 애플리케이션에서 필요한 수준의 SQL은 충분히 커버하고 서버를 관리할 필요가 없다는 게 매력적이죠.

데이터베이스 만들기

D1 데이터베이스를 만드는 건 Wrangler CLI 한 줄이면 됩니다.

npx wrangler d1 create my-database
결과
 Successfully created DB 'my-database'

[[d1_databases]]
binding = "DB"
database_name = "my-database"
database_id = "xxxx-xxxx-xxxx-xxxx"

출력에 나온 설정을 wrangler.toml(또는 wrangler.jsonc)에 복사하면 Workers에서 이 데이터베이스를 사용할 준비가 끝납니다.

wrangler.toml
name = "my-worker"
main = "src/index.ts"
compatibility_date = "2025-01-01"

[[d1_databases]]
binding = "DB"
database_name = "my-database"
database_id = "xxxx-xxxx-xxxx-xxxx"

여기서 binding이 중요한데요. 이 값이 Workers 코드에서 env.DB로 데이터베이스에 접근할 때 쓰는 이름이 됩니다.

Workers에서 D1 사용하기

바인딩이 설정되면 Worker 코드에서 env.DB로 데이터베이스에 바로 접근할 수 있습니다.

src/index.ts
export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const { pathname } = new URL(request.url);

    if (pathname === "/api/users") {
      const { results } = await env.DB.prepare(
        "SELECT * FROM users ORDER BY created_at DESC",
      ).all();
      return Response.json(results);
    }

    return new Response("Not Found", { status: 404 });
  },
};

env.DB가 D1 데이터베이스 인스턴스이고, prepare()로 SQL 문을 준비한 뒤 all()로 실행하는 흐름입니다. HTTP 연결을 맺거나 드라이버를 설치할 필요 없이 바로 쿼리를 날릴 수 있어요.

쿼리 API

D1은 prepared statement 패턴을 사용합니다. SQL 인젝션을 방지하면서 동적 값을 안전하게 바인딩할 수 있어요.

// 단일 결과
const user = await env.DB.prepare("SELECT * FROM users WHERE id = ?")
  .bind(userId)
  .first();

// 여러 결과
const { results } = await env.DB.prepare("SELECT * FROM users WHERE role = ?")
  .bind("admin")
  .all();

// INSERT/UPDATE/DELETE
const info = await env.DB.prepare(
  "INSERT INTO users (name, email) VALUES (?, ?)",
)
  .bind("Dale", "dale@example.com")
  .run();

console.log(info.meta.changes); // 영향받은 행 수

? 자리표시자에 bind()로 값을 넘기는 방식이라 SQL 문자열을 직접 조합하는 것보다 훨씬 안전합니다. bind()에 여러 값을 전달하면 순서대로 ?에 매핑돼요.

실행 메서드는 세 가지가 있습니다.

  • first() — 첫 번째 행만 반환. 결과가 없으면 null
  • all() — 모든 행을 배열로 반환. results 속성으로 접근
  • run() — INSERT, UPDATE, DELETE처럼 결과 행이 필요 없는 쿼리에 사용. 변경된 행 수 등 메타 정보 반환

배치 실행

여러 쿼리를 한 번에 실행해야 할 때는 batch()를 사용합니다.

const results = await env.DB.batch([
  env.DB.prepare("INSERT INTO users (name, email) VALUES (?, ?)").bind(
    "Alice",
    "alice@example.com",
  ),
  env.DB.prepare("INSERT INTO users (name, email) VALUES (?, ?)").bind(
    "Bob",
    "bob@example.com",
  ),
  env.DB.prepare("INSERT INTO logs (action) VALUES (?)").bind("users_created"),
]);

batch() 안의 쿼리는 하나의 트랜잭션으로 묶여서 실행됩니다. 중간에 하나라도 실패하면 전체가 롤백돼요. 개별 쿼리를 하나씩 보내는 것보다 네트워크 왕복이 줄어들어 성능도 좋습니다.

사용자 등록 시 여러 테이블에 데이터를 넣어야 하는 상황을 생각해보세요. batch()가 없으면 쿼리를 하나씩 보내야 하고, 중간에 실패하면 부분적으로만 데이터가 들어간 상태가 됩니다. batch()를 쓰면 전부 성공하거나 전부 실패하거나 둘 중 하나니까 데이터 정합성을 유지하기 쉽죠.

마이그레이션

스키마 변경을 체계적으로 관리하려면 마이그레이션을 사용합니다.

npx wrangler d1 migrations create my-database init

이 명령어를 실행하면 migrations/ 디렉토리에 0001_init.sql 같은 파일이 생깁니다. 여기에 DDL을 작성하면 돼요.

migrations/0001_init.sql
CREATE TABLE users (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  name TEXT NOT NULL,
  email TEXT UNIQUE NOT NULL,
  role TEXT DEFAULT 'user',
  created_at TEXT DEFAULT (datetime('now'))
);

CREATE TABLE posts (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  user_id INTEGER NOT NULL,
  title TEXT NOT NULL,
  content TEXT,
  created_at TEXT DEFAULT (datetime('now')),
  FOREIGN KEY (user_id) REFERENCES users(id)
);

로컬 환경에서 먼저 테스트해볼 수 있습니다.

npx wrangler d1 migrations apply my-database --local

로컬에서 문제없이 동작하는 걸 확인했으면 프로덕션에 적용합니다.

npx wrangler d1 migrations apply my-database --remote

마이그레이션 파일은 번호 순서대로 실행되고, 이미 적용된 마이그레이션은 건너뜁니다. 팀에서 협업할 때 Git으로 마이그레이션 파일을 공유하면 모든 환경에서 동일한 스키마를 유지할 수 있어요.

로컬 개발

wrangler dev를 실행하면 D1도 로컬에서 시뮬레이션됩니다.

npx wrangler dev

내부적으로 Miniflare가 SQLite 파일을 로컬에 만들어서 프로덕션과 동일한 API로 동작합니다. .wrangler/state/ 디렉토리에 로컬 데이터가 저장되는데 이 디렉토리는 .gitignore에 추가해야 합니다.

로컬 데이터베이스에 직접 SQL을 실행하고 싶다면 --local 플래그를 사용하면 됩니다.

npx wrangler d1 execute my-database --local --command "SELECT * FROM users"

개발 중에는 로컬에서 충분히 테스트하고, 배포 직전에 --remote로 프로덕션 데이터를 확인하는 흐름이 좋습니다.

Time Travel

실수로 데이터를 날려먹었거나 잘못된 마이그레이션을 적용했을 때 Time Travel이 구원해줍니다. D1은 30일간의 변경 이력을 자동으로 보관하고, 특정 시점으로 데이터베이스를 복원할 수 있어요.

먼저 어떤 시점으로 돌아갈 수 있는지 확인합니다.

npx wrangler d1 time-travel info my-database

특정 시점으로 복원하려면 타임스탬프를 지정합니다.

npx wrangler d1 time-travel restore my-database --timestamp="2025-03-05T14:30:00Z"

복원을 실행하면 이전 상태의 북마크가 반환되니까 혹시 복원을 취소하고 싶으면 그 북마크로 다시 돌아갈 수도 있습니다.

주의할 점은 Time Travel 복원이 파괴적인 작업이라는 거예요. 복원 시점 이후의 모든 변경 사항이 사라지고, 진행 중인 쿼리도 취소됩니다. 프로덕션에서 실행할 때는 반드시 팀원들에게 알리고 진행하세요.

Time Travel을 사용하면 별도의 백업 시스템을 구축할 필요가 없어요. 실수로 DROP TABLE을 실행해도 30일 이내라면 복구할 수 있으니까요.

Drizzle ORM 연동

SQL을 직접 작성하는 것도 좋지만 프로젝트가 커지면 ORM을 도입하는 게 생산성에 유리합니다. D1은 Drizzle ORM과 잘 맞는데요. Drizzle이 SQLite를 지원하고 엣지 환경에서도 가볍게 돌아가기 때문입니다.

먼저 패키지를 설치합니다.

bun add drizzle-orm
bun add -d drizzle-kit

스키마를 TypeScript로 정의합니다.

src/db/schema.ts
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core";

export const users = sqliteTable("users", {
  id: integer("id").primaryKey({ autoIncrement: true }),
  name: text("name").notNull(),
  email: text("email").notNull().unique(),
  role: text("role").default("user"),
});

export const posts = sqliteTable("posts", {
  id: integer("id").primaryKey({ autoIncrement: true }),
  userId: integer("user_id")
    .notNull()
    .references(() => users.id),
  title: text("title").notNull(),
  content: text("content"),
});

Drizzle 설정 파일을 만듭니다.

drizzle.config.ts
import { defineConfig } from "drizzle-kit";

export default defineConfig({
  schema: "./src/db/schema.ts",
  out: "./migrations",
  dialect: "sqlite",
});

이제 Worker에서 Drizzle을 사용할 수 있습니다.

src/index.ts
import { drizzle } from "drizzle-orm/d1";
import { users } from "./db/schema";
import { eq } from "drizzle-orm";

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const db = drizzle(env.DB);
    const { pathname } = new URL(request.url);

    if (pathname === "/api/users") {
      const allUsers = await db.select().from(users);
      return Response.json(allUsers);
    }

    if (pathname === "/api/users/admin") {
      const admins = await db
        .select()
        .from(users)
        .where(eq(users.role, "admin"));
      return Response.json(admins);
    }

    return new Response("Not Found", { status: 404 });
  },
};

drizzle(env.DB)로 D1 바인딩을 Drizzle 인스턴스로 감싸면 끝이에요. 이후에는 SQL 대신 TypeScript 메서드 체이닝으로 쿼리를 작성할 수 있습니다. 스키마에서 타입 정보가 자동으로 추론되니까 오타나 잘못된 컬럼 이름을 컴파일 타임에 잡을 수 있어요.

마이그레이션도 Drizzle Kit으로 생성할 수 있습니다.

bunx drizzle-kit generate

이 명령어가 스키마 파일을 읽어서 migrations/ 디렉토리에 SQL 파일을 만들어줍니다. 그걸 wrangler d1 migrations apply로 적용하면 됩니다.

용량과 제한

D1을 실무에 적용하기 전에 알아둬야 할 제한사항이 있습니다.

무료 플랜에서도 넉넉한 읽기/쓰기 한도를 제공하고 유료 플랜에서는 훨씬 여유로워집니다. 단일 SQL 문 크기나 바인딩 파라미터 개수에 상한이 있지만 대부분의 웹 애플리케이션에서는 이 제한에 부딪힐 일이 거의 없어요. 대량 데이터 마이그레이션이나 복잡한 분석 쿼리를 실행할 때는 주의가 필요합니다.

계정당 데이터베이스 개수도 유료 플랜에서는 넉넉해서 마이크로서비스 아키텍처에서 서비스별로 데이터베이스를 분리하기에 충분하죠. 최신 한도는 D1 공식 문서에서 확인할 수 있습니다.

KV와 비교하면

Cloudflare에는 이미 Workers KV라는 저장소가 있는데 D1과 어떻게 다를까요?

KV는 이름 그대로 키-값 저장소입니다. 캐시, 설정값, 세션 토큰처럼 키로 값을 빠르게 읽어오는 패턴에 최적화되어 있어요. 반면 “특정 조건에 맞는 사용자를 찾아서 최신 순으로 정렬해줘” 같은 요청은 KV로는 구현하기 어렵습니다.

D1은 SQL을 지원하니까 WHERE, JOIN, ORDER BY, GROUP BY 같은 관계형 쿼리가 가능합니다. 사용자 프로필, 주문 내역, 블로그 글처럼 구조화된 데이터를 다룰 때는 D1이 맞고, 캐시나 설정값처럼 단순한 키-값 조회가 주 패턴이면 KV가 적합해요.

둘 다 Workers에서 바인딩으로 접근하는 방식은 같으니까, 하나의 Worker에서 D1과 KV를 동시에 사용하는 것도 가능합니다. 예를 들어 사용자 정보는 D1에 저장하고, 자주 조회하는 프로필은 KV에 캐싱하는 식으로요.

마치며

D1은 Cloudflare 생태계에서 데이터를 다루는 가장 자연스러운 방법입니다. Workers와 같은 플랫폼에서 실행되니까 별도의 인프라 없이 SQL 데이터베이스를 쓸 수 있고, SQLite 기반이라 진입 장벽도 낮아요.

특히 Time Travel로 30일간의 포인트 인 타임 복구가 가능하다는 건 서버리스 데이터베이스치고는 꽤 든든한 안전장치입니다. Drizzle ORM까지 붙이면 타입 안전한 쿼리를 엣지에서 실행하는, 꽤 현대적인 풀스택 개발 환경이 만들어지죠.

물론 SQLite 기반이라 PostgreSQL 수준의 복잡한 쿼리나 대규모 동시 쓰기가 필요한 상황에는 맞지 않을 수 있어요. 하지만 대부분의 웹 애플리케이션이 필요로 하는 수준의 데이터 처리에는 D1이면 충분하고 서버 관리 부담이 없다는 건 큰 장점입니다.

Wrangler CLI를 이미 쓰고 계시다면 wrangler d1 create로 바로 시작해보세요. 더 자세한 내용은 Cloudflare D1 공식 문서를 참고하시면 됩니다.

This work is licensed under CC BY 4.0 CC BY

개발자를 위한 뉴스레터

달레가 정리한 AI 개발 트렌드와 직접 만든 콘텐츠를 전해드립니다.

Discord