Turso libSQL 임베디드 레플리카로 서버리스 함수의 P99 읽기 레이턴시를 10ms 아래로 끌어내리는 원리
서버리스 DB 레이턴시 문제를 처음 마주했을 때, Redis TTL을 줄이고, 커넥션 풀을 손보고, CDN에서 JSON을 캐싱했습니다. 그런데 문제의 근원은 "DB까지 가는 왕복 자체"였고, 캐시는 그 왕복을 가릴 뿐이었습니다. P99 레이턴시가 들쑥날쑥한 이유를 파고들면, 요청마다 30~150ms RTT가 발생하는 원격 DB 왕복이 분포의 꼬리를 잡아당기고 있다는 사실에 닿습니다.
먼저 용어 정리가 필요합니다. "엣지 함수"는 플랫폼마다 다르게 씁니다. Cloudflare Workers처럼 V8 Isolate 기반으로 파일시스템 없이 동작하는 런타임도 엣지 함수고, Vercel Functions(Node.js 런타임), AWS Lambda, Fly.io VM도 엣지 함수라 불립니다. 이 글에서 다루는 패턴은 파일시스템이 있는 Node.js 계열 런타임에 한합니다. Cloudflare Workers나 Vercel Edge Runtime에서는 동작하지 않습니다. 이 구분이 글 전체의 전제입니다.
Turso의 **임베디드 레플리카(Embedded Replica)**는 원격 Turso 데이터베이스를 로컬 SQLite 파일로 복제하고, 읽기는 네트워크 없이 로컬 디스크에서만 처리하는 방식입니다. 쓰기만 원격 primary로 전달하고, 변경 사항은 WAL 프레임 단위로 백그라운드 동기화됩니다. 단순 캐시가 아니라 완전한 SQLite 파일이기 때문에 복잡한 집계 쿼리도 그대로 로컬에서 실행됩니다.
핵심 개념
libSQL이란
libSQL은 SQLite의 오픈소스 포크(MIT 라이선스)입니다. Turso가 SQLite의 분산 기능 부재를 해결하기 위해 만들었고, 기존 SQLite와 완전히 호환됩니다. 스키마, 쿼리, 인덱스 — 전부 그대로 씁니다. 여기에 원격 복제, WAL 기반 동기화, HTTP/WebSocket 접근이 추가됩니다.
레이턴시 모델: 읽기와 쓰기는 완전히 다르다
임베디드 레플리카에서 읽기와 쓰기의 레이턴시 특성은 완전히 다릅니다. 이걸 혼동하면 잘못된 기대를 하게 됩니다.
| 동작 | 경로 | 레이턴시 |
|---|---|---|
| 읽기 | 로컬 디스크 I/O만 | 평균 624μs, P99 1ms 미만 (Turso 공식 벤치마크) |
| 쓰기 | 원격 primary 왕복 필수 | 수십~수백 ms (primary 위치에 따라 다름) |
| 동기화 | WAL 프레임 단위 증분 | 백그라운드, 전체 파일 재전송 없음 |
쓰기 레이턴시는 전혀 개선되지 않습니다. 이 기술이 유효한 건 읽기 트래픽이 전체 DB 병목인 워크로드입니다. 마케팅 사이트 제품 카탈로그, 대시보드 집계, A/B 테스트 플래그 서빙, 리더보드처럼 읽기가 압도적인 케이스에서 효과가 큽니다.
그렇다면 제목의 "10ms 이하"는 어디서 나오는가. 624μs는 DB 쿼리 자체의 레이턴시이고, 서버리스 함수 end-to-end P99는 런타임 초기화·네트워크 스택 오버헤드가 더해집니다. DB 왕복(30~150ms)을 제거하고 나면 end-to-end P99가 10ms 수준으로 내려오는 것이 이 패턴의 목표치입니다.
WAL 기반 동기화와 Read-Your-Writes 일관성
WAL(Write-Ahead Log) 기반 동기화는 변경분만 스트리밍합니다. 10줄이 바뀌었으면 그 10줄에 해당하는 WAL 프레임만, GB짜리 파일 전체를 재전송하지 않습니다.
Read-your-writes 일관성은 sync() 호출을 통해 보장됩니다. 쓰기가 primary에 반영된 후 sync()를 호출하면 그 WAL 프레임이 로컬로 내려오고, 이후 로컬 읽기에서 자신의 쓰기를 확인할 수 있습니다. 자동 동기화(syncInterval)는 백그라운드에서 주기적으로 실행되므로, 쓰기 직후 즉시 로컬에서 읽으려면 명시적 sync() 호출이 필요합니다.
다중 인스턴스 배포의 설계 제약: Vercel 서버리스나 Fly.io 다중 VM 환경에서는 인스턴스마다 로컬 SQLite 파일이 따로 존재합니다. 인스턴스 A에서 쓴 데이터가 인스턴스 B에 반영되는 시점은 B의 다음 sync() 주기입니다. 사용자의 쓰기 요청을 처리한 인스턴스와 다른 인스턴스로 읽기 요청이 라우팅되면, syncInterval 내에서 이전 데이터를 볼 수 있습니다. 예를 들어 게임 점수를 업데이트한 직후 리더보드를 새로고침했을 때 내 점수가 아직 반영되지 않은 현상이 이 경우입니다. 이건 단서가 아니라 아키텍처 수준에서 수용해야 하는 트레이드오프입니다.
런타임 호환성 — 먼저 확인해야 할 것
| 런타임 | 임베디드 레플리카 지원 | 비고 |
|---|---|---|
| Node.js | ✅ | 완전 지원 |
| Bun | ✅ | 완전 지원 |
| Deno | ✅ | --allow-write 플래그 필요 |
| Cloudflare Workers | ❌ | 파일시스템 없음 |
| Vercel Edge Runtime | ❌ | 파일시스템 없음 |
| Fly.io / Railway VM | ✅ | 퍼시스턴트 볼륨과 함께 사용 |
Vercel을 쓴다면 Edge Runtime이 아닌 Node.js 서버 함수로 배포해야 합니다. Next.js라면 runtime: 'edge' 설정을 제거하고 기본 Node.js로 두면 됩니다.
실전 적용
기본 셋업
npm install @libsql/clientimport { createClient } from "@libsql/client";
const client = createClient({
url: "file:./local.db", // 로컬 SQLite 파일 경로
syncUrl: process.env.TURSO_DATABASE_URL,
authToken: process.env.TURSO_AUTH_TOKEN,
syncInterval: 60, // 단위: 초(seconds). 60 = 60초 간격
});
// 앱 시작 시 최신 상태 보장
await client.sync();
// 이후 읽기는 100% 로컬 디스크
const result = await client.execute(
"SELECT * FROM products WHERE id = ?",
[productId]
);syncInterval의 단위는 **초(seconds)**입니다. 60이면 60초, 5면 5초 간격으로 동기화됩니다. ms 단위로 착각해서 syncInterval: 5000을 넣으면 약 83분에 한 번 동기화됩니다. 실수하기 쉬운 지점이라 뒤에서 다시 짚겠습니다.
시나리오 1: Next.js API Route (Node.js 런타임)
// app/api/products/route.js
// runtime: 'edge' 설정이 없어야 합니다 — Node.js에서 실행됩니다
import { createClient } from "@libsql/client";
let client;
async function getClient() {
if (!client) {
client = createClient({
url: "file:/tmp/local.db", // Vercel 서버리스 함수의 임시 스토리지
syncUrl: process.env.TURSO_DATABASE_URL,
authToken: process.env.TURSO_AUTH_TOKEN,
syncInterval: 30,
});
// 클라이언트 생성 시 한 번 동기화해 최신 데이터 확보
await client.sync();
}
return client;
}
export async function GET(request) {
const db = await getClient();
const { searchParams } = new URL(request.url);
const category = searchParams.get("category");
const result = await db.execute(
"SELECT id, name, price FROM products WHERE category = ? ORDER BY name",
[category]
);
return Response.json(result.rows);
}Vercel 서버리스 컨테이너가 재시작되면
/tmp/local.db가 초기화됩니다.getClient()안에서sync()를 호출하므로 재시작 후 첫 요청 시 자동으로 재동기화됩니다. 모듈 레벨에서 클라이언트를 한 번만 생성하는 패턴은 컨테이너 재사용 시 불필요한 동기화를 방지합니다.
시나리오 2: Fly.io VM + 퍼시스턴트 볼륨 (가장 안정적인 패턴)
Fly.io에서 퍼시스턴트 볼륨을 마운트하면 재배포 후에도 로컬 DB 파일이 유지됩니다. Cold start 시 전체 동기화 비용을 아낄 수 있는 구조입니다.
# fly.toml
[mounts]
source = "db_volume"
destination = "/data"import { createClient } from "@libsql/client";
const client = createClient({
url: "file:/data/local.db", // 퍼시스턴트 볼륨 경로
syncUrl: process.env.TURSO_DATABASE_URL,
authToken: process.env.TURSO_AUTH_TOKEN,
syncInterval: 10, // 단위: 초
});
await client.sync();
console.log("로컬 레플리카 동기화 완료, 읽기 준비");시나리오 3: 대시보드 집계 API
복잡한 집계 쿼리도 완전히 로컬에서 실행됩니다. 네트워크 레이턴시가 없으니 조인이나 서브쿼리에서 특히 이점이 큽니다. 사용자 보고 기준으로 Cloudflare D1 대비 P99 45ms에서 8ms로 개선된 사례가 있습니다.
const stats = await client.execute(`
SELECT
date(created_at) as day,
COUNT(*) as order_count,
SUM(amount) as revenue,
AVG(amount) as avg_order
FROM orders
WHERE created_at >= date('now', '-30 days')
GROUP BY date(created_at)
ORDER BY day DESC
`);시나리오 4: 리더보드 (신선도 vs 레이턴시 트레이드오프)
초 단위 갱신이 필수가 아닌 리더보드라면 주기적 동기화와 로컬 읽기를 조합할 수 있습니다.
const client = createClient({
url: "file:./leaderboard.db",
syncUrl: process.env.TURSO_DATABASE_URL,
authToken: process.env.TURSO_AUTH_TOKEN,
syncInterval: 5, // 단위: 초
});
// GET /api/leaderboard — 로컬 읽기, 네트워크 없음
// SQLite 3.25+ / libSQL 지원: rank() OVER
const leaderboard = await client.execute(`
SELECT username, score, rank() OVER (ORDER BY score DESC) as rank
FROM scores
ORDER BY score DESC
LIMIT 100
`);
// POST /api/score — 원격 primary로 전송
await client.execute(
"INSERT OR REPLACE INTO scores (user_id, score) VALUES (?, ?)",
[userId, newScore]
);이 패턴에서 다중 인스턴스 불일치가 실제로 드러납니다. 점수를 업데이트한 직후 다른 인스턴스로 리더보드를 조회하면 최대 syncInterval(여기서는 5초) 동안 이전 순위가 보입니다. 서비스 요건상 이 수준의 지연이 허용되는지 먼저 확인하세요.
새 Turso Sync 프로토콜 (@tursodatabase/sync)
기존 물리적 페이지 레벨 복제에서 논리적 CDC(변경 데이터 캡처) 방식으로 전환이 진행 중입니다. Turso 공식 벤치마크(2025) 기준 기존 대비 7.3배 빠른 동기화 속도이며(벤더 자체 측정, 독립 검증 수치 아님), push()/pull() API로 오프라인-퍼스트 패턴도 지원됩니다.
import { createClient } from "@tursodatabase/sync";
const client = await createClient({
url: "file:./local.db",
syncUrl: process.env.TURSO_DATABASE_URL,
authToken: process.env.TURSO_AUTH_TOKEN,
});
// 새 API: 명시적 pull/push
await client.pull(); // 원격 변경사항 가져오기
await client.push(); // 로컬 변경사항 업로드현재 전환 과도기이므로 프로덕션 도입 전 안정성을 확인하는 게 좋습니다.
장단점 분석
임베디드 레플리카의 장점
| 항목 | 내용 |
|---|---|
| 읽기 레이턴시 제거 | 평균 624μs, P99 1ms 미만(Turso 공식 벤치마크). 네트워크 왕복 0 |
| SQLite 완전 호환 | 기존 쿼리·스키마 그대로, 마이그레이션 비용 최소화 |
| 증분 동기화 | WAL 프레임 단위 델타만 전송, 대역폭 효율적 |
| Read-your-writes | sync() 호출 후 자신의 쓰기를 로컬에서 읽기 가능 |
| 캐시 레이어 불필요 | Redis 없이 DB 레벨에서 로컬 읽기 구현 |
제약과 주의사항
| 항목 | 내용 |
|---|---|
| 파일시스템 필수 | CF Workers, Vercel Edge Runtime 사용 불가 |
| 쓰기 레이턴시 개선 없음 | 모든 쓰기는 원격 primary 왕복 |
| Cold start 비용 | 초기 전체 파일 다운로드. 수 GB 이상이면 부담 |
| 레플리카 간 최종 일관성 | 다음 sync 전까지 다른 인스턴스는 구버전 읽기 가능 |
| 단일 primary 병목 | 다중 리전 쓰기 강일관성 불가 |
경쟁 기술과의 비교
아래 수치는 Turso 공식 비교 자료 기준이며, 리전·데이터 크기·쿼리 복잡도에 따라 달라집니다. 독립 벤치마크와 차이가 있을 수 있습니다.
| 기술 | 읽기 레이턴시 | 파일시스템 필요 | 쓰기 강일관성 | 비고 |
|---|---|---|---|---|
| Turso 임베디드 레플리카 | 평균 624μs, P99 <1ms | ✅ 필요 | ❌ | 읽기 특화 |
| Cloudflare D1 | P99 8~15ms 수준 | ❌ 불필요 | ❌ | Workers 네이티브 |
| Fly.io LiteFS | ✅ 필요 | ❌ | Fly 플랫폼 의존 | |
| PlanetScale / Neon | P99 20~100ms | ❌ 불필요 | ✅ | 범용 서버리스 DB |
| Redis (Upstash) | <1ms | ❌ 불필요 | ✅ (단순 KV) | SQL 불가 |
실무에서 흔한 실수들
1. syncInterval 단위 혼동
가장 많이 보는 실수입니다.
// 잘못된 예 — 5000초(약 83분)마다 동기화
syncInterval: 5000
// 올바른 예 — 5초마다 동기화
syncInterval: 52. 동기화 중 로컬 DB 직접 접근
동기화가 진행 중일 때 다른 프로세스가 로컬 SQLite 파일을 직접 열면 데이터 손상이 발생할 수 있습니다. 항상 @libsql/client를 통해서만 접근하세요.
3. 쓰기 집약적 워크로드에 잘못 적용
실시간 채팅, 결제 트랜잭션, 재고 차감처럼 쓰기가 많고 즉각적 일관성이 필요한 케이스에는 맞지 않습니다. 읽기 트래픽이 DB 전체 병목인 워크로드에 적합합니다.
4. 대용량 DB로 초기 셋업 시도
수 GB 이상 DB에서 처음 설정하면 cold start에 수십 초수 분이 걸릴 수 있습니다. 실용적인 상한은 수백 MB1GB 수준입니다.
5. Vercel Edge Runtime에서 사용 시도
미들웨어나 runtime: 'edge' API Route에서 libSQL 클라이언트를 임포트하면 빌드 에러가 납니다. Node.js 런타임의 API Route나 Server Actions에서 사용하세요.
어느 상황에서 도입할지 판단하는 흐름
마치며
임베디드 레플리카는 네트워크 왕복을 디스크 I/O로 대체해 DB 읽기 레이턴시를 평균 624μs, P99 1ms 수준으로 낮추는 기술입니다. 복잡한 캐시 레이어 없이 DB 레벨에서 이걸 해결한다는 게 핵심 가치입니다.
도입 전 확인할 세 가지 제약이 있습니다.
파일시스템이 있는 런타임(Node.js, Bun, Fly.io VM)에서만 동작합니다. Cloudflare Workers나 Vercel Edge Runtime을 쓴다면 이 기술이 아닌 다른 접근이 필요합니다.
쓰기 레이턴시는 개선되지 않습니다. 읽기 트래픽이 DB 병목인 워크로드에서만 실질적 효과가 있습니다.
레플리카 간 최종 일관성 모델을 수용해야 합니다. 다중 인스턴스 환경에서 syncInterval 이내 불일치가 서비스 요건에 허용되는지 먼저 확인하세요.
이 세 가지가 괜찮다면 시작은 간단합니다.
1단계 — 런타임 확인: 현재 배포 환경이 Node.js, Bun, 또는 파일시스템이 있는 VM인지 확인합니다. Cloudflare Workers나 Vercel Edge Runtime이라면 Cloudflare D1이나 Node.js 함수로의 전환을 먼저 검토합니다.
2단계 — 읽기 병목 확인: 현재 워크로드에서 읽기 트래픽이 DB 레이턴시의 주원인인지 확인합니다. 쓰기가 병목이라면 임베디드 레플리카가 아닌 다른 접근이 필요합니다.
3단계 — 단일 엔드포인트 파일럿: 전체 DB를 한 번에 마이그레이션하기보다, 읽기 집약적인 단일 API 엔드포인트에 먼저 적용해 P99 개선을 직접 확인해볼 수 있습니다.
참고 자료
- Embedded Replicas 공식 문서 — Turso
- Microsecond-level SQL query latency with libSQL local replicas — Turso 블로그
- Introducing Embedded Replicas: Deploy Turso anywhere — Glauber Costa (Medium)
- Turso Sync: a much, much, much better way to sync — 벤치마크
- Introducing Databases Anywhere with Turso Sync
- tursodatabase/embedded-replica-examples — GitHub
- Turso Sync Engine Architecture — DeepWiki
- Turso on the Vercel Marketplace: Edge SQLite vs the Serverless Connection Pool
- Use Embedded Replicas of LibSQL with Golang — Meet Gor
- Distributed SQLite: Why LibSQL and Turso are the New Standard in 2026 — DEV Community
- Turso vs Cloudflare D1 for Solo Developers — SoloDevStack
- Post-PostgreSQL: Is SQLite on the Edge Production Ready? — SitePoint