Drizzle ORM + Turso 엣지 DB

Turso와 libSQL이란?

Turso는 SQLite 포크인 libSQL 기반의 엣지 데이터베이스다. SQLite의 단순함과 신뢰성을 유지하면서 HTTP 프로토콜, 임베디드 복제(Embedded Replicas), 멀티 테넌트 스키마를 지원한다. Cloudflare Workers, Vercel Edge Functions 같은 엣지 런타임에서도 사용할 수 있으며, Drizzle ORM과의 공식 통합을 제공한다.

전통적인 PostgreSQL/MySQL 대비 읽기 지연시간이 극도로 낮고(로컬 복제본 활용), 서버리스 환경에서 커넥션 풀 관리가 불필요하다는 것이 핵심 이점이다.

프로젝트 설정

# 패키지 설치
pnpm add drizzle-orm @libsql/client
pnpm add -D drizzle-kit

# Turso CLI 설치 및 DB 생성
curl -sSfL https://get.tur.so/install.sh | bash
turso db create my-app
turso db show my-app --url    # libsql://my-app-xxx.turso.io
turso db tokens create my-app # 인증 토큰
// drizzle.config.ts
import { defineConfig } from 'drizzle-kit';

export default defineConfig({
  schema: './src/db/schema.ts',
  out: './drizzle',
  dialect: 'turso',
  dbCredentials: {
    url: process.env.TURSO_DATABASE_URL!,
    authToken: process.env.TURSO_AUTH_TOKEN,
  },
});

스키마 정의: SQLite 방언

// src/db/schema.ts
import { sqliteTable, text, integer, real, index } from 'drizzle-orm/sqlite-core';
import { relations, sql } from 'drizzle-orm';

export const users = sqliteTable('users', {
  id: integer('id').primaryKey({ autoIncrement: true }),
  email: text('email').notNull().unique(),
  name: text('name').notNull(),
  role: text('role', { enum: ['admin', 'user', 'guest'] })
    .default('user')
    .notNull(),
  createdAt: text('created_at')
    .default(sql`(CURRENT_TIMESTAMP)`)
    .notNull(),
}, (table) => ({
  emailIdx: index('email_idx').on(table.email),
}));

export const posts = sqliteTable('posts', {
  id: integer('id').primaryKey({ autoIncrement: true }),
  title: text('title').notNull(),
  content: text('content'),
  slug: text('slug').notNull().unique(),
  authorId: integer('author_id')
    .notNull()
    .references(() => users.id, { onDelete: 'cascade' }),
  published: integer('published', { mode: 'boolean' }).default(false),
  viewCount: integer('view_count').default(0),
  createdAt: text('created_at')
    .default(sql`(CURRENT_TIMESTAMP)`)
    .notNull(),
});

export const tags = sqliteTable('tags', {
  id: integer('id').primaryKey({ autoIncrement: true }),
  name: text('name').notNull().unique(),
});

// 다대다 조인 테이블
export const postTags = sqliteTable('post_tags', {
  postId: integer('post_id')
    .notNull()
    .references(() => posts.id, { onDelete: 'cascade' }),
  tagId: integer('tag_id')
    .notNull()
    .references(() => tags.id, { onDelete: 'cascade' }),
});

// Relations 정의
export const usersRelations = relations(users, ({ many }) => ({
  posts: many(posts),
}));

export const postsRelations = relations(posts, ({ one, many }) => ({
  author: one(users, {
    fields: [posts.authorId],
    references: [users.id],
  }),
  postTags: many(postTags),
}));

SQLite는 날짜 타입이 없으므로 text로 ISO 8601 형식을 저장한다. boolean은 integermode: 'boolean'을 설정하면 Drizzle이 자동 변환한다.

클라이언트 초기화: 리모트 vs 임베디드 복제

// src/db/index.ts
import { drizzle } from 'drizzle-orm/libsql';
import { createClient } from '@libsql/client';
import * as schema from './schema';

// 1. 리모트 연결 (서버리스/엣지)
const remoteClient = createClient({
  url: process.env.TURSO_DATABASE_URL!,
  authToken: process.env.TURSO_AUTH_TOKEN,
});
export const db = drizzle(remoteClient, { schema });

// 2. 임베디드 복제본 (Node.js 서버 — 초저지연 읽기)
const replicaClient = createClient({
  url: 'file:local-replica.db',         // 로컬 SQLite 파일
  syncUrl: process.env.TURSO_DATABASE_URL!,
  authToken: process.env.TURSO_AUTH_TOKEN,
  syncInterval: 60,                      // 60초마다 동기화
});
export const replicaDb = drizzle(replicaClient, { schema });

// 3. 로컬 개발 (순수 SQLite)
const devClient = createClient({
  url: 'file:dev.db',
});
export const devDb = drizzle(devClient, { schema });

임베디드 복제본이 Turso의 킬러 기능이다. 읽기는 로컬 SQLite에서 수행하고(< 1ms), 쓰기만 리모트로 전송한다. 환경 설정 모듈로 환경별 클라이언트를 분기하면 깔끔하다.

CRUD 쿼리 패턴

import { eq, like, and, desc, sql, count } from 'drizzle-orm';
import { db } from './db';
import { users, posts, postTags, tags } from './db/schema';

// INSERT
const newUser = await db.insert(users).values({
  email: 'test@example.com',
  name: 'Test User',
  role: 'user',
}).returning();

