메인 콘텐츠로 이동

Cloudflare D1으로 좋아요, 댓글, 조회수 붙이기

·
CloudflareD1DrizzleNext.jsEdge Runtime
Cloudflare D1 로고

왜 Cloudflare였나

블로그를 새로 만들면서 좋아요, 댓글, 조회수 같은 인터랙션 기능이 필요했어요. 그런데 이걸 위해 별도의 서버를 띄우긴 부담스러웠어요.

Vercel에 올리면 서버리스 함수로 처리할 수 있지만, DB를 어디에 둘지가 애매하더라고요. PlanetScale은 무료 플랜이 사라졌고, Supabase도 고려했지만 개인 블로그에 PostgreSQL까지 쓸 필요는 없었어요.

그러다 Cloudflare D1을 알게 됐어요.

D1이 뭔데?

D1은 Cloudflare에서 제공하는 서버리스 SQLite 데이터베이스예요. MySQL이나 PostgreSQL처럼 별도 서버를 띄울 필요 없이, Cloudflare의 Edge 네트워크 위에서 바로 동작하는 가벼운 관계형 DB예요.

SQLite 기반이라 문법도 익숙하고 설정도 간단해요. 개인 블로그처럼 트래픽이 크지 않은 프로젝트에서는 MySQL이나 PostgreSQL을 쓸 이유가 딱히 없더라고요. 무료 플랜도 꽤 넉넉하고요.

Drizzle ORM은?

Drizzle은 TypeScript 네이티브 ORM이에요. Prisma랑 비슷한 포지션인데, Edge Runtime에서 동작한다는 게 큰 차이예요. Prisma는 바이너리 엔진이 필요해서 Cloudflare Workers/Pages 환경에서 쓸 수가 없거든요.

Drizzle은 순수 JS/TS로 돌아가고, D1 전용 어댑터(drizzle-orm/d1)도 제공해서 Cloudflare 환경에서 궁합이 좋았어요. 스키마를 TypeScript 코드로 정의하면 타입도 자동으로 추론되고, SQL에 가까운 쿼리 빌더라 러닝 커브도 거의 없었어요.

전체 구조

제가 구성한 구조를 간단히 정리하면 이래요.

Next.js 15 (App Router)
  └─ API Routes (Edge Runtime)
       └─ Drizzle ORM
            └─ Cloudflare D1 (SQLite)
  • 배포: Cloudflare Pages (@cloudflare/next-on-pages)
  • DB: Cloudflare D1 (서버리스 SQLite)
  • ORM: Drizzle ORM (Edge 호환, D1 어댑터)
  • 런타임: 모든 API 라우트가 Edge Runtime으로 동작

D1 세팅하기

본격적으로 세팅에 들어가기 전에 wrangler 얘기를 먼저 해야 해요. wrangler는 Cloudflare에서 만든 공식 CLI인데, D1 입장에서는 사실상 유일한 관리 도구라고 봐도 돼요. 데이터베이스를 만들고, 쿼리를 날리고, 마이그레이션을 돌리고, 로컬에서 테스트하는 것까지 D1과 관련된 작업은 거의 다 wrangler 한 줄로 끝나요.

처음에는 "DB 하나 다루는데 굳이 CLI를?" 싶었는데, 막상 써보니까 대시보드를 들락거리는 것보다 훨씬 빠르더라고요. 이제 wrangler를 써서 단계별로 세팅해볼게요.

1. 데이터베이스 만들기

npx wrangler d1 create sw-blog-db

처음 한 번만 실행하면 돼요. 명령어를 치면 터미널에 database_id가 출력되는데, 이 값을 복사해서 wrangler.toml에 그대로 붙여 넣으면 프로젝트와 D1이 연결돼요.

# wrangler.toml
name = "sw-blog"
pages_build_output_dir = ".vercel/output/static"
compatibility_date = "2024-09-23"
compatibility_flags = ["nodejs_compat"]
 
[[d1_databases]]
binding = "DB"
database_name = "sw-blog-db"
database_id = "ccc1ea5a-..."
migrations_dir = "drizzle/migrations"

여기서 binding = "DB"로 적은 부분이 중요해요. 나중에 코드에서 env.DB로 D1을 가져올 때 이 이름을 그대로 쓰게 돼요. 원하는 이름으로 바꿔도 되지만, 이 블로그에서는 그냥 DB로 두고 썼어요.

처음에는 wrangler.jsonc로 시작했는데 도중에 wrangler.toml로 갈아탔어요. Cloudflare Pages가 pages_build_output_dir 설정을 wrangler.toml에서만 인식하더라고요. Workers는 두 포맷을 다 지원하는데, Pages 프로젝트는 toml만 제대로 읽어줘서 어쩔 수 없이 옮겼어요. 처음부터 Pages로 갈 거라면 toml로 시작하는 게 마음 편해요.

