Cloudflare Workers에서 멀티 리전 읽기를 10ms 이하로 — Turso HTTP 클라이언트, Drizzle ORM, 그리고 임베디드 레플리카가 실제로 작동하는 환경
분산 SQLite라는 개념을 처음 접했을 때 솔직히 좀 헷갈렸습니다. "Turso 임베디드 레플리카를 쓰면 쿼리가 로컬에서 처리돼서 단번에 수백 마이크로초가 나온다"는 말을 읽고 "오, Cloudflare Workers에 붙이면 엣지에서 초고속 쿼리를 날릴 수 있겠다"고 흥분했거든요. 그런데 막상 코드를 짜다 보니 뭔가 이상합니다. Workers에는 영구적인 파일시스템이 없으니까요.
Cloudflare Workers에서는 임베디드 레플리카를 사용할 수 없습니다 — 이걸 먼저 이야기하고 시작하는 게 맞을 것 같습니다.
그러면 Workers에서 어떻게 10ms 이하 읽기를 달성하는 걸까요? 답은 Turso가 제공하는 HTTP 기반 엣지 라우팅에 있습니다. Workers가 HTTP 클라이언트로 Turso의 엔드포인트에 연결하면, 클라이언트는 단일 호스트명으로 요청을 보내지만 Turso 인프라가 그 요청을 요청 출발지에서 가장 가까운 리전의 레플리카로 분기합니다 (Cloudflare Workers × Turso 서드파티 통합 문서 참조). 여기서 나오는 읽기 지연이 10ms 미만입니다.
임베디드 레플리카가 주는 로컬 읽기는 공식 벤치마크 기준 평균 624μs에 달하지만 (Turso 임베디드 레플리카 도입 발표 참조), Node.js·Bun처럼 영구 파일시스템이 있는 런타임에서만 가능한 이야기입니다. PostgreSQL 연결이 대서양을 건너오는 50–100ms와 비교하면 HTTP 클라이언트 경로의 10ms 미만도 충분히 실용적인 차이입니다.
이 글에서는 두 가지 경로를 명확히 분리해서 설명합니다. Cloudflare Workers에서의 HTTP 클라이언트 패턴, 그리고 Node.js·Bun 환경에서 진짜 임베디드 레플리카를 쓰는 패턴입니다. 두 경우 모두 Drizzle ORM이 타입 안전 레이어로 들어오고, 마이그레이션은 drizzle-kit이 처리합니다.
핵심 개념
libSQL — Turso가 SQLite를 분산 환경으로 확장한 방식
Turso의 기반은 libSQL이라는 SQLite 오픈소스 포크입니다. 기존 SQLite는 단일 파일 임베디드 데이터베이스로 단일 프로세스 안에서만 동작했습니다. libSQL은 SQLite 바이너리 포맷 호환성을 유지하면서 세 가지를 추가했습니다.
- 네이티브 네트워크 복제: WAL 프레임 스트리밍 기반
- HTTP 프로토콜 지원: V8 Isolate 환경에서도 연결 가능
- 읽기-자신의-쓰기 보장: 동일 클라이언트 연결에서 자신이 발행한 쓰기는
sync()없이 즉시 읽을 수 있음 — 단, 이 보장은 같은 연결 안에서만 유효하며, 다른 클라이언트나 다른 인스턴스의 쓰기는syncInterval또는 명시적sync()호출을 통해야 반영됨
WAL(Write-Ahead Log)은 변경 사항을 페이지 단위 프레임으로 기록합니다. Turso는 이 WAL 프레임을 네트워크로 스트리밍해서 레플리카들을 업데이트합니다. 각 프레임이 단일 데이터베이스 페이지 변경을 나타내기 때문에 증분 동기화가 가능하고, 레플리카 간 복제 지연은 대체로 1–50ms 수준입니다 (LibSQL 복제 아키텍처 분석 참조).
임베디드 레플리카 vs HTTP 클라이언트 — 결정적 분기점
여기가 많은 분들이 헷갈리는 지점입니다. Turso에는 두 가지 연결 모드가 있고, 어떤 런타임을 사용하느냐에 따라 선택지가 갈립니다.
임베디드 레플리카는 원격 Turso 데이터베이스와 동기화되는 로컬 SQLite 파일입니다. 읽기가 로컬 파일에서 처리되기 때문에 네트워크 왕복이 없습니다. 평균 624μs가 나오는 이유입니다. 단, 영구 파일시스템이 있는 런타임에서만 동작합니다.
HTTP 클라이언트는 Cloudflare Workers처럼 파일시스템이 없는 환경을 위한 것입니다. @libsql/client/http 또는 @tursodatabase/serverless 패키지를 사용하며, 클라이언트는 단일 Turso 엔드포인트 URL로 HTTP 요청을 보냅니다. Turso 인프라가 요청 출발지에서 가장 가까운 리전의 레플리카로 분기하기 때문에 10ms 미만 지연이 가능합니다. 2025년 기준 35개 이상 리전에 레플리카가 배포되어 있습니다 (Turso 공식 문서 참조, 리전 수는 시점에 따라 달라질 수 있음).
읽기와 쓰기의 비대칭 구조
두 모드 모두 공통점이 있습니다. 쓰기는 항상 단일 원격 프라이머리로 라우팅됩니다. 프라이머리와의 거리에 따라 20–100ms 지연이 발생합니다.
아래는 HTTP 클라이언트 모드(Cloudflare Workers)에서의 요청 흐름입니다.
이 비대칭 구조가 Turso의 핵심 전제입니다. 읽기가 쓰기보다 압도적으로 많은 워크로드에서 글로벌 사용자 경험을 극적으로 개선할 수 있는 반면, 초당 수천 건 쓰기가 필요한 워크로드에는 적합하지 않습니다.
Drizzle ORM의 역할
Turso 자체적으로는 스키마 마이그레이션 시스템이 없습니다. 대부분의 팀이 Drizzle ORM을 채택한 이유입니다. Drizzle은 @libsql/client를 드라이버로 받아 Turso와 직접 연결되고, 세 가지를 담당합니다.
- 타입 안전 스키마 정의: TypeScript 타입이 SQL 스키마와 1:1로 대응
- 쿼리 빌더: Raw SQL 없이 체이닝 API로 복잡한 쿼리 구성
- 마이그레이션 관리:
drizzle-kit으로 SQL 마이그레이션 파일 생성 및 Turso에 직접 push
2025년 초 Drizzle ORM v1.0 베타가 릴리스되면서 마이그레이션 툴링이 안정화됐고, Prisma의 현실적인 대안으로 자리매김했습니다 (최신 릴리스 상태는 공식 changelog 확인 권장).
실전 적용
1. Cloudflare Workers 환경 — HTTP 클라이언트 패턴
먼저 의존성을 설치합니다.
npm install drizzle-orm @libsql/client hono
npm install -D drizzle-kit wrangler스키마를 정의합니다.
// src/db/schema.ts
import { text, integer, sqliteTable } from 'drizzle-orm/sqlite-core';
import { sql } from 'drizzle-orm';
export const products = sqliteTable('products', {
id: integer('id').primaryKey({ autoIncrement: true }),
name: text('name').notNull(),
price: integer('price').notNull(),
stock: integer('stock').notNull().default(0),
createdAt: text('created_at').default(sql`(datetime('now'))`),
});
export type Product = typeof products.$inferSelect;
export type NewProduct = typeof products.$inferInsert;Cloudflare Workers에서는 env 바인딩으로 환경 변수를 받습니다. process.env가 아닌 점이 처음엔 낯설 수 있습니다.
// src/db/client.ts
import { drizzle } from 'drizzle-orm/libsql';
import { createClient } from '@libsql/client/http';
import * as schema from './schema';
export function createDB(env: { TURSO_URL: string; TURSO_AUTH_TOKEN: string }) {
const client = createClient({
url: env.TURSO_URL,
authToken: env.TURSO_AUTH_TOKEN,
});
return drizzle(client, { schema });
}Hono.js와 함께 Workers 핸들러를 구성하면 이렇게 됩니다.
// src/index.ts
import { Hono } from 'hono';
import { eq } from 'drizzle-orm';
import { createDB } from './db/client';
import { products } from './db/schema';
type Env = {
TURSO_URL: string;
TURSO_AUTH_TOKEN: string;
};
const app = new Hono<{ Bindings: Env }>();
app.get('/products/:id', async (c) => {
const db = createDB(c.env);
const id = Number(c.req.param('id'));
const product = await db
.select()
.from(products)
.where(eq(products.id, id))
.get();
if (!product) return c.json({ error: 'Not found' }, 404);
return c.json(product);
});
app.post('/products', async (c) => {
const db = createDB(c.env);
const body = await c.req.json<{ name: string; price: number }>();
const result = await db.insert(products).values(body).returning();
return c.json(result[0], 201);
});
export default app;wrangler.toml에는 공개 URL만 넣고, 토큰은 별도로 등록합니다.
# wrangler.toml
name = "my-edge-api"
main = "src/index.ts"
compatibility_date = "2024-01-01"
[vars]
TURSO_URL = "libsql://your-db-your-org.turso.io"
# 토큰은 아래 명령으로 시크릿으로 등록
# wrangler secret put TURSO_AUTH_TOKEN2. 마이그레이션 — drizzle-kit으로 Turso에 직접 push
아래 설정은 drizzle-kit 0.30.x 기준입니다. 버전에 따라 설정 필드 구조가 달라질 수 있으니, 공식 changelog에서 현재 버전의 형식을 확인하고 npm install -D drizzle-kit@버전으로 고정해서 사용하는 것을 권장합니다.
// drizzle.config.ts — drizzle-kit 0.30.x 기준
import type { Config } from 'drizzle-kit';
export default {
schema: './src/db/schema.ts',
out: './migrations',
dialect: 'sqlite',
driver: 'turso',
dbCredentials: {
url: process.env.TURSO_URL!,
authToken: process.env.TURSO_AUTH_TOKEN!,
},
} satisfies Config;개발 환경에서 스키마 변경 사항을 바로 반영하고 싶다면 push를, 프로덕션에서는 마이그레이션 파일을 생성해서 관리할 수 있습니다.
# 개발 중 빠른 스키마 동기화
npx drizzle-kit push
# 프로덕션용 마이그레이션 파일 생성 후 적용
npx drizzle-kit generate
npx drizzle-kit migrate3. Node.js / Bun 환경 — 임베디드 레플리카 패턴
파일시스템이 있는 환경에서는 진짜 임베디드 레플리카를 쓸 수 있습니다. syncUrl과 authToken을 함께 설정하면 됩니다.
// Node.js / Bun 전용 — Cloudflare Workers에서는 동작하지 않습니다
import { drizzle } from 'drizzle-orm/libsql';
import { createClient } from '@libsql/client';
import * as schema from './db/schema';
import { eq } from 'drizzle-orm';
const client = createClient({
url: 'file:./local-replica.db',
syncUrl: process.env.TURSO_URL,
authToken: process.env.TURSO_AUTH_TOKEN,
// 60초마다 자동 동기화 — 최대 60초 동안 stale 데이터를 읽을 수 있음.
// 재고 수량·사용자 상태처럼 정합성이 중요한 경우 낮은 값을 사용하거나
// 쓰기 직후 명시적 sync()를 호출하는 패턴으로 보완할 것.
syncInterval: 60,
});
// 앱 시작 시 한 번 동기화해서 로컬 파일을 최신 상태로 맞춤
await client.sync();
const db = drizzle(client, { schema });
// 이후 읽기는 로컬 SQLite 파일에서 처리 → 네트워크 없음 → 평균 624μs
const allProducts = await db.select().from(schema.products).all();중요한 읽기 전에 최신 상태를 보장하고 싶다면 이런 패턴을 쓸 수 있습니다.
async function getProductFresh(id: number) {
await client.sync();
return db
.select()
.from(schema.products)
.where(eq(schema.products.id, id))
.get();
}sync()를 너무 자주 호출하면 임베디드 레플리카의 이점이 희석됩니다. 대부분의 경우 syncInterval로 주기적 동기화를 설정하고, 쓰기 직후에만 명시적으로 호출하는 전략이 균형이 좋습니다.
한 가지 더 짚고 넘어갈 게 있습니다. 읽기-자신의-쓰기 보장은 동일 클라이언트 연결 안에서만 유효합니다. 내 연결에서 발행한 쓰기는 즉시 읽을 수 있지만, 다른 클라이언트나 다른 서버 인스턴스가 쓴 내용은 syncInterval 주기나 명시적 sync() 호출 전까지 로컬 파일에 반영되지 않습니다. 멀티 인스턴스 환경에서 이 경계를 인식하지 못하면 데이터 정합성 문제가 발생할 수 있습니다.
4. 멀티테넌트 — database-per-tenant 패턴
2025년 Turso의 "Database Freedom Day" 이후 Developer 플랜에서 무제한 데이터베이스를 사용할 수 있게 됐습니다 (가격 정책은 변경될 수 있으므로 공식 pricing 페이지에서 최신 정보를 확인하세요). 덕분에 각 테넌트마다 독립된 SQLite 데이터베이스를 할당하는 패턴이 비용 측면에서 현실적인 선택이 됐습니다.
Turso 데이터베이스 URL의 실제 형식은 libsql://[db-name]-[org-slug].turso.io입니다. 조직 슬러그를 환경 변수로 분리해서 관리하지 않으면 연결 오류가 발생합니다.
type TenantEnv = {
TURSO_AUTH_TOKEN: string;
TURSO_ORG_SLUG: string; // 예: "my-company"
};
function getTenantDB(tenantId: string, env: TenantEnv) {
const client = createClient({
url: `libsql://${tenantId}-${env.TURSO_ORG_SLUG}.turso.io`,
authToken: env.TURSO_AUTH_TOKEN,
});
return drizzle(client, { schema });
}
app.get('/api/projects', async (c) => {
const tenantId = c.req.header('x-tenant-id');
if (!tenantId) return c.json({ error: 'Missing tenant' }, 400);
const db = getTenantDB(tenantId, c.env);
const projectList = await db.select().from(schema.projects).all();
return c.json(projectList);
});Row-Level Security 없이 데이터 격리를 달성하고, 고객 요청 시 SQLite 파일 자체를 내보낼 수 있다는 것이 이 패턴의 실용적 장점입니다.
장단점 분석
런타임별 성능 비교
| 항목 | Workers + HTTP 클라이언트 | Node/Bun + 임베디드 레플리카 |
|---|---|---|
| 읽기 지연 | 10ms 미만 (엣지 라우팅) | 평균 624μs (로컬 파일) |
| 쓰기 지연 | 20–100ms (프라이머리) | 20–100ms (프라이머리) |
| 파일시스템 필요 | 없음 | 있음 |
| 오프라인 읽기 | 불가 | 가능 (로컬 파일) |
| 동기화 관리 | 없음 (자동 라우팅) | sync() 타이밍 설계 필요 |
| 복잡도 | 낮음 | 중간 |
스택 전체 장점
| 항목 | 세부 내용 |
|---|---|
| 글로벌 저지연 읽기 | 35개 이상 엣지 리전 자동 복제, 가장 가까운 레플리카로 라우팅 (2025년 기준) |
| SQLite 친숙성 | 기존 SQLite 문법·툴링 그대로 사용 가능 |
| 콜드 스타트 해소 | 2025년 이전에는 비활성 DB 접근 시 수백 ms의 웜업 지연이 있었으나, Turso 인프라 업데이트로 즉각 응답으로 개선됨 |
| 무제한 데이터베이스 | Developer 플랜에서 멀티테넌트 패턴 현실화 (최신 가격은 공식 페이지 확인) |
| 타입 안전 쿼리 | Drizzle TypeScript 타입 추론으로 런타임 오류 감소 |
| 데이터 이식성 | SQLite 파일 단위 내보내기 가능 |
| 읽기-자신의-쓰기 보장 | 동일 연결에서 자신이 발행한 쓰기는 즉시 읽기 가능 |
한계와 적합하지 않은 케이스
| 항목 | 세부 내용 |
|---|---|
| 쓰기 집약적 워크로드 | 초당 수천 건 쓰기 → PostgreSQL이 적합 |
| 글로벌 강 일관성 | 단일 프라이머리 아키텍처, 멀티 리전 동시 쓰기 불가 |
| 대용량 조인 쿼리 | 수백 GB 규모에서 PostgreSQL 대비 성능 한계 |
| 빈번한 넓은 테이블 업데이트 | MVCC가 실험적 단계, 메모리 사용 급증 가능성 |
| Workers에서 서브밀리초 읽기 | 임베디드 레플리카 불가, Node/Bun 런타임으로 이동해야 함 |
실무에서 흔히 마주치는 실수
1. Workers에서 임베디드 레플리카 설정을 시도하는 경우
syncUrl을 설정해도 Cloudflare Workers 환경에서는 파일시스템이 없어 동작하지 않습니다. @libsql/client/http를 사용하거나 @tursodatabase/serverless 패키지로 전환하면 됩니다.
2. process.env로 환경 변수를 읽으려는 경우
Workers에서는 c.env.TURSO_AUTH_TOKEN 형태로 바인딩에서 읽어야 합니다. wrangler secret put으로 시크릿을 등록하고 타입 바인딩을 정의해두면 타입 안전하게 접근할 수 있습니다.
3. sync()를 앱 시작 시 한 번도 호출하지 않는 경우
임베디드 레플리카 환경에서 앱이 처음 시작될 때 로컬 파일이 오래된 상태일 수 있습니다. 초기화 시 await client.sync()를 한 번 호출하고, 이후는 syncInterval로 주기적으로 맞추는 패턴을 권장합니다.
4. 쓰기 집약적 기능에 Turso를 선택하는 경우
실시간 채팅, 고빈도 로그 수집처럼 쓰기가 매우 빈번한 기능은 단일 프라이머리 병목이 발생합니다. 기능별로 스토어를 분리하는 혼합 아키텍처를 고려해볼 수 있습니다.
어떤 워크로드에 맞는 선택인가
읽기 비중이 70% 이상이고 사용자가 3개 대륙 이상에 분산된 워크로드라면 Turso + Drizzle 조합이 자연스럽게 맞습니다. 쓰기 집약적이거나 글로벌 강 일관성이 필요한 워크로드라면 처음부터 PostgreSQL을 검토하는 게 낫습니다.
마치며
Cloudflare Workers에서 10ms 미만 글로벌 읽기를 달성하는 건 임베디드 레플리카가 아니라 HTTP 클라이언트 + Turso의 엣지 라우팅 덕분입니다. 임베디드 레플리카가 주는 로컬 읽기는 Node.js, Bun처럼 영구 파일시스템이 있는 런타임에서만 가능합니다. 이 둘을 구분하면 아키텍처 선택이 훨씬 명확해집니다.
읽기 비중이 높고 사용자가 여러 리전에 분산된 워크로드 — 이커머스 카탈로그, SaaS 대시보드, 콘텐츠 플랫폼 — 에서 이 스택은 PostgreSQL 대비 더 단순하고 운영 비용이 낮은 선택지가 됩니다. 반대로 쓰기 집약적이거나 글로벌 강 일관성이 필요하다면 분산 PostgreSQL을 먼저 검토하는 게 맞습니다.
직접 확인해보고 싶다면 아래 순서로 시작할 수 있습니다.
1단계 — Turso CLI로 글로벌 복제 구성 경험하기
Turso CLI 설치 후 5분 안에 도쿄 레플리카를 포함한 글로벌 복제 구성을 경험할 수 있습니다.
# 데이터베이스 생성 + 도쿄 레플리카 추가
turso db create my-app
turso db replicate my-app --location nrt
# 연결 URL과 토큰 확인
turso db show my-app
turso db tokens create my-app2단계 — Drizzle ORM으로 스키마 정의 + 마이그레이션
Drizzle 공식 Turso 연동 튜토리얼을 따라 스키마를 정의하고 drizzle-kit push로 마이그레이션을 적용해보면 타입 안전한 쿼리가 얼마나 편한지 바로 체감됩니다.
3단계 — Hono.js + Cloudflare Workers로 배포 후 응답 시간 직접 측정
Cloudflare Workers × Turso 연동 튜토리얼을 참고해서 엔드포인트를 하나 배포하고, 전 세계 다른 리전에서 응답 시간을 측정해보면 됩니다.
curl -o /dev/null -s -w "total: %{time_total}s\n" \
https://your-worker.your-domain.workers.dev/products/1이 수치가 기존 단일 리전 PostgreSQL과 어떻게 다른지 직접 보는 것이 가장 빠른 설득입니다.
libSQL과 Turso, Drizzle ORM이 함께 만들어낸 생태계는 분산 환경에서도 SQLite의 단순함을 유지하면서 글로벌 읽기 성능을 실용적인 수준으로 끌어올렸습니다. 이 조합으로 전환하는 스타트업과 중견 SaaS 팀이 늘어나는 건, 특정 워크로드에서 PostgreSQL보다 운영이 단순하고 멀티테넌트 확장 비용이 낮기 때문입니다.
참고 자료
- Turso 공식 문서 — Embedded Replicas 소개
- Drizzle ORM 공식 — Turso 연결 가이드
- Drizzle ORM 공식 튜토리얼 — Drizzle with Turso
- Cloudflare Workers 공식 — Turso 연동 튜토리얼
- Cloudflare Workers 공식 — Turso 서드파티 통합
- Turso 공식 블로그 — Embedded Replicas 도입 발표
- Turso 공식 블로그 — Remix + Turso + Drizzle 이커머스 Cloudflare Workers 배포
- Turso 공식 블로그 — Drizzle이 Turso를 활용하는 방법
- Turso 공식 — Database Per Tenant 아키텍처
- Better Stack — Turso의 단일 Writer 병목 해소 방법
- LibSQL 복제 아키텍처 심층 분석
- Turso + Drizzle 프로덕션 조합 실사용기
- SQLite 엣지 프로덕션 준비 현황 2026
- Distributed SQLite — LibSQL·Turso가 2026년 표준이 된 이유
- GitHub — tursodatabase/embedded-replica-examples