서울은 50ms, 상파울루는 280ms — Turso LibSQL로 격차를 줄이는 법
"왜 상파울루 사용자만 280ms가 나오지?" 이 질문의 답은 대부분 간단합니다. DB가 단일 리전에 갇혀 있기 때문입니다. CDN으로 정적 파일은 해결했는데, 결국 DB가 발목을 잡는 그 상황 말입니다.
2025~2026년에 들어서면서 "SQLite 르네상스"라는 말이 심심찮게 등장하고 있습니다. SQLite를 장난감 DB로 여기던 시절은 지났고, 이제는 엣지 환경의 프로덕션 DB로 진지하게 검토되고 있습니다. 그 중심에 Turso와 LibSQL이 있습니다.
이 글에서는 LibSQL이 SQLite의 한계를 어떻게 넘어서는지, 임베디드 레플리카가 50ms 응답을 만드는 아키텍처, Drizzle ORM과 turso 다이얼렉트를 활용한 타입 안전 쿼리, 프로덕션에서 안전한 스키마 마이그레이션 워크플로우까지 코드로 짚어봅니다. 멀티테넌트 SaaS 패턴도 포함합니다.
LibSQL: SQLite DNA를 가진 분산 데이터베이스
SQLite는 오픈소스이지만 외부 기여를 받지 않습니다. 네트워크 접근, 서버사이드 복제, 동시 다중 연결 같은 기능을 공식적으로 추가할 방법이 없다는 뜻입니다. LibSQL은 이 한계를 해결하기 위해 SQLite를 포크하여 만든 프로젝트입니다.
| 기능 | SQLite | LibSQL |
|---|---|---|
| HTTP 프로토콜 접근 | 불가 | 네이티브 지원 |
| 서버사이드 복제 | 불가 | WAL 스트리밍 기반 복제 |
| 임베디드 레플리카 | 불가 | 원격 DB와 자동 동기화 |
| 벡터 검색 | 불가 | 네이티브 내장 |
| 기존 SQLite 호환성 | — | 완전 호환 |
| 커넥션 풀 필요 여부 | 불필요 | 불필요 |
로컬 개발 시에는 file:./dev.db로 일반 SQLite 파일처럼 쓰다가, 프로덕션에서는 Turso Cloud URL로 바꾸는 것만으로 전환이 됩니다.
Turso 아키텍처: 쓰기는 하나, 읽기는 전 세계
Turso의 기본 구조는 단일 프라이머리 + 다중 리드 레플리카입니다. 쓰기 요청은 항상 프라이머리로 라우팅되고, 읽기 요청은 사용자와 가장 가까운 PoP(Point of Presence, 이 글에서 "리전"과 동일한 의미로 사용)의 레플리카에서 처리됩니다. 프라이머리는 WAL(Write-Ahead Log)을 스트리밍 방식으로 레플리카에 전파합니다.
여기서 짚어둬야 할 포인트가 두 가지 있습니다.
쓰기 집약 워크로드 주의: 쓰기가 단일 프라이머리에 집중되므로, 초당 수천 건을 넘는 쓰기 집약적 워크로드에는 맞지 않습니다. 읽기 비중이 절대적인 콘텐츠 사이트, 문서 서비스, 노트 앱 같은 경우에 잘 맞고, 주문 처리나 실시간 채팅처럼 쓰기가 많은 서비스라면 CockroachDB·PlanetScale 등 다른 솔루션을 고려하는 편이 좋습니다.
최종 일관성: WAL 스트리밍은 비동기입니다. 레플리카는 **최종 일관성(eventual consistency)**을 보장합니다. 쓰기 직후 레플리카에서 즉시 최신 데이터를 읽어야 하는 시나리오라면 별도 처리가 필요합니다. 이 주제는 뒤에서 코드와 함께 다룹니다. 아키텍처를 설계할 때부터 이 특성을 고려해두는 것이 좋습니다.
읽기 레플리카 vs 임베디드 레플리카: 어디서 얼마나 빠른가
"레플리카"라는 단어가 두 가지 개념에 쓰여서 헷갈릴 수 있습니다. 이 차이가 이 글 전체 논지의 핵심입니다.
| 구분 | 읽기 레플리카 | 임베디드 레플리카 |
|---|---|---|
| 위치 | Turso 클라우드 특정 PoP 서버 | 애플리케이션 프로세스 내부 |
| 읽기 지연시간 | 수~수십 ms (네트워크 왕복 필요) | 수백 마이크로초 (디스크 I/O만) |
| 동작 방식 | 가장 가까운 PoP 서버로 요청 라우팅 | 로컬 SQLite 파일에서 즉시 반환 |
| 사용 가능 환경 | 모든 환경 | 퍼시스턴트 파일시스템이 있는 환경 |
| 설정 | 자동 (Turso가 라우팅) | syncUrl + syncInterval 옵션 |
여기서 중요한 환경 제약이 있습니다. Cloudflare Workers(V8 isolate)는 퍼시스턴트 파일시스템이 없어 임베디드 레플리카를 사용할 수 없습니다. Workers에서는 HTTP 원격 클라이언트로 읽기 레플리카에 연결합니다. 임베디드 레플리카의 마이크로초 읽기 속도는 Node.js 서버, Deno 서버처럼 로컬 파일시스템을 유지할 수 있는 환경에서만 누릴 수 있습니다.
WAL 스트리밍은 비동기로 백그라운드에서 진행됩니다. 쓰기 요청이 완료된 시점에 로컬 레플리카가 즉시 업데이트되지는 않습니다.
@libsql/client 기본 연결 설정
npm install @libsql/client drizzle-orm
npm install -D drizzle-kit원격 연결 (기본 패턴)
import { createClient } from "@libsql/client";
const client = createClient({
url: process.env.TURSO_DATABASE_URL!,
authToken: process.env.TURSO_AUTH_TOKEN!,
});임베디드 레플리카 연결
import { createClient } from "@libsql/client";
const client = createClient({
url: "file:./local.db",
syncUrl: process.env.TURSO_DATABASE_URL!,
authToken: process.env.TURSO_AUTH_TOKEN!,
syncInterval: 60, // 단위: 초. 생략 시 자동 동기화 없음
});
// 앱 시작 시 최신 상태로 동기화
await client.sync();syncUrl이 있으면 임베디드 레플리카 모드로 동작합니다. syncInterval의 단위는 **초(second)**이며, 이 옵션을 생략하면 자동 주기 동기화가 비활성화되고 수동으로 client.sync()를 호출할 때만 원격과 동기화됩니다. 임베디드 레플리카를 처음 쓸 때 이 부분을 놓치면 앱이 점점 오래된 데이터를 반환하게 됩니다.
로컬 개발 시에는 url: "file:./dev.db"만 지정하면 Turso 없이도 그대로 동작합니다.
실전 적용
1단계: Turso CLI로 DB 생성 및 레플리카 추가
# Turso CLI 설치 (macOS)
brew install tursodatabase/tap/turso
# 로그인
turso auth login
# DB 생성 (도쿄를 프라이머리로)
turso db create my-app --location nrt
# 읽기 레플리카 추가
turso db replicate my-app --location iad # 버지니아 (미국 동부)
turso db replicate my-app --location lhr # 런던
# 연결 정보 확인
turso db show my-app --url
turso db tokens create my-appPoP 코드 전체 목록은 turso db locations 명령으로 확인할 수 있습니다. 35개 이상의 PoP 중 서비스 사용자 분포에 맞게 레플리카를 추가하는 방식입니다.
2단계: Drizzle ORM 스키마 정의
Turso의 공식 권장 ORM은 Drizzle ORM입니다. drizzle-orm/libsql 어댑터와 함께 사용합니다.
스키마 정의 (src/schema.ts)
import { text, integer, sqliteTable } from "drizzle-orm/sqlite-core";
import { sql, relations } from "drizzle-orm";
export const users = sqliteTable("users", {
id: integer("id").primaryKey({ autoIncrement: true }),
name: text("name").notNull(),
email: text("email").notNull().unique(),
createdAt: integer("created_at", { mode: "timestamp" })
.notNull()
.default(sql`(unixepoch())`),
});
export const posts = sqliteTable("posts", {
id: integer("id").primaryKey({ autoIncrement: true }),
title: text("title").notNull(),
content: text("content"),
userId: integer("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
publishedAt: integer("published_at", { mode: "timestamp" }),
});
export const usersRelations = relations(users, ({ many }) => ({
posts: many(posts),
}));
export const postsRelations = relations(posts, ({ one }) => ({
author: one(users, {
fields: [posts.userId],
references: [users.id],
}),
}));DB 연결 설정 (src/db.ts)
import { drizzle } from "drizzle-orm/libsql";
import { createClient } from "@libsql/client";
import * as schema from "./schema";
const client = createClient({
url: process.env.TURSO_DATABASE_URL ?? "file:./dev.db",
authToken: process.env.TURSO_AUTH_TOKEN,
});
export const db = drizzle(client, { schema });
export { client };authToken은 로컬 파일 모드에서는 없어도 됩니다. 환경변수 TURSO_DATABASE_URL이 없으면 로컬 SQLite 파일로 동작합니다.
타입 안전 쿼리 예시
import { db } from "./db";
import { users, posts } from "./schema";
import { eq, desc } from "drizzle-orm";
// 모든 사용자 조회
const allUsers = await db.select().from(users);
// 특정 사용자의 최신 포스트 5개 (JOIN)
const recentPosts = await db
.select({
title: posts.title,
publishedAt: posts.publishedAt,
authorName: users.name,
})
.from(posts)
.innerJoin(users, eq(posts.userId, users.id))
.where(eq(posts.userId, 1))
.orderBy(desc(posts.publishedAt))
.limit(5);
// 새 사용자 추가 (쓰기 → 프라이머리로 라우팅)
const newUser = await db
.insert(users)
.values({ name: "김철수", email: "kim@example.com" })
.returning();3단계: 안전한 스키마 마이그레이션 워크플로우
drizzle-kit 0.25.0부터 turso 다이얼렉트가 sqlite와 분리됐습니다. dialect: "sqlite"로 지정하면 LibSQL 특화 기능이 제대로 동작하지 않을 수 있으니, 반드시 dialect: "turso"를 지정해야 합니다.
drizzle.config.ts
import { defineConfig } from "drizzle-kit";
export default defineConfig({
dialect: "turso",
schema: "./src/schema.ts",
out: "./drizzle",
dbCredentials: {
url: process.env.TURSO_DATABASE_URL ?? "file:./dev.db",
authToken: process.env.TURSO_AUTH_TOKEN,
},
});db.ts와 동일하게 로컬 폴백을 추가해두면, TURSO_DATABASE_URL 환경변수 없이도 로컬에서 npx drizzle-kit generate를 실행할 수 있습니다.
프로덕션 마이그레이션은 다음 순서로 진행하는 것이 안전합니다.
# 스키마 변경 후 마이그레이션 파일 생성
npx drizzle-kit generate
# drizzle/ 디렉토리에 생성된 SQL 파일 확인
cat drizzle/0001_add_published_at.sql
# 실제 DB에 적용
npx drizzle-kit migratedrizzle-kit push는 로컬 개발 시 빠른 프로토타이핑에는 편리하지만, 마이그레이션 이력이 남지 않고 스키마에서 컬럼이 사라지면 DB에서도 즉시 삭제됩니다. 저도 처음에 push를 썼다가 로컬 테스트 데이터를 날려먹은 적이 있는데, 프로덕션이었으면 아찔했을 상황입니다. 프로덕션에서는 generate → SQL 검토 → migrate 순서를 지키는 것이 안전합니다.
4단계: 멀티테넌트 DB 패턴
테넌트(고객)마다 독립된 Turso DB를 생성하는 패턴입니다. 데이터 격리, 개별 백업·복원, 특정 테넌트 데이터의 완전 삭제(GDPR Right to Erasure 이행 등)가 DB 삭제 한 번으로 처리됩니다. 단, GDPR 준수는 데이터 삭제 외에도 처리 기록 관리, 동의 관리 등 별도 요소를 포함한다는 점은 별도로 챙겨야 합니다.
Turso Platform API로 프로그래밍 방식 DB 생성
async function createTenantDatabase(tenantId: string): Promise<string> {
const response = await fetch(
`https://api.turso.tech/v1/organizations/${process.env.TURSO_ORG}/databases`,
{
method: "POST",
headers: {
Authorization: `Bearer ${process.env.TURSO_API_TOKEN}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
name: `tenant-${tenantId}`,
group: "default",
}),
}
);
if (!response.ok) {
throw new Error(`DB 생성 실패: ${response.status} ${response.statusText}`);
}
const data = await response.json();
return data.database.Hostname;
}테넌트별 DB 연결 팩토리 (캐싱 포함)
import { createClient } from "@libsql/client";
import { drizzle } from "drizzle-orm/libsql";
import * as schema from "./schema";
const tenantDbCache = new Map<string, ReturnType<typeof drizzle>>();
function getTenantDb(tenantId: string) {
if (tenantDbCache.has(tenantId)) {
return tenantDbCache.get(tenantId)!;
}
const client = createClient({
url: `libsql://tenant-${tenantId}-${process.env.TURSO_ORG}.turso.io`,
authToken: process.env.TURSO_AUTH_TOKEN!,
});
const db = drizzle(client, { schema });
tenantDbCache.set(tenantId, db);
return db;
}
// API 라우터 예시 (Next.js App Router)
export async function GET(
request: Request,
{ params }: { params: { tenantId: string } }
) {
const db = getTenantDb(params.tenantId);
const data = await db.select().from(schema.posts);
return Response.json(data);
}Map으로 캐싱하지 않으면 요청마다 클라이언트 객체가 새로 생성됩니다. LibSQL은 HTTP 기반이라 TCP 커넥션 고갈 문제는 아니지만, 불필요한 객체 생성이 반복됩니다. 서버리스 환경에서는 모듈 수준 캐시가 인스턴스 수명만큼 유지됩니다.
Turso는 조직당 수십만 개의 DB를 지원하는 구조라, 이 패턴이 실제로 경제적으로 운영됩니다.
쓰기 직후 읽기 일관성 처리
임베디드 레플리카를 쓸 때 자주 마주치는 문제입니다. 기본 설정에서 로컬 레플리카는 최종 일관성을 보장하므로, 쓰기 직후 로컬 레플리카에서 읽으면 아직 반영되지 않은 이전 데이터가 반환될 수 있습니다.
해결 방법은 쓰기 완료 후 명시적으로 client.sync()를 호출하는 것입니다. 이 패턴은 임베디드 레플리카 클라이언트(로컬 url + syncUrl 설정)에서만 유효합니다. syncUrl 없이 원격 URL만 설정한 클라이언트에서는 sync()가 동작하지 않습니다.
import { createClient } from "@libsql/client";
import { drizzle } from "drizzle-orm/libsql";
import { users } from "./schema";
// 임베디드 레플리카 클라이언트 — syncUrl 필수
const client = createClient({
url: "file:./local.db",
syncUrl: process.env.TURSO_DATABASE_URL!,
authToken: process.env.TURSO_AUTH_TOKEN!,
});
const db = drizzle(client, { schema: { users } });
async function createUserAndReturn(name: string, email: string) {
const newUser = await db
.insert(users)
.values({ name, email })
.returning();
// 로컬 레플리카를 원격 프라이머리와 즉시 동기화
await client.sync();
// 이 시점부터 로컬 레플리카 읽기에 방금 쓴 데이터가 반영됨
return newUser;
}syncInterval로 주기 동기화를 설정하더라도, 쓰기 직후 즉시 최신 데이터가 필요한 플로우에서는 client.sync()를 명시적으로 호출하는 것이 안전합니다.
장단점 정리
장점
| 항목 | 내용 |
|---|---|
| 글로벌 읽기 지연시간 | 35개+ PoP 복제 + 임베디드 레플리카로 50ms 이하, 이상적 환경에서 수백 마이크로초 |
| SQLite 완전 호환 | 기존 코드베이스·도구 그대로 사용, 로컬 개발은 단순 파일 DB |
| 서버리스 친화 | HTTP 기반 연결, 커넥션 풀 불필요 |
| 멀티테넌시 비용 효율 | 소형 DB 수십만 개를 저렴하게 운영 가능 |
| 벡터 검색 내장 | 별도 벡터 DB 없이 AI 임베딩 유사도 검색 처리 |
| 개발자 경험 | Drizzle ORM 공식 통합, 타입 안전 쿼리, drizzle-kit 자동화 |
단점
| 항목 | 내용 |
|---|---|
| 쓰기 병목 | 단일 프라이머리 구조, 초당 수천 건 초과 쓰기에 부적합 |
| 최종 일관성 | 기본 설정은 eventual consistency, 쓰기 직후 즉시 읽기 보장은 별도 처리 필요 |
| SQL 기능 제한 | PostgreSQL 대비 ENUM·ARRAY·UUID 타입 없음, ALTER TABLE 제한적 |
| 다중 리전 쓰기 불가 | 강한 일관성 필요 시 CockroachDB·PlanetScale 등 대안 고려 |
| 파일시스템 필요 | 임베디드 레플리카는 Cloudflare Workers 등 파일시스템 없는 환경에서 사용 불가 |
| 벤더 종속 가능성 | Turso Cloud 의존 시 자체 호스팅 전환 비용 발생 가능 |
자주 마주치는 실수
1. drizzle-kit push를 프로덕션에 사용
가장 흔한 실수입니다. push는 마이그레이션 이력 없이 현재 스키마 상태를 DB에 강제 동기화합니다. 스키마에서 컬럼을 지우면 DB에서도 즉시 삭제됩니다. 프로덕션에서는 generate → SQL 검토 → migrate 순서만 사용하는 것이 안전합니다.
2. dialect를 "sqlite"로 지정
drizzle-kit 0.25.0 이후 turso 다이얼렉트가 분리됐습니다. dialect: "sqlite"로 지정하면 LibSQL 특화 기능이 제대로 동작하지 않을 수 있습니다.
3. 쓰기 집약 워크로드에 적용
읽기 비중이 낮고 쓰기가 많은 서비스(실시간 채팅, 주문 처리 등)에 적용했다가 프라이머리 병목이 발생하는 경우가 있습니다. Turso는 읽기 비중이 높은 서비스에 최적화되어 있습니다.
4. syncInterval 없이 임베디드 레플리카 설정
syncInterval을 생략하면 자동 주기 동기화가 비활성화됩니다. 앱 시작 시 client.sync()를 호출하더라도, 이후 데이터가 갱신되지 않아 점점 오래된 데이터를 반환하게 됩니다. syncInterval: 60처럼 명시적으로 지정하거나, 적절한 시점에 수동 client.sync()를 호출하는 전략을 선택하는 것이 좋습니다.
5. Cloudflare Workers에서 임베디드 레플리카 사용 시도
Workers는 V8 isolate 환경이라 퍼시스턴트 파일시스템이 없습니다. 임베디드 레플리카 설정을 그대로 가져오면 동작하지 않습니다. Workers 환경에서는 syncUrl 없이 url만 지정하는 HTTP 원격 클라이언트 방식을 써야 합니다.
마치며
Turso LibSQL은 읽기 비중이 높은 글로벌 서비스에서 탁월한 선택지입니다. 임베디드 레플리카로 수백 마이크로초 읽기를 달성하고, 35개+ PoP 복제로 어디서든 50ms 이하 응답이 가능하며, SQLite 완전 호환으로 마이그레이션 부담이 낮습니다. Drizzle ORM과 turso 다이얼렉트의 조합은 타입 안전한 쿼리와 안전한 마이그레이션 워크플로우를 동시에 제공합니다.
단, 단일 프라이머리 구조의 쓰기 병목, 최종 일관성의 특성, SQLite의 기능 제한, 임베디드 레플리카가 파일시스템 없는 환경에서는 동작하지 않는다는 점을 이해하고 적합한 워크로드에 적용하는 것이 중요합니다.
지금 시작할 수 있는 3단계
turso db create로 DB를 생성하고, 서비스 사용자 분포에 맞는 PoP에 레플리카를 추가합니다.drizzle.config.ts에dialect: "turso"를 지정하고 Drizzle ORM으로 타입 안전한 스키마와 쿼리를 작성합니다.- 스키마 변경 시
drizzle-kit generate→ SQL 파일 검토 →drizzle-kit migrate순서를 습관으로 만들어둡니다.
참고 자료
- Turso 공식 문서 — LibSQL 개요
- Turso 공식 문서 — Drizzle ORM 연동
- Drizzle ORM 공식 튜토리얼 — Drizzle with Turso
- Drizzle ORM 공식 문서 — Turso Cloud 연결
- Drizzle ORM 공식 문서 — Get Started with Turso
- Turso 공식 블로그 — Microsecond-level SQL query latency with libSQL local replicas
- Turso 공식 블로그 — Beyond the Single-Writer Limitation with Turso's Concurrent Writes
- DEV Community — Distributed SQLite: Why LibSQL and Turso are the New Standard in 2026
- BotMonster — Turso and libSQL: SQLite at the Edge With Embedded Replicas
- Better Stack — How Turso Eliminates SQLite's Single-Writer Bottleneck
- Oflight Inc. — Turso Complete Guide 2026
- Calmops — Turso and LibSQL Complete Guide
- GitHub — drizzle-kit 0.25.0 Changelog