// INSERT 다건
await db.insert(posts).values([
  { title: 'First Post', slug: 'first-post', authorId: 1 },
  { title: 'Second Post', slug: 'second-post', authorId: 1 },
]);

// SELECT with relations (Relational Query API)
const userWithPosts = await db.query.users.findFirst({
  where: eq(users.id, 1),
  with: {
    posts: {
      where: eq(posts.published, true),
      orderBy: [desc(posts.createdAt)],
      limit: 10,
    },
  },
});

// 검색 + 페이지네이션
const searchResults = await db
  .select({
    id: posts.id,
    title: posts.title,
    authorName: users.name,
    viewCount: posts.viewCount,
  })
  .from(posts)
  .innerJoin(users, eq(posts.authorId, users.id))
  .where(
    and(
      like(posts.title, `%${keyword}%`),
      eq(posts.published, true),
    )
  )
  .orderBy(desc(posts.viewCount))
  .limit(20)
  .offset(page * 20);

// UPDATE with returning
const updated = await db
  .update(posts)
  .set({
    viewCount: sql`${posts.viewCount} + 1`,
  })
  .where(eq(posts.slug, 'first-post'))
  .returning({ id: posts.id, viewCount: posts.viewCount });

// DELETE
await db.delete(postTags)
  .where(eq(postTags.postId, 5));

// 집계 쿼리
const stats = await db
  .select({
    authorId: posts.authorId,
    authorName: users.name,
    postCount: count(posts.id),
    totalViews: sql<number>`sum(${posts.viewCount})`,
  })
  .from(posts)
  .innerJoin(users, eq(posts.authorId, users.id))
  .groupBy(posts.authorId, users.name)
  .having(sql`count(${posts.id}) >= 5`);

트랜잭션과 배치

// 트랜잭션: 게시글 + 태그 동시 생성
const result = await db.transaction(async (tx) => {
  const [post] = await tx.insert(posts).values({
    title: 'Tagged Post',
    slug: 'tagged-post',
    authorId: 1,
    published: true,
  }).returning();

  const tagIds = [1, 3, 5];
  await tx.insert(postTags).values(
    tagIds.map(tagId => ({
      postId: post.id,
      tagId,
    }))
  );

  return post;
});

// 배치: 여러 쿼리를 한 번에 전송 (네트워크 라운드트립 감소)
const batchResult = await db.batch([
  db.insert(users).values({ email: 'a@test.com', name: 'A' }),
  db.insert(users).values({ email: 'b@test.com', name: 'B' }),
  db.select().from(users),
]);

db.batch()는 Turso의 HTTP 프로토콜에서 여러 쿼리를 단일 요청으로 묶어 전송한다. 엣지 환경에서 트랜잭션 오버헤드를 줄이는 핵심 기법이다.

마이그레이션 운영

# 스키마 변경 감지 → 마이그레이션 파일 생성
pnpm drizzle-kit generate

# 생성된 SQL 확인
cat drizzle/0001_create_users.sql

# Turso에 마이그레이션 적용
pnpm drizzle-kit migrate

# 또는 프로그래매틱 마이그레이션 (앱 시작 시)
import { migrate } from 'drizzle-orm/libsql/migrator';

await migrate(db, { migrationsFolder: './drizzle' });
// CI/CD 파이프라인에서 마이그레이션
// Dockerfile
FROM node:20-slim
COPY drizzle/ ./drizzle/
COPY drizzle.config.ts ./
RUN pnpm drizzle-kit migrate

엣지 배포: Cloudflare Workers 연동

// Cloudflare Workers + Hono + Drizzle + Turso
import { Hono } from 'hono';
import { drizzle } from 'drizzle-orm/libsql';
import { createClient } from '@libsql/client/web'; // 웹 호환 클라이언트
import * as schema from './db/schema';

type Bindings = {
  TURSO_URL: string;
  TURSO_TOKEN: string;
};

const app = new Hono<{ Bindings: Bindings }>();

app.get('/api/posts', async (c) => {
  const client = createClient({
    url: c.env.TURSO_URL,
    authToken: c.env.TURSO_TOKEN,
  });
  const db = drizzle(client, { schema });

  const allPosts = await db.query.posts.findMany({
    where: eq(posts.published, true),
    with: { author: true },
    orderBy: [desc(posts.createdAt)],
    limit: 20,
  });

  return c.json(allPosts);
});

export default app;

성능 팁과 주의사항

항목 권장사항
임베디드 복제 Node.js 서버에서는 반드시 사용. 읽기 지연 < 1ms
배치 쿼리 여러 독립 쿼리는 db.batch()로 묶어 전송
인덱스 SQLite는 자동 인덱스 없음. WHERE/JOIN 컬럼에 명시적 인덱스 필수
WAL 모드 Turso는 기본 WAL. 로컬 개발 시 PRAGMA journal_mode=WAL 설정
동시 쓰기 SQLite는 쓰기 락 존재. 높은 쓰기 동시성에는 PostgreSQL 고려

Turso + Drizzle ORM 조합은 읽기 중심 워크로드에서 탁월하다. 블로그, CMS, 대시보드, 설정 관리 같은 유스케이스에 최적이며, 임베디드 복제본으로 글로벌 엣지 배포 시 사용자에게 밀리초 단위 응답을 제공할 수 있다.

위로 스크롤
WordPress Appliance - Powered by TurnKey Linux