Void 데이터베이스: 로컬 SQLite에서 Cloudflare D1까지

Void 데이터베이스: 로컬 SQLite에서 Cloudflare D1까지

Void: Vite 네이티브 배포 플랫폼에서 데이터베이스를 소개할 때 “로컬에서는 SQLite, 프로덕션에서는 Cloudflare D1로 매핑된다”는 한 줄로 짚고 넘어갔는데요. 이번엔 그 데이터베이스 레이어를 제대로 들여다볼게요. 스키마는 어떻게 정의하고, 마이그레이션은 어떻게 굴리고, 쿼리는 어떻게 날리는지까지 차근차근 살펴보겠습니다.

Void 라우팅에서 API 핸들러에 insertUserSchema로 입력을 검증하는 예제를 봤는데, 그 검증기가 어디서 오는지도 이 글에서 채워집니다.

로컬은 SQLite, 프로덕션은 D1

Void의 데이터베이스는 Drizzle ORM 위에 얹혀 있습니다. 기본 백엔드는 D1인데요. 로컬 개발에서는 그냥 SQLite 파일로 돌아가고, 배포하면 Cloudflare D1으로 매핑됩니다. 같은 코드가 내 노트북에서는 SQLite를, 엣지에서는 D1을 바라보는 거죠. PostgreSQL이 필요하면 Hyperdrive를 통해 연결할 수도 있는데, 이건 void init 때 고르거나 void.json에서 "database": "pg"로 바꾸면 됩니다.

여기서 편한 점은 바인딩을 직접 설정할 일이 없다는 거예요. D1을 쓰면 Void가 DB 바인딩을 자동으로 꽂아주고, 뒤에서 볼 void/db 클라이언트가 그걸 알아서 집어 씁니다. wrangler.toml에 데이터베이스 ID를 적거나 대시보드에서 D1을 만드는 과정이 통째로 사라지는 셈이죠.

스키마는 Drizzle로 정의

테이블은 db/schema.ts에 Drizzle 문법으로 정의합니다. Drizzle ORM을 따로 설치할 필요는 없어요. Void가 전부 번들로 들고 있어서 void/schema-d1 같은 경로로 바로 가져다 씁니다.

db/schema.ts
import { sqliteTable, text, integer } from "void/schema-d1";
import { sql } from "void/db";

export const users = sqliteTable("users", {
  id: integer("id").primaryKey({ autoIncrement: true }),
  name: text("name").notNull(),
  email: text("email").notNull().unique(),
  role: text("role").notNull().default("user"),
  createdAt: text("created_at")
    .notNull()
    .default(sql`(datetime('now'))`),
});

notNull(), unique(), default() 같은 제약을 체이닝으로 붙이는 게 Drizzle의 스타일이에요. 이렇게 정의한 스키마는 단순히 테이블 모양만 잡는 게 아니라, 뒤에서 쿼리할 때 컬럼 타입까지 그대로 추론의 근거가 됩니다. 테이블이 늘어나면 db/schema/ 디렉토리로 쪼갠 뒤 db/schema.ts에서 한데 모아 내보내면 되고요.

마이그레이션: push와 generate

스키마를 바꿨으면 실제 데이터베이스에 반영해야겠죠. Void는 두 가지 방식을 제공합니다.

개발 초반에 스키마를 자주 갈아엎을 때는 push가 편합니다. 마이그레이션 파일을 만들지 않고 로컬 데이터베이스에 변경을 바로 찍어줘요.

npx void db push

반대로 운영까지 가져갈 변경이라면 generate로 마이그레이션 파일을 남깁니다. db/migrations/에 타임스탬프가 붙은 SQL 파일이 생기는데, 이게 스키마 변경의 단일 진실 공급원이 됩니다.

npx void db generate

여기서 중요한 점이 있어요. 이렇게 생성된 마이그레이션은 배포할 때 자동으로 적용됩니다. npx void deploy를 돌리면 아직 적용되지 않은 마이그레이션을 찾아 순서대로 실행하기 때문에, 로컬과 프로덕션의 스키마가 어긋날 일이 줄어듭니다. 지금 어떤 변경이 밀려 있는지, 스키마가 코드와 어긋났는지는 npx void db status로 확인할 수 있고요.

void/db로 쿼리하기

데이터를 읽고 쓸 때는 void/db에서 미리 연결된 db 클라이언트를 가져옵니다. @schemadb/schema.ts를 가리키는 별칭이라 테이블을 바로 import할 수 있어요.

import { db } from "void/db";
import { users } from "@schema";

const allUsers = await db.select().from(users);

조건을 걸 때는 eq, and 같은 헬퍼를 함께 가져옵니다.

import { db, eq, and } from "void/db";
import { users } from "@schema";

const user = await db.select().from(users).where(eq(users.id, 1));

const admins = await db
  .select()
  .from(users)
  .where(and(eq(users.role, "admin"), eq(users.name, "Alice")));

삽입은 insert().values()로 하고, 방금 넣은 행을 돌려받고 싶으면 returning()을 붙입니다.