2. Drizzle 스키마 정의

DB는 만들었지만 아직 빈 깡통이에요. 이제 어떤 테이블이 들어갈지 정의해줘야 해요.

테이블은 총 4개를 만들었어요. 조회수, 좋아요, 댓글, 댓글 좋아요.

// src/lib/schema.ts
export const views = sqliteTable("views", {
  slug: text("slug").primaryKey(),
  count: integer("count").notNull().default(0),
});
 
export const likes = sqliteTable("likes", {
  id: integer("id").primaryKey({ autoIncrement: true }),
  slug: text("slug").notNull(),
  visitorId: text("visitor_id").notNull(),
  createdAt: text("created_at")
    .notNull()
    .default(sql`(datetime('now'))`),
});
 
export const comments = sqliteTable("comments", {
  id: integer("id").primaryKey({ autoIncrement: true }),
  slug: text("slug").notNull(),
  author: text("author").notNull(),
  content: text("content").notNull(),
  password: text("password").notNull(),
  createdAt: text("created_at")
    .notNull()
    .default(sql`(datetime('now'))`),
  parentId: integer("parent_id"),
});

DB 연결도 간단해요.

// src/lib/db.ts
import { drizzle } from "drizzle-orm/d1";
 
export function getDB(d1: D1Database) {
  return drizzle(d1, { schema });
}

3. 마이그레이션 생성하고 적용하기

스키마를 정의했다고 끝이 아니에요. 이 정의를 실제 DB에 반영하려면 마이그레이션이라는 단계를 거쳐야 해요.

여기서 말하는 마이그레이션은 DB 스키마 변경 이력을 SQL 파일로 만들어두고, 그걸 순서대로 실행해서 DB 구조를 최신 상태로 맞추는 작업이에요. 데이터를 옮기는 게 아니라, "이번에 어떤 컬럼을 추가했고, 어떤 테이블을 만들었는지"를 기록한 SQL 파일을 만들고 그걸 DB에 실행하는 거예요.

흐름을 풀어보면 이래요.

  1. src/lib/schema.ts에서 Drizzle 스키마를 수정해요. (예: comments 테이블에 password 컬럼 추가)
  2. npx drizzle-kit generate를 돌리면 변경된 부분만 비교해서 drizzle/migrations/ 안에 ALTER TABLE comments ADD password ... 같은 SQL 파일이 자동으로 생성돼요.
  3. 이 SQL 파일을 실제 DB에 실행하는 게 wrangler d1 migrations apply예요.
# 1. 스키마 변경사항으로부터 SQL 파일 생성
npx drizzle-kit generate
 
# 2. 로컬 DB에 적용 (개발용)
npx wrangler d1 migrations apply sw-blog-db --local
 
# 3. 운영 DB에 적용 (실제 배포용)
npx wrangler d1 migrations apply sw-blog-db --remote

처음에 가장 헷갈렸던 게 --local이랑 --remote예요. D1은 로컬 개발용 DB와 운영 DB가 완전히 따로 있어요. 로컬 DB는 내 컴퓨터에 SQLite 파일로 저장되고, 운영 DB는 Cloudflare 클라우드에 따로 존재해요. 그래서 같은 SQL 파일을 두 곳에 각각 실행해줘야 해요.

한쪽만 하고 다른 쪽을 깜빡하면 "왜 로컬에선 되는데 배포하면 에러나지?" 같은 상황이 생겨요. (네, 제 얘기예요.)

참고로 wrangler.toml에 적는 database_id운영 DB만 가지고 있어요. 이 ID는 wrangler d1 create를 실행할 때 Cloudflare가 발급해주는 운영 DB 전용 식별자예요. 로컬 DB는 별도의 ID 같은 건 없고, wrangler가 .wrangler/state/v3/d1/ 경로 아래에 SQLite 파일을 하나 만들어서 운영 DB인 척 흉내내는 방식이에요. 그래서 .wrangler/ 폴더를 통째로 지우면 로컬 DB도 같이 날아가요. (저도 한 번 잘못 지워서 로컬 댓글 데이터를 다 잃어본 적 있는데, 마이그레이션을 다시 돌리면 스키마는 복원되니까 큰 문제는 없었어요.)

4. 데이터 직접 확인해보기

세팅이 끝났으면 진짜 데이터가 잘 쌓이는지 확인하고 싶어져요. 그럴 때 쓰는 게 wrangler d1 execute예요.

# 운영 DB에 SELECT 날려보기
npx wrangler d1 execute sw-blog-db --remote --command "SELECT * FROM views"
 
