Turso + libSQL + Drizzle ORM으로 만드는 엣지 네이티브 DB 스택 — Cloudflare Workers에서 한 자릿수 밀리초 읽기까지
서버리스로 전환하면서 가장 먼저 마주친 병목은 DB 레이턴시였습니다. 컴퓨팅은 이미 전 세계 엣지에 배포했는데, 데이터베이스는 버지니아 us-east-1 한 곳에 고정되어 있었거든요. 서울 사용자가 API를 호출하면 Workers 인스턴스는 근처에서 떴지만, DB 쿼리는 북아메리카 동부 해안을 왕복했습니다. p99 레이턴시가 300ms를 넘는 구간이 생겼고, 엣지 컴퓨팅이 DB 왕복 시간 앞에서 무력해지는 상황이었습니다.
Turso를 처음 접했을 때 선뜻 믿기 어려웠던 이유가 있었습니다. SQLite는 단일 파일에 임베딩되는 DB인데, 분산 복제와는 태생적으로 어울리지 않아 보였거든요. 클라이언트-서버 DB와 달리 SQLite는 단독 프로세스가 파일을 직접 읽는 구조이고, 네트워크 복제를 붙이려면 프로토콜 자체를 새로 설계해야 합니다. 그걸 libSQL이 실제로 해냈고, Turso는 그 위에 전 세계 레플리카 인프라를 얹었습니다.
이 글에서는 Turso와 libSQL이 어떻게 동작하는지, Drizzle ORM으로 타입 안전한 쿼리 레이어를 구성하는 방법, 그리고 Cloudflare Workers에 배포하기까지의 과정을 다룹니다.
libSQL: SQLite의 오픈소스 포크
libSQL은 SQLite의 공개 포크(fork)입니다. SQLite 자체는 퍼블릭 도메인으로 라이선스 제약이 가장 적은 쪽에 속하지만, 프로젝트 운영 정책이 closed development model을 따릅니다. 핵심 팀 외에는 코드가 채택되지 않는 구조이고, 외부 개발자가 기여할 수 있는 공식 CLA 프로세스도 없습니다. 라이선스 문제가 아니라 기여 정책의 문제입니다. libSQL은 이 한계에서 출발해 다음 기능들을 추가했습니다.
- 원격 복제 및 HTTP 프로토콜 지원
- 추가
ALTER TABLE구문 - DiskANN 기반 ANN(근사 최근접 이웃) 벡터 인덱스
- WASM 및 엣지 런타임 친화적 빌드
SQLite와 바이너리 수준 호환성을 유지하기 때문에 기존 SQLite 스키마나 드라이버를 대부분 그대로 가져올 수 있습니다.
Turso의 쓰기-읽기 분리 아키텍처
Turso는 libSQL 위에 구축된 엣지 DBaaS입니다. 핵심 구조는 쓰기는 단일 Primary로, 읽기는 가장 가까운 레플리카로 입니다.
Turso 내부 라우팅의 구현 상세(DNS 기반인지 드라이버 수준인지)는 공개된 문서에 명시되어 있지 않습니다. 위 다이어그램은 동작 모델을 단순화한 것입니다.
Turso 공식 문서 기준 35개 이상의 위치에 레플리카가 배포되어 있으며(시점에 따라 변동 가능), 읽기 요청은 물리적으로 가장 가까운 레플리카로 자동 라우팅됩니다. 엣지 레플리카 기준 10ms 이하의 읽기 레이턴시를 달성할 수 있다고 공식 문서에 명시되어 있으나, 실제 레이턴시는 네트워크 경로, 쿼리 복잡도, 리전 인프라 상황에 따라 달라집니다.
쓰기는 반드시 단일 Primary를 거칩니다. 쓰기 직렬화가 보장되는 대신, 고빈도 쓰기 워크로드에서는 Primary가 병목이 될 수 있습니다. 이 트레이드오프는 스택 적합성 기준표에서 다시 다룹니다.
Embedded Replica: Workers에서는 사용할 수 없는 기능
Turso에는 Embedded Replica라는 기능이 있습니다. 애플리케이션 프로세스 내부에 로컬 SQLite 파일을 유지하면서 Primary와 자동 동기화하는 구조입니다. 읽기는 이 로컬 파일에서 처리되기 때문에 네트워크 왕복이 없습니다. Turso Sync 벤치마크 블로그(측정 환경: 동일 프로세스 내 파일 읽기, 단순 SELECT 쿼리 기준)에서 평균 624μs 수준의 읽기 레이턴시를 보고했습니다. 측정 조건이 다르면 결과도 달라집니다.
Cloudflare Workers에서는 이 기능을 사용할 수 없습니다. Workers 런타임에 로컬 파일 시스템이 없어서 SQLite 파일 자체를 생성할 수 없기 때문입니다. Embedded Replica는 Node.js 서버, Tauri 데스크톱 앱, Deno 환경처럼 파일 시스템 접근이 가능한 런타임에서만 활용할 수 있습니다.
Drizzle ORM: TypeScript 타입 안전 레이어
Drizzle ORM은 스키마를 TypeScript로 선언하면 타입이 자동 추론되는 ORM입니다. drizzle-orm/libsql 어댑터를 통해 libSQL 호환 클라이언트와 연결하며, drizzle-kit으로 마이그레이션 파일을 자동 생성하고 Turso DB에 적용할 수 있습니다.
런타임별 드라이버 선택
코드를 작성하기 전에 런타임에 맞는 드라이버를 먼저 선택해야 합니다. 틀린 드라이버를 선택하면 빌드 단계에서 실패하거나 런타임에 예측하기 어려운 오류가 발생합니다.
| 런타임 | 드라이버 | 특징 |
|---|---|---|
| Node.js, Deno, 번들러 환경 | @libsql/client |
Node.js 네이티브 모듈 사용 가능, Embedded Replica 지원 |
| Cloudflare Workers, Pages | @tursodatabase/serverless |
순수 fetch() API 기반, 파일 시스템 불필요 |
Workers 환경에서 @libsql/client를 사용하면 Node.js 네이티브 모듈 의존성 때문에 빌드가 실패합니다. @tursodatabase/serverless는 fetch() API만 사용하는 순수 HTTP 드라이버로 모든 엣지 런타임에서 동작합니다.
연결 관계를 짚고 넘어가면, @tursodatabase/serverless의 createClient()가 반환하는 객체는 libSQL HTTP 프로토콜을 구현한 클라이언트 인터페이스를 따릅니다. drizzle-orm/libsql의 drizzle() 함수는 이 인터페이스를 그대로 인자로 받아들이기 때문에 별도의 어댑터 없이 두 패키지를 바로 연결할 수 있습니다. 다만 메이저 버전 간 API 시그니처가 변경될 수 있으므로, 실제 프로젝트에서는 각 패키지의 공식 문서에서 현재 버전의 호환성을 확인하는 것을 권장합니다.
실전 적용
버전 참고: 이 글의 코드 예시는 2026년 7월 기준으로 작성됐습니다.
drizzle-orm,drizzle-kit,@tursodatabase/serverless,hono모두 메이저 버전 간 API 변경이 있으므로, 현재 사용 중인 버전의 공식 문서와 함께 확인하시기 바랍니다.
Turso CLI로 DB 생성
# Turso CLI 설치 (macOS)
brew install tursodatabase/tap/turso
# 로그인
turso auth login
# 사용 가능한 리전 목록 확인
turso db locations
# 리전을 지정하지 않으면 현재 위치 기준 자동 선택
turso db create my-app-db
# Primary 위치를 직접 지정하려면 --location 플래그 사용
# 정확한 리전 코드는 turso db locations 결과를 참고하세요
turso db create my-app-db --location nrt
# 연결 URL 및 토큰 확인
turso db show my-app-db --url
turso db tokens create my-app-dbPrimary 리전 선택은 쓰기 레이턴시에 직접 영향을 줍니다. 서비스 사용자가 주로 아시아에 있다면 Primary를 도쿄나 싱가포르에 두는 것이 쓰기 지연을 최소화하는 방법입니다. 읽기는 어느 리전에 Primary가 있든 가장 가까운 레플리카에서 처리되므로 리전 선택이 읽기 성능에 미치는 영향은 작습니다.
Workers 프로젝트에 패키지를 설치합니다.
npm install drizzle-orm @tursodatabase/serverless hono zod
npm install -D drizzle-kit wrangler스키마 정의
// src/db/schema.ts
import { int, sqliteTable, text } from 'drizzle-orm/sqlite-core';
export const usersTable = sqliteTable('users', {
id: int().primaryKey({ autoIncrement: true }),
name: text().notNull(),
email: text().notNull().unique(),
// SQLite에 datetime 타입이 없어 Unix 타임스탬프(정수)로 저장, Drizzle이 Date 변환 자동 처리
createdAt: int({ mode: 'timestamp' }).$defaultFn(() => new Date()),
});
export const postsTable = sqliteTable('posts', {
id: int().primaryKey({ autoIncrement: true }),
title: text().notNull(),
content: text(),
authorId: int().notNull().references(() => usersTable.id),
});int, text 같은 타입 헬퍼 덕분에 컬럼 타입이 TypeScript 타입으로 자동 추론됩니다. 쿼리 결과를 별도로 캐스팅할 필요가 없습니다.
drizzle.config.ts 구성
// drizzle.config.ts
import { defineConfig } from 'drizzle-kit';
export default defineConfig({
schema: './src/db/schema.ts',
out: './migrations',
dialect: 'turso', // 'sqlite'가 아닌 'turso'로 지정해야 합니다
dbCredentials: {
url: process.env.TURSO_DATABASE_URL!,
authToken: process.env.TURSO_AUTH_TOKEN!,
},
});# 스키마 변경 사항을 마이그레이션 파일로 생성
npx drizzle-kit generate
# Turso DB에 마이그레이션 적용
npx drizzle-kit migrateCloudflare Workers + Hono로 API 구성
실제 요청 흐름에서 핵심은 라우팅 결정 주체입니다. 읽기는 서울 레플리카에서 처리되고, 쓰기는 Turso SDK가 내부적으로 Primary로 자동 전달합니다. 애플리케이션 코드에서 Primary URL을 별도로 알 필요가 없습니다.
Workers에서의 클라이언트 생성 전략도 중요합니다. 하나의 요청 핸들러 안에서 getDb()를 여러 번 호출하면 클라이언트가 중복 생성됩니다. Hono 미들웨어에서 한 번 생성해 컨텍스트에 주입하면 이 문제를 피할 수 있습니다.
// src/index.ts
import { Hono } from 'hono';
import { drizzle } from 'drizzle-orm/libsql';
import { createClient } from '@tursodatabase/serverless';
import { usersTable } from './db/schema';
import { eq } from 'drizzle-orm';
import { z } from 'zod';
type Env = {
TURSO_DATABASE_URL: string;
TURSO_AUTH_TOKEN: string;
};
function getDb(env: Env) {
const client = createClient({
url: env.TURSO_DATABASE_URL,
authToken: env.TURSO_AUTH_TOKEN,
});
return drizzle(client);
}
type Variables = {
db: ReturnType<typeof getDb>;
};
const app = new Hono<{ Bindings: Env; Variables: Variables }>();
app.use('*', async (c, next) => {
c.set('db', getDb(c.env));
await next();
});
const userSchema = z.object({
name: z.string().min(1),
email: z.string().email(),
});
app.get('/api/users', async (c) => {
const db = c.get('db');
const users = await db.select().from(usersTable);
return c.json(users);
});
app.get('/api/users/:id', async (c) => {
const db = c.get('db');
const rawId = Number(c.req.param('id'));
if (!Number.isInteger(rawId) || rawId <= 0) {
return c.json({ error: 'Invalid id' }, 400);
}
const user = await db
.select()
.from(usersTable)
.where(eq(usersTable.id, rawId))
.get();
if (!user) return c.json({ error: 'Not found' }, 404);
return c.json(user);
});
app.post('/api/users', async (c) => {
const db = c.get('db');
const parsed = userSchema.safeParse(await c.req.json());
if (!parsed.success) {
return c.json({ error: parsed.error.flatten() }, 400);
}
const result = await db.insert(usersTable).values(parsed.data).returning();
return c.json(result[0], 201);
});
export default app;c.set()과 c.get()을 사용할 때 Variables 타입을 Hono 제네릭에 선언해야 합니다. 타입 선언 방법의 전체 예시는 Hono 공식 문서의 Context 섹션을 참고하세요.
wrangler.toml 설정과 배포
# wrangler.toml
name = "my-edge-api"
main = "src/index.ts"
# 이 날짜는 Workers 런타임 동작에 직접 영향을 줍니다.
# 최신 권장 날짜는 Cloudflare Workers 공식 문서에서 확인하세요.
compatibility_date = "2026-07-01"시크릿은 소스에 직접 넣지 않고 Wrangler CLI로 주입합니다. 명령 실행 후 터미널이 값을 기다리는 상태가 됩니다. 값을 입력하고 Enter를 누르면 저장됩니다.
wrangler secret put TURSO_DATABASE_URL
# 프롬프트에 URL 값 입력 후 Enter
wrangler secret put TURSO_AUTH_TOKEN
# 프롬프트에 토큰 값 입력 후 EnterCloudflare Integrations Marketplace에서 Turso를 연결하면 대시보드 클릭 몇 번으로 이 과정 일부를 자동화할 수 있습니다. 배포는 아래 한 줄입니다.
wrangler deploy이 스택을 선택하기 전에 알아야 할 것들
멀티 테넌트 SaaS에서 DB-per-tenant가 현실적인 이유
Turso가 특히 강점을 발휘하는 시나리오 중 하나는 멀티 테넌트 SaaS입니다. PostgreSQL에서 테넌트마다 별도 DB를 운영하면 테넌트당 서버 자원이 고정으로 들어갑니다. libSQL은 파일 하나가 하나의 DB이기 때문에, 유휴 테넌트의 자원 점유가 거의 없습니다. 각 테넌트에 별도 URL과 토큰만 발급하면 데이터 격리와 독립적인 스케일링이 동시에 가능합니다. 아키텍처 예시는 Turso Database Per Tenant 공식 문서를 참고하세요.
로컬 퍼스트 앱과 Embedded Replica 조합
Embedded Replica를 활용하면 Tauri 데스크톱 앱이나 Node.js 서버처럼 파일 시스템이 있는 환경에서 오프라인 읽기를 지원하면서 클라우드와 자동 동기화할 수 있습니다. Workers에서는 이 기능을 쓸 수 없지만, 같은 스키마와 Drizzle 쿼리 코드를 데스크톱 앱과 서버 사이에 공유하는 모노레포 구조에서 강점이 나타납니다. Drizzle + Turso Sync를 Tauri에 적용한 예시는 DEV Community 글을 참고하세요.
벡터 검색: 관계형 데이터와 같은 DB에서
libSQL은 DiskANN 기반 ANN 인덱스를 내장합니다. 벡터 임베딩과 관계형 데이터를 같은 DB에서 관리할 수 있어 초기 스택 복잡도를 낮출 수 있습니다. 다만 대규모 벡터 검색 요구사항이 있다면 전문 벡터 DB와 성능을 비교 검토해보는 것이 좋습니다. API 레퍼런스는 Turso 공식 문서에서 확인하세요.
고빈도 쓰기 워크로드에서는 다른 선택지를
단일 Primary 모델은 쓰기 직렬화를 보장하는 대신 처리량에 상한이 있습니다. 실시간 이벤트 처리, 초당 수백 건의 쓰기가 필요한 시나리오에서는 Turso 단독 적용이 맞지 않습니다. 쓰기는 PostgreSQL 계열 DB(Neon, Supabase 등)로 분리하거나, 큐를 통해 비동기 처리하는 아키텍처를 검토해보세요.
Turso는 최종 일관성 모델을 따릅니다. 쓰기 직후 동일한 읽기 요청에서 변경 사항이 즉시 보이지 않을 수 있습니다. 강한 일관성이 비즈니스 요구사항인 서비스라면 적합하지 않습니다.
스택 적합성 기준표
| 항목 | 평가 | 설명 |
|---|---|---|
| 읽기 레이턴시 | ✅ 우수 | 엣지 레플리카 기준 10ms 이하 (Turso 공식 문서 기준, 조건 따라 변동) |
| SQLite 호환성 | ✅ 우수 | 기존 SQLite 스키마·드라이버 재사용 가능 |
| 서버리스 친화성 | ✅ 우수 | HTTP 드라이버로 Workers·Deno Deploy·Vercel Edge 모두 지원 |
| 멀티 테넌트 경제성 | ✅ 우수 | DB-per-tenant 패턴에서 유휴 테넌트 자원 점유 최소 |
| 벡터 검색 내장 | ✅ 우수 | 관계형 데이터와 벡터 데이터를 단일 DB에서 관리 가능 |
| Drizzle ORM 통합 | ✅ 우수 | 스키마에서 마이그레이션·쿼리까지 타입 안전 파이프라인 |
| 쓰기 성능 | ⚠️ 제한 | 단일 Primary 모델, 고빈도 쓰기 워크로드에 부적합 |
| 쓰기 일관성 | ⚠️ 제한 | 최종 일관성 모델, 강한 일관성 보장 없음 |
| 고급 SQL 기능 | ⚠️ 제한 | PostGIS·pg_trgm 같은 PostgreSQL 전용 확장 사용 불가 |
| 엔터프라이즈 SLA | ⚠️ 주의 | 스타트업 규모 회사, SLA 조항 꼼꼼한 검토 필요 |
| libSQL 포크 리스크 | ⚠️ 주의 | SQLite 메인라인과의 장기 호환성 지속 모니터링 필요 |
| 복제 엔진 안정성 | ⚠️ 주의 | 클라우드 네이티브 복제 엔진은 2026년 7월 기준 BETA 단계 (공식 문서 현황 확인 권장) |
실무에서 자주 막히는 지점
Workers에서 Embedded Replica 사용 시도
Workers 런타임에 파일 시스템이 없어서 로컬 SQLite 파일을 생성할 수 없습니다. Embedded Replica 예제를 그대로 붙이면 빌드 오류가 납니다.
// ❌ Workers에서 동작하지 않음
import { createClient } from '@libsql/client';
const client = createClient({
url: 'file:local.db',
syncUrl: env.TURSO_DATABASE_URL,
});
// ✅ Workers에서의 올바른 방법
import { createClient } from '@tursodatabase/serverless';
const client = createClient({
url: env.TURSO_DATABASE_URL,
authToken: env.TURSO_AUTH_TOKEN,
});dialect를 'sqlite'로 지정
drizzle.config.ts에서 Turso를 대상으로 할 때는 반드시 dialect: 'turso'여야 합니다. 'sqlite'로 지정하면 마이그레이션이 예상과 다르게 동작할 수 있습니다.
모듈 레벨에서 DB 클라이언트를 생성
Workers에서는 요청 컨텍스트 밖에서 env 객체에 접근할 수 없습니다. 클라이언트 생성은 반드시 요청 핸들러 또는 미들웨어 내부에서 이뤄져야 합니다.
마치며
이 스택이 잘 맞는 서비스는 명확합니다. 읽기 비율이 높고, 사용자가 전 세계에 분산되어 있으며, SQLite의 단순한 데이터 모델로 충분한 서비스입니다. 글로벌 API, 멀티 테넌트 SaaS, 로컬 퍼스트 앱이 그 사례입니다.
맞지 않는 서비스도 마찬가지로 명확합니다. 강한 일관성이 필요하거나, 쓰기 처리량이 높거나, PostgreSQL 전용 확장에 의존하는 서비스에서는 이 스택을 선택하기 전에 비용과 한계를 충분히 검토해야 합니다.
이 스택이 본인 서비스에 맞는다고 판단된다면, 자연스러운 다음 탐색 경로가 있습니다. Node.js 환경에서 Embedded Replica를 활용한 마이크로초 읽기 구현, 런타임에 테넌트별 DB URL을 전환하는 멀티 테넌트 분기 패턴, 그리고 libSQL 벡터 인덱스와 Drizzle 쿼리를 결합한 시맨틱 검색 구현이 이 스택의 깊이를 더 탐색해볼 만한 방향입니다.
참고 자료
- Turso 공식 문서
- libSQL 공식 문서
- Turso Embedded Replicas 소개
- Drizzle ORM + Turso 공식 튜토리얼
- Drizzle ORM + Turso 연결 가이드
- Cloudflare Workers에서 Turso 연결 공식 튜토리얼
- Cloudflare Workers Third-party Integrations: Turso
- Turso 서버리스 JavaScript 드라이버 발표 블로그
- Turso Sync 벤치마크 블로그
- Turso Database Per Tenant 아키텍처
- Distributed SQLite: Why LibSQL and Turso are the New Standard in 2026 — DEV Community
- Turso Complete Guide 2026 (Oflight Inc.)
- The Edge-Native Stack: Cloudflare Workers and Turso
- Building a Local-First Tauri App with Drizzle ORM and Turso Sync — DEV Community
- Cloudflare Integrations Marketplace: Turso 파트너 발표