const [created] = await db
  .insert(users)
  .values({ name: "Bob", email: "bob@example.com" })
  .returning();

수정과 삭제도 같은 결이에요. where로 대상을 좁히는 패턴이 그대로 반복됩니다.

import { db, eq, desc } from "void/db";
import { users } from "@schema";

await db.update(users).set({ role: "admin" }).where(eq(users.id, 1));
await db.delete(users).where(eq(users.id, 1));

// 정렬·페이지네이션
const page = await db
  .select()
  .from(users)
  .orderBy(desc(users.createdAt))
  .limit(10)
  .offset(20);

여러 테이블을 묶을 때는 innerJoin으로 조인하고, 필요한 컬럼만 골라 담을 수 있습니다.

import { db, eq } from "void/db";
import { users, posts } from "@schema";

const results = await db
  .select({ id: posts.id, title: posts.title, author: users.name })
  .from(posts)
  .innerJoin(users, eq(posts.userId, users.id));

참고로 db.query.users.findMany() 같은 관계형 쿼리 API는 대부분의 프레임워크에서 동작하지만, Nuxt와 Analog에서는 Nitro 번들링 문제로 쓸 수 없습니다. 이 경우 위처럼 쿼리 빌더를 쓰면 돼요.

초기 데이터 시드 넣기

개발할 때 빈 테이블만 있으면 화면을 확인하기 어렵죠. Void는 시드 기능으로 초기 데이터를 채워줍니다. 기본 진입점은 db/seed.ts이고, defineSeed로 감싼 함수 안에서 평소처럼 db를 쓰면 됩니다.

db/seed.ts
import { defineSeed } from "void/seed";

export default defineSeed(async ({ db, schema }) => {
  await db.insert(schema.users).values([
    { name: "Alice", email: "alice@example.com" },
    { name: "Bob", email: "bob@example.com" },
  ]);
});

시드 컨텍스트로는 로컬 데이터베이스에 연결된 db 인스턴스와 schema가 넘어와요. SQL이 더 편하면 db/seed.sql에 직접 INSERT 문을 적어도 됩니다.

npx void db seed

이 명령은 데이터베이스를 리셋하고, 마이그레이션을 다시 적용한 뒤, 시드 파일을 실행합니다. 그래서 언제든 깨끗한 상태에서 같은 테스트 데이터로 시작할 수 있어요.

Drizzle Studio로 데이터 들여다보기

테이블에 뭐가 들어 있는지 눈으로 보고 싶을 때는 Drizzle Studio를 띄웁니다.

npx void db studio

브라우저에서 열리는 UI로 로컬 데이터베이스의 스키마와 데이터를 둘러보고 직접 수정할 수도 있어요. 앞서 Void 라우팅에서 언급한, 대시보드 없는 Void가 데이터만큼은 이렇게 시각적으로 확인할 길을 열어둔 셈입니다.

스키마에서 검증기까지

이제 시리즈의 조각이 맞물리는 부분이에요. Drizzle 스키마에서 입력 검증기를 바로 뽑아낼 수 있습니다. void/drizzle-zodcreateInsertSchema에 테이블을 넘기면 끝이에요.

db/schema.ts
import { createInsertSchema } from "void/drizzle-zod";

export const insertUserSchema = createInsertSchema(users, {
  name: (schema) => schema.min(1),
  email: (schema) => schema.email(),
});

createInsertSchema는 자동 생성 컬럼(여기선 id, createdAt)을 빼고 삽입에 필요한 필드만 추려줍니다. 쿼리 결과 모양에 맞춘 createSelectSchema, 부분 수정을 위해 모든 필드를 옵셔널로 만드는 createUpdateSchema도 있고요.

여기서 나온 insertUserSchema가 바로 Void 라우팅에서 봤던 그 검증기입니다. 라우트 핸들러에서 withValidator({ body: insertUserSchema })로 걸면, 데이터베이스 스키마 한 곳만 고쳐도 API 입력 검증까지 함께 따라옵니다. 테이블 모양과 검증 규칙이 어긋날 일이 없어지는 거죠.

마치며

Void의 데이터베이스는 로컬 SQLite와 프로덕션 D1을 같은 코드로 다루면서, 스키마 정의부터 마이그레이션, 시드, 쿼리, 검증까지 Drizzle 하나로 꿰는 구조입니다. 바인딩 설정이나 마이그레이션 적용 같은 번거로운 일은 Void가 배포 과정에 녹여뒀고요. 스키마 한 곳을 진실 공급원으로 삼아 쿼리 타입과 입력 검증이 모두 따라오는 흐름이 특히 매력적이에요.

여기서 정의한 데이터를 화면과 API로 풀어내는 방법은 Void 라우팅에서, 이렇게 만든 앱을 배포하는 과정은 Void: Vite 네이티브 배포 플랫폼에서 이어집니다.

더 자세한 내용은 Void 공식 데이터베이스 문서를 참고하세요.

This work is licensed under CC BY 4.0 CC BY

개발자를 위한 뉴스레터

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

Discord