# 로컬 DB에 INSERT 해보기
npx wrangler d1 execute sw-blog-db --local --command "INSERT INTO views (slug, count) VALUES ('test', 0)"

운영 데이터를 확인하고 싶을 때 DB 클라이언트를 따로 띄울 필요 없이 터미널에서 바로 쿼리를 날려볼 수 있어서 좋아요. SQL 파일을 통째로 실행하고 싶다면 --command 대신 --file 옵션을 쓰면 돼요.

5. 로컬 개발 서버 띄우기

이제 마지막으로 로컬에서 Next.js를 띄워서 실제 코드와 D1을 연결해볼 차례예요. 방법은 두 가지가 있어요.

방법 1: next dev + setupDevPlatform()

이 블로그가 쓰는 방식이에요. next.config.mjssetupDevPlatform()을 한 줄 추가해두면 그냥 next dev로 띄워도 D1, R2, 환경변수 같은 Cloudflare 바인딩이 자동으로 연결돼요.

// next.config.mjs
import { setupDevPlatform } from "@cloudflare/next-on-pages/next-dev";
 
const nextConfig = {};
 
if (process.env.NODE_ENV === "development") {
  await setupDevPlatform();
}
 
export default nextConfig;

개발 모드일 때 wrangler.toml을 읽어서 거기 적힌 바인딩들을 Next.js 개발 서버에 주입해줘요. 그래서 API 라우트에서 getRequestContext().env.DB를 호출하면 wrangler가 만들어둔 로컬 SQLite 파일에 자동으로 연결돼요. Turbopack이나 Hot Reload도 그대로 쓸 수 있어서 개발 경험이 가장 좋아요.

방법 2: wrangler pages dev

npx wrangler pages dev

setupDevPlatform() 설정 없이 진짜 Cloudflare Pages 환경을 그대로 흉내내고 싶을 때 쓰는 방법이에요. wrangler가 직접 워커 런타임을 띄워서 운영 환경에 가장 가까운 동작을 보여주는데, 대신 next dev보다 느리고 일부 개발 편의 기능이 안 통할 수 있어요.

저는 평소엔 방법 1로 빠르게 개발하다가, 배포 직전에 운영 환경 비슷하게 한 번 더 검증하고 싶을 때만 방법 2를 써요.

여기까지 하면 D1 세팅은 끝이에요. 그 외에 가끔 쓰는 명령어로는 npx wrangler d1 list(내가 만든 DB 목록 확인) 정도가 있고, 나머지는 그때그때 npx wrangler d1 --help로 찾아보면 충분했어요.

기능별 구현

조회수

가장 단순한 기능이에요. 글 페이지에 들어오면 POST를 한 번 쏴서 카운트를 올려요.

export async function POST(request: Request) {
  const { slug } = (await request.json()) as { slug: string };
  const db = getDB(getRequestContext().env.DB);
 
  await db
    .insert(views)
    .values({ slug, count: 1 })
    .onConflictDoUpdate({
      target: views.slug,
      set: { count: sql`${views.count} + 1` },
    });
 
  const result = await db.select().from(views).where(eq(views.slug, slug));
  return Response.json({ count: result[0]?.count ?? 1 });
}

onConflictDoUpdate는 첫 방문이면 row를 만들고, 이미 있으면 카운트만 +1 해줘요. upsert를 SQL 한 줄로 처리해줘요.

좋아요 (토글)

좋아요는 IP 기반으로 중복을 막았어요. Cloudflare 환경에서는 cf-connecting-ip 헤더로 방문자 IP를 가져올 수 있어요.

const visitorId = request.headers.get("cf-connecting-ip") ?? "unknown";

이미 좋아요를 눌렀으면 삭제, 안 눌렀으면 추가하는 토글 방식이에요.

const [existing] = await db
  .select()
  .from(likes)
  .where(and(eq(likes.slug, slug), eq(likes.visitorId, visitorId)));
 
if (existing) {
  await db.delete(likes)
    .where(and(eq(likes.slug, slug), eq(likes.visitorId, visitorId)));
} else {
  await db.insert(likes).values({ slug, visitorId });
}

OAuth 같은 로그인 없이도 깔끔하게 동작해요. 물론 VPN으로 IP를 바꾸면 중복이 가능하지만, 개인 블로그에서 그 정도까지 막을 필요는 없다고 봤어요.

댓글 (비밀번호 기반 수정/삭제)

댓글은 좀 고민이 많았어요. OAuth를 붙이자니 개인 블로그 방문자한테 로그인을 강제하는 게 부담이었어요. 그래서 작성 시에 이름 + 비밀번호를 받고, 수정/삭제할 때 비밀번호를 확인하는 방식으로 갔어요. 비밀번호는 당연히 평문이 아니라 해시해서 저장하고 있어요.

