SQLite를 글로벌 분산 DB로 — Turso + libSQL이 서버리스 엣지 아키텍처를 바꾸는 방식
"SQLite? 그거 로컬 개발용 아니야?" — 솔직히 저도 작년까지 그렇게 생각했습니다. 프로덕션에서 SQLite를 쓴다고 하면 은근히 무시당하던 시절이 있었죠. 그런데 2024년을 기점으로 상황이 완전히 뒤집혔습니다. Cloudflare D1과 Turso가 프로덕션 성숙도에 도달하면서, "SQLite = 장난감"이라는 공식이 조용히 깨지고 있습니다.
이 글에서는 Turso + libSQL 조합이 어떻게 SQLite를 글로벌 분산 DB로 탈바꿈시키는지 살펴봅니다. 그리고 같은 영역에서 경쟁하는 Cloudflare D1, PlanetScale과 어떤 상황에서 무엇을 골라야 하는지 선택 기준도 정리합니다. "이거 내 프로젝트에 써도 되나?"라는 질문에 답할 수 있도록, 아키텍처부터 실제 코드, 비용 계산까지 최대한 현실적으로 다뤄볼게요.
이 글을 읽고 나면 임베디드 레플리카가 왜 Turso의 핵심 차별점인지, D1과 Turso를 무엇으로 구분해야 하는지, PlanetScale이 여전히 유효한 선택인 시나리오를 명확하게 정리할 수 있게 될 겁니다.
핵심 개념
SQLite는 왜 갑자기 "엣지 DB"가 됐을까
SQLite는 원래 서버 없이 파일 하나로 동작하는 임베디드 DB입니다. 안드로이드 앱, 브라우저 내부, 비행기 예약 단말기 — 세상에서 가장 많이 배포된 DB라는 말이 과장이 아닙니다. 그런데 문제가 있었습니다. SQLite는 "오픈소스이되 오픈 컨트리뷰션은 아님(open source, not open contribution)" 정책을 고수합니다. 커뮤니티가 클라우드·엣지에 필요한 기능을 제안해도 메인테이너가 수락하지 않으면 그만입니다.
Turso 팀이 바로 여기서 결단을 내렸습니다. SQLite를 포크해서 libSQL을 만든 거죠.
libSQL — SQLite의 오픈 컨트리뷰션 포크
libSQL은 SQLite와 동일한 파일 포맷과 API를 유지하면서, 클라우드·엣지 시대에 필요한 기능들을 추가했습니다:
- 서버 모드: HTTP/WebSocket으로 원격 접속 지원
- 임베디드 레플리카: 로컬 SQLite 파일 ↔ 원격 DB 자동 동기화
- 네이티브 벡터 검색: DiskANN(디스크 기반 근사 최근접 이웃 탐색) 알고리즘, AI/RAG 워크로드 내장
- WASM UDF: WebAssembly로 사용자 정의 함수 작성
JavaScript SDK의 주간 다운로드 수가 80만 건을 넘겼다는 건, 이미 프로덕션에서 실제로 쓰이고 있다는 뜻입니다.
// libSQL 클라이언트 — HTTP 모드 (서버리스/엣지 환경)
import { createClient } from "@libsql/client";
const client = createClient({
// 실제 형식: libsql://[db명]-[org명].turso.io
// https:// 프로토콜도 HTTP 폴백으로 동작하지만, libsql://이 표준 형식
url: "libsql://my-db-myorg.turso.io",
authToken: process.env.TURSO_AUTH_TOKEN,
});
const result = await client.execute(
"SELECT * FROM users WHERE active = 1 LIMIT 10"
);
console.log(result.rows);// libSQL 클라이언트 — 임베디드 레플리카 모드 (Node.js 서버/Fly.io 등)
import { createClient } from "@libsql/client";
const client = createClient({
url: "file:local.db", // 로컬 SQLite 파일
syncUrl: "libsql://my-db-myorg.turso.io", // 원격 프라이머리
authToken: process.env.TURSO_AUTH_TOKEN,
syncInterval: 60, // 60초마다 자동 동기화
});
// 읽기: 로컬 디스크 속도 (~200나노초)
const result = await client.execute("SELECT * FROM products");
// 쓰기: 자동으로 원격 프라이머리에 전달
await client.execute(
"INSERT INTO orders (user_id, total) VALUES (?, ?)",
[userId, total]
);Turso의 글로벌 분산 아키텍처
Turso는 libSQL 위에 구축된 관리형 플랫폼입니다. 전통적인 DB가 장기 실행 서버 프로세스라면, Turso의 데이터베이스는 파일입니다. 아이들 상태에서는 스토리지 비용만 발생하고, 콜드 스타트가 없습니다.
전 세계 35개 이상 리전에서 libSQL 서버를 운영하고, 프라이머리에 쓰기 → WAL(Write-Ahead Log) 세그먼트를 팔로워에 스트리밍 → 사용자 가장 근접 레플리카에서 읽기 구조로 글로벌 단일 자릿수 밀리초 읽기 지연을 달성합니다.
임베디드 레플리카 — Turso의 핵심 차별점
다른 경쟁 제품들과 비교했을 때 Turso가 가진 가장 독보적인 기능이 임베디드 레플리카입니다. 로컬 SQLite 파일을 원격 Turso DB와 자동 동기화하는 건데, 읽기는 **로컬 디스크 속도(~200나노초)**로 처리됩니다. 네트워크를 아예 타지 않는 거죠.
| 모드 | 읽기 지연 | 쓰기 지연 | 사용 환경 |
|---|---|---|---|
| HTTP 모드 | ~수ms (네트워크) | ~수ms | Cloudflare Workers, Vercel Edge |
| 임베디드 레플리카 | ~200ns (로컬 디스크) | ~수ms (원격 동기화) | Node.js 서버, Fly.io, 모바일 앱 |
| 로컬 전용 SQLite | ~200ns | ~200ns | 단일 노드, 복제 불필요 |
Cloudflare D1의 포지셔닝
D1은 Cloudflare Workers에 내장된 관리형 SQLite DB입니다. Workers 런타임과 네이티브로 바인딩돼서 추가 네트워크 홉이 없고, 읽기 지연 ~0.5ms를 달성합니다. 2025년 Sessions API로 read-after-write 일관성 문제도 해결했습니다.
// Cloudflare Workers + D1
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const { results } = await env.DB.prepare(
"SELECT * FROM posts WHERE published = 1 ORDER BY created_at DESC LIMIT 20"
).all();
return Response.json(results);
},
};D1의 강점은 명확합니다. Cloudflare 생태계 — KV, R2, Queues, AI Workers — 를 하나의 플랫폼 안에서 조립할 수 있고, wrangler CLI 하나로 로컬 개발부터 배포까지 처리됩니다. 단, Cloudflare Workers 밖에서는 쓸 수 없습니다. 이 종속성이 D1 선택의 전제 조건입니다.
PlanetScale — 다른 레이어의 경쟁자
PlanetScale은 사실 SQLite 계열과 직접적인 기술 경쟁은 아닙니다. Vitess(MySQL) 또는 Raft 합의 기반 Postgres 클러스터를 제공하는 서버리스 플랫폼이라, 완전한 MySQL/Postgres 기능 세트(저장 프로시저, 복잡한 트랜잭션)를 지원합니다. 데이터베이스 브랜칭과 무중단 DDL이 핵심 차별점이고, 대규모 팀의 스키마 관리 워크플로에 최적화돼 있습니다.
2025년에 Postgres용 PlanetScale이 정식 출시됐고, 단일 노드 $5/월부터 시작합니다. 단, 2024년 4월 무료 플랜을 폐지한 이후 소규모 프로젝트에서 이탈이 상당히 있었습니다.
한 가지 주의할 점은 Vitess 기반 MySQL의 경우 외래 키 제약(Foreign Key)이 기본 비활성화 상태라는 겁니다. 참조 무결성을 애플리케이션 레이어에서 관리하는 패턴을 미리 잡아야 합니다.
실전 적용
시나리오 1: 멀티테넌트 SaaS — 테넌트별 독립 DB
Turso를 처음 접했을 때 가장 인상적이었던 부분이 여기였습니다. 테넌트마다 DB를 따로 만든다는 발상 자체는 데이터 격리 관점에서 이상적이지만, 기존 방식으로는 서버 비용이 폭발적으로 늘어나죠.
Turso에서는 DB가 파일이라서, 1,000개 테넌트 DB를 만들어도 아이들 상태에서는 스토리지 비용만 나옵니다. 2025년 "Database Freedom Day" 이후 모든 유료 플랜에서 DB 수 제한이 폐지됐고, 동시 활성 500개 이하 기준으로 1,000 테넌트 = $4.99/월 수준이 됩니다.
// Turso Platform REST API로 테넌트별 DB 자동 프로비저닝
// 공식 관리 SDK(@turso/api)도 제공되므로 최신 문서에서 패키지명 확인 권장
async function provisionTenantDatabase(tenantId: string, region: string) {
const org = process.env.TURSO_ORG!;
const apiToken = process.env.TURSO_API_TOKEN!;
const baseUrl = "https://api.turso.tech/v1";
// 테넌트 DB 생성
const createRes = await fetch(`${baseUrl}/organizations/${org}/databases`, {
method: "POST",
headers: {
Authorization: `Bearer ${apiToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
name: `tenant-${tenantId}`,
group: "production",
location: region, // 테넌트 위치에 맞는 리전
}),
});
const { database } = await createRes.json();
// 접속 토큰 발급 (테넌트별 격리)
const tokenRes = await fetch(
`${baseUrl}/organizations/${org}/databases/${database.Name}/auth/tokens`,
{
method: "POST",
headers: { Authorization: `Bearer ${apiToken}` },
}
);
const { jwt } = await tokenRes.json();
return {
url: `libsql://${database.Hostname}`,
authToken: jwt,
};
}
// 테넌트 접속 정보는 별도 메타DB(예: PostgreSQL, Redis)에 보관
// 최초 프로비저닝 시 저장하고, 이후 요청마다 조회
async function getTenantCredentials(tenantId: string): Promise<{ url: string; authToken: string }> {
const meta = await metaDb.findOne({ tenantId });
if (!meta) throw new Error(`테넌트 ${tenantId}의 DB 정보를 찾을 수 없습니다.`);
return { url: meta.dbUrl, authToken: meta.dbToken };
}
// 테넌트 요청 처리
async function handleTenantRequest(tenantId: string, query: string) {
const { url, authToken } = await getTenantCredentials(tenantId);
const client = createClient({ url, authToken });
return await client.execute(query);
}// 스키마 마이그레이션 — 모든 테넌트에 일괄 적용
async function migrateAllTenants(migrationSql: string) {
const tenants = await getAllTenants();
await Promise.allSettled(
tenants.map(async (tenant) => {
const client = createClient({
url: tenant.dbUrl,
authToken: tenant.dbToken,
});
await client.executeMultiple(migrationSql);
})
);
}시나리오 2: Next.js App Router + Turso + Drizzle ORM
Vercel + Turso 조합은 현재 가장 많이 쓰이는 스택 중 하나입니다. Turso가 Vercel Marketplace에 공식 등록돼 있어서 연동이 간단합니다.
pnpm add @libsql/client drizzle-orm
pnpm add -D drizzle-kit// lib/db.ts — Drizzle + libSQL 설정
import { drizzle } from "drizzle-orm/libsql";
import { createClient } from "@libsql/client";
const client = createClient({
url: process.env.TURSO_DATABASE_URL!,
authToken: process.env.TURSO_AUTH_TOKEN!,
});
export const db = drizzle(client);// schema.ts
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core";
export const posts = sqliteTable("posts", {
id: integer("id").primaryKey({ autoIncrement: true }),
title: text("title").notNull(),
content: text("content").notNull(),
authorId: integer("author_id").notNull(),
publishedAt: integer("published_at", { mode: "timestamp" }),
});// app/posts/page.tsx — Server Component (읽기)
import { db } from "@/lib/db";
import { posts } from "@/schema";
import { desc, isNotNull } from "drizzle-orm";
export default async function PostsPage() {
const publishedPosts = await db
.select()
.from(posts)
.where(isNotNull(posts.publishedAt))
.orderBy(desc(posts.publishedAt))
.limit(20);
return (
<ul>
{publishedPosts.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
);
}// app/posts/actions.ts — Server Action (쓰기)
"use server";
import { db } from "@/lib/db";
import { posts } from "@/schema";
import { revalidatePath } from "next/cache";
export async function createPost(formData: FormData) {
const title = formData.get("title") as string;
const content = formData.get("content") as string;
// 경계값 검증 — 실제 서비스에서는 zod 등 스키마 검증 라이브러리 사용 권장
if (!title?.trim() || !content?.trim()) {
throw new Error("제목과 내용을 입력해야 합니다.");
}
await db.insert(posts).values({
title,
content,
authorId: 1, // 실제로는 세션에서 가져옴
});
revalidatePath("/posts");
}시나리오 3: Cloudflare Workers + D1 + Hono
D1을 선택한다면 Cloudflare 생태계 안에서 움직이는 게 자연스럽습니다. Hono는 D1과 잘 어울리는 경량 프레임워크입니다.
// src/index.ts — Hono + D1
import { Hono } from "hono";
import { cors } from "hono/cors";
type Bindings = {
DB: D1Database;
};
const app = new Hono<{ Bindings: Bindings }>();
app.use("/*", cors());
app.get("/api/products", async (c) => {
const { results } = await c.env.DB.prepare(
"SELECT id, name, price FROM products WHERE active = 1"
).all();
return c.json(results);
});
// Sessions API — read-after-write 일관성 보장
// "first-primary"는 Cloudflare가 정의한 리터럴로,
// 해당 세션의 모든 쿼리를 프라이머리 노드에서 실행하도록 강제함
app.post("/api/orders", async (c) => {
const body = await c.req.json();
const session = c.env.DB.withSession("first-primary");
await session
.prepare("INSERT INTO orders (user_id, product_id, amount) VALUES (?, ?, ?)")
.bind(body.userId, body.productId, body.amount)
.run();
// 같은 세션으로 읽기 — 방금 쓴 데이터 보장
const { results } = await session
.prepare(
"SELECT * FROM orders WHERE user_id = ? ORDER BY created_at DESC LIMIT 1"
)
.bind(body.userId)
.all();
return c.json(results[0]);
});
export default app;# wrangler.toml
name = "my-api"
main = "src/index.ts"
compatibility_date = "2026-01-01"
[[d1_databases]]
binding = "DB"
database_name = "my-database"
database_id = "your-database-id-here"시나리오 4: AI 에이전트 상태 저장 + 벡터 검색
libSQL의 네이티브 벡터 검색 기능은 AI 에이전트 워크로드에서 진가를 발휘합니다. 에이전트 메모리, 임베딩, 작업 이력을 단일 DB에 저장할 수 있습니다.
// AI 에이전트 DB 설정 — 벡터 인덱스 포함
const initSchema = `
CREATE TABLE IF NOT EXISTS agent_memory (
id INTEGER PRIMARY KEY AUTOINCREMENT,
agent_id TEXT NOT NULL,
content TEXT NOT NULL,
embedding F32_BLOB(1536), -- OpenAI text-embedding-3-small
created_at INTEGER DEFAULT (unixepoch())
);
CREATE INDEX IF NOT EXISTS memory_embedding_idx
ON agent_memory (libsql_vector_idx(embedding));
`;
await client.executeMultiple(initSchema);// 메모리 저장
async function storeMemory(
agentId: string,
content: string,
embedding: number[]
) {
await client.execute({
sql: `
INSERT INTO agent_memory (agent_id, content, embedding)
VALUES (?, ?, vector32(?))
`,
args: [agentId, content, JSON.stringify(embedding)],
});
}
// 유사 메모리 검색 (RAG)
// vector_top_k는 rowid와 distance를 반환하므로 alias로 컬럼 명확화
async function recallSimilarMemories(
agentId: string,
queryEmbedding: number[],
topK = 5
) {
const result = await client.execute({
sql: `
SELECT m.content, v.distance
FROM vector_top_k('memory_embedding_idx', vector32(?), ?) AS v
JOIN agent_memory AS m ON m.rowid = v.id
WHERE m.agent_id = ?
ORDER BY v.distance
`,
args: [JSON.stringify(queryEmbedding), topK, agentId],
});
return result.rows;
}시나리오 5: PlanetScale + Prisma (Next.js)
PlanetScale은 HTTP 드라이버 덕분에 서버리스 환경에서도 연결 풀 고갈 문제 없이 동작합니다. Prisma와의 조합이 가장 일반적입니다.
pnpm add @prisma/client
pnpm add -D prisma
npx prisma init// prisma/schema.prisma
datasource db {
provider = "mysql"
url = env("DATABASE_URL")
relationMode = "prisma" // Vitess에서는 FK 제약 대신 Prisma 레이어에서 참조 무결성 관리
}
generator client {
provider = "prisma-client-js"
}
model Post {
id Int @id @default(autoincrement())
title String
content String @db.Text
published Boolean @default(false)
publishedAt DateTime?
authorId Int
@@index([authorId])
}# .env — PlanetScale 연결 문자열
DATABASE_URL="mysql://username:password@aws.connect.psdb.cloud/mydb?sslaccept=strict"// lib/db.ts — PlanetScale + Prisma (서버리스 환경용 싱글톤)
import { PrismaClient } from "@prisma/client";
const globalForPrisma = globalThis as unknown as { prisma: PrismaClient };
export const prisma =
globalForPrisma.prisma ??
new PrismaClient();
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;// app/posts/page.tsx — Server Component
import { prisma } from "@/lib/db";
export default async function PostsPage() {
const posts = await prisma.post.findMany({
where: { published: true },
orderBy: { publishedAt: "desc" },
take: 20,
});
return (
<ul>
{posts.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
);
}PlanetScale의 브랜칭 워크플로도 언급할 가치가 있습니다. pscale branch create mydb feature/add-comments로 DB 브랜치를 만들고, 스키마 변경 후 pscale deploy-request create로 PR처럼 검토·머지하는 흐름입니다. 복잡한 마이그레이션을 다운타임 없이 진행하는 게 PlanetScale이 가장 잘하는 영역입니다.
장단점 분석
세 플랫폼 비교표
| Turso + libSQL | Cloudflare D1 | PlanetScale | |
|---|---|---|---|
| 기반 기술 | libSQL (SQLite 포크) | SQLite | MySQL / Postgres |
| 읽기 지연 | ~200ns (임베디드) / 수ms (HTTP) | ~0.5ms (Workers 내) | 수ms~수십ms |
| 쓰기 지연 | 수ms (원격 프라이머리) | 수ms | 수ms~수십ms |
| 플랫폼 종속성 | 없음 (어디서나 사용) | Cloudflare Workers 전용 | 낮음 (HTTP 드라이버) |
| 멀티테넌트 DB | ✅ (무제한, 권장 패턴) | ❌ (지원 미흡) | △ (가능하나 비쌈) |
| 벡터 검색 | ✅ (DiskANN 내장) | ❌ | ❌ |
| 인터랙티브 트랜잭션 | ✅ | ❌ | ✅ |
| 저장 프로시저 | ❌ | ❌ | ✅ |
| DB 브랜칭 | ❌ | ❌ | ✅ |
| 무료 플랜 | ✅ | ✅ | ❌ |
| DB당 용량 한계 | 플랜별 상이 — 공식 제한 문서 확인 | 10GB 상한 | 사실상 무제한 (Vitess 샤딩) |
| 자체 호스팅 | ✅ (sqld) | ❌ | ❌ |
시나리오별 선택 기준
실무에서 흔한 실수
Turso 관련
syncInterval을 너무 짧게 설정하면 불필요한 네트워크 비용이 발생합니다. 읽기 일관성 요구사항에 맞게 조정하는 게 좋습니다.- 임베디드 레플리카는 WAL(Write-Ahead Log) 기반 비동기 복제라 "최종 일관성(eventual consistency)" 모델입니다. 쓰기 직후 바로 읽으면 아직 동기화되지 않은 데이터가 반환될 수 있습니다. 즉각적 일관성이 필요하면
client.sync()명시 호출이 필요합니다. - 단일 DB 용량 한계를 간과하다가 대규모 로그·이벤트 데이터를 밀어 넣는 경우 문제가 됩니다. 대용량 단일 DB 워크로드는 Turso 적합 범위 밖입니다.
D1 관련
BEGIN/COMMIT인터랙티브 트랜잭션이 안 되는 점을 간과합니다. 여러 쿼리를 묶어야 한다면batch()API를 써야 합니다.batch()API는 여러 쿼리를 단일 HTTP 라운드트립으로 묶어 처리해서 인터랙티브 트랜잭션 부재를 부분적으로 보완합니다.- 10GB 용량 상한은 절대 한계입니다. 성장 계획이 있다면 미리 고려해야 합니다.
- Workers 밖에서 D1에 직접 접근하는 방법이 없어서, 로컬 개발 환경에서 실제 D1 데이터를 보려면 wrangler를 거쳐야 합니다.
PlanetScale 관련
- Vitess 기반 MySQL은 외래 키 제약이 기본 비활성화 상태라는 점을 당연하게 기대했다가 낭패를 볼 수 있습니다. Prisma 사용 시
relationMode = "prisma"설정이 필수입니다. - 무료 플랜이 없기 때문에 MVP/사이드 프로젝트에서는 초기 비용 부담이 있습니다.
마치며
서버리스·엣지 DB 선택에서 핵심은 하나입니다. 어떤 플랫폼에서 어떤 워크로드를 돌리느냐.
- Cloudflare Workers 중심 아키텍처라면 D1이 가장 자연스럽습니다. 별도 학습 비용 없이 Workers 생태계에 DB가 붙습니다.
- 멀티테넌트 SaaS, AI/에이전트 워크로드, 플랫폼 비종속이 중요하다면 Turso를 고려해볼 만합니다. 특히 임베디드 레플리카와 무제한 DB 아키텍처는 SQLite 계열에서 Turso만 제공하는 차별점입니다.
- 복잡한 SQL, 높은 쓰기 처리량, 팀 스키마 관리가 필요하다면 PlanetScale이 여전히 유효합니다. SQLite 계열이 커버 못 하는 영역을 담당합니다.
각 플랫폼으로 시작하는 방법을 간단히 정리하면:
D1: wrangler d1 create <db-name> → wrangler.toml에 바인딩 추가 → wrangler dev로 로컬 개발 시작
Turso: brew install tursodatabase/tap/turso → turso auth login → turso db create <db-name> → @libsql/client + Drizzle/Prisma 연결
PlanetScale: planetscale.com 대시보드에서 DB 생성 → 연결 문자열 복사 → Prisma datasource 설정
SQLite 기반 DB가 엄격한 프로덕션 환경에서 채택되는 사례가 2026년 현재도 계속 나오고 있습니다. 세 플랫폼 모두 무료 또는 저렴한 티어에서 시작할 수 있으니, 실제 워크로드로 직접 시도해보는 것이 가장 좋은 선택 기준이 됩니다.
참고 자료
- libSQL 공식 문서 — Turso
- GitHub: tursodatabase/libsql
- Turso 공식 사이트 — "에이전트 시대를 위한 데이터베이스"
- Turso Database Freedom Day — 무제한 DB 발표
- Cloudflare D1 공식 문서
- Cloudflare D1 제한 사항 (공식)
- Bejamas: Cloudflare D1 vs PlanetScale vs Turso 3자 비교
- Turso vs Cloudflare D1 2026 상세 비교
- DEV: Distributed SQLite — Why LibSQL and Turso are the New Standard in 2026
- Turso and libSQL: SQLite at the Edge With Embedded Replicas
- PlanetScale 공식 가격 페이지
- Turso 멀티테넌시 공식 문서
- GitHub: hbmartin/comparison-serverless-cloud-sql-databases