Edge Runtime 환경에서는 Node.js의 crypto 모듈을 쓸 수 없어서, Web Crypto API를 활용해야 했어요. 이것도 삽질하다가 알게 된 부분이에요.

대댓글도 지원하는데, 깊은 뎁스는 오히려 UX를 해친다고 생각해서 1단계 답글까지만 허용했어요. parentId가 있으면 대댓글이고, 대댓글의 대댓글은 원본 댓글의 parentId를 그대로 사용해서 같은 레벨에 쌓이게 했어요.

시행착오

1. ALTER TABLE에 NOT NULL 넣었다가 터짐

댓글에 비밀번호 기능을 추가하면서 컬럼을 하나 추가해야 했는데요.

ALTER TABLE comments ADD password text NOT NULL;

이렇게 하면 기존 데이터가 있을 때 터져요. NOT NULL인데 기존 row에는 값이 없으니까요. DEFAULT ''를 붙여야 했어요.

ALTER TABLE comments ADD password text NOT NULL DEFAULT '';

SQLite의 ALTER TABLE은 제약이 꽤 있어서, 저도 이 부분에서 한참 헤맸어요.

2. Edge Runtime에서 Node.js 모듈 못 씀

Edge Runtime에서는 fs, path, crypto 같은 Node.js 내장 모듈을 사용할 수 없어요. import만 해도 빌드가 실패해요.

Module not found: Can't resolve 'fs'

이 제약 때문에 두 가지를 우회해야 했어요.

파일 시스템: 빌드 타임 생성

MDX 파일을 읽는 로직은 런타임에서 fs를 쓸 수 없으니, 빌드 시 JSON으로 미리 생성해두는 방식으로 우회했어요. 검색 인덱스나 AI 챗봇용 RAG 데이터를 빌드 스크립트에서 만들어서 public/에 넣어두고, 런타임에서는 fetch로 가져오는 구조예요.

비밀번호 해싱: Web Crypto API

댓글 비밀번호를 해싱해야 하는데 crypto 모듈을 쓸 수 없으니, Web Crypto API로 대체했어요.

async function hashPassword(password: string): Promise<string> {
  const encoded = new TextEncoder().encode(password);
  const buffer = await crypto.subtle.digest("SHA-256", encoded);
  return Array.from(new Uint8Array(buffer))
    .map((b) => b.toString(16).padStart(2, "0"))
    .join("");
}

TextEncoder로 바이트 배열로 변환하고, crypto.subtle.digest로 SHA-256 해싱한 뒤, hex 문자열로 변환하는 흐름이에요. 댓글 작성 시 해싱해서 저장하고, 수정/삭제 시 같은 방식으로 해싱해서 비교해요.

// 작성 시
const hashed = await hashPassword(password);
await db.insert(comments).values({ slug, author, content, password: hashed });
 
// 수정/삭제 시
const hashed = await hashPassword(password);
if (hashed !== existing.password) {
  return Response.json({ error: "wrong password" }, { status: 403 });
}

처음에 crypto를 import 했다가 빌드 에러가 나서 당황했는데, Web Crypto API가 거의 동일한 기능을 제공하더라고요. Edge Runtime에서는 Node.js 모듈보다 Web API를 먼저 확인해보는 게 좋더라고요.

비용

Cloudflare D1의 무료 플랜 한도예요.

항목무료 한도
읽기500만 row/일
쓰기10만 row/일
저장5GB

개인 블로그에서 이 한도를 넘길 일은 거의 없어요. Pages 호스팅도 무료고, Workers 호출도 일 10만 건까지 무료라서 운영 비용이 사실상 0원이에요.

정리

Cloudflare D1을 써보면서 느낀 점이에요.

좋았던 점

  • Edge에서 DB를 바로 접근할 수 있어서 레이턴시가 거의 없어요
  • SQLite 기반이라 스키마가 단순하고 직관적이에요
  • Drizzle ORM과의 조합이 깔끔해요
  • 무료 한도가 넉넉해서 개인 프로젝트에 부담이 없어요

아쉬웠던 점

  • getRequestContext() 같은 Cloudflare 전용 API를 알아야 해요
  • 로컬/운영 마이그레이션을 따로 관리해야 하는 게 번거로워요
  • Edge Runtime 제약 (Node.js 모듈 사용 불가) 때문에 설계를 미리 잘 해야 해요

개인 블로그 수준에서는 Cloudflare 무료 스택만으로도 충분하다는 걸 직접 확인했어요. 가벼운 어플리케이션용으로 서버리스하게 DB 구성하려고 하신다면 D1은 좋은 선택지가 될 수 있어요.