Turso와 libSQL: 엣지에서 쓰는 SQLite, 임베디드 복제, 멀티테넌트 DB, Workers·Next.js 연동
이 글의 핵심
중앙 DB 서버 하나에 네트워크로 붙는 구조는 엣지와 서버리스 환경에서 지연과 연결 수 문제를 드러냅니다. Turso는 SQLite 기반이라 읽기를 로컬로 끌어올 수 있지만 쓰기는 여전히 한 곳으로 모여 SQLITE_BUSY 에러가 날 수 있어, 이런 한계와 복제 파일 증가, Workers 타임아웃 대응까지 함께 봅니다.
Turso / libSQL이 해결하는 문제
전통적 DB 설계는 “중앙 서버 1개, 클라이언트가 네트워크로 연결” 입니다. 엣지 런타임·서버리스 시대에는 다음 한계가 드러났습니다.
- Cold start의 DB 연결 지연: TCP handshake + TLS + 인증
- 지리적 거리: DB가 미국 동부, 사용자가 서울 → 왕복 200ms
- Serverless에서 persistent connection 어려움: 풀 관리가 까다롭고 비쌈
- 멀티테넌트 복잡성: 테넌트 격리를 위한 schema 분리·RLS 오버헤드
Turso는 이를 이렇게 풉니다:
- HTTP 프로토콜: 상주 TCP 연결 없이 fetch로 쿼리를 보내므로 서버리스 함수의 연결 풀 문제가 없음
- 리전 선택: DB를 주 사용자와 가까운 리전에 배치 (제공 리전과 복제 옵션은 시점·요금제에 따라 다르므로
turso db locations로 확인) - 임베디드 복제: 애플리케이션이 로컬 SQLite 파일을 직접 읽어 네트워크 왕복이 없음
- 테넌트당 DB: SQLite 파일 하나가 DB 하나라 생성 비용이 작아, 테넌트마다 DB를 따로 두는 구조가 현실적
핵심 아이디어는 “SQLite는 원래 라이브러리이지 서버가 아니다”라는 점을 거꾸로 활용한 것입니다. SQLite 파일은 복사하기 쉽고, 읽기는 파일을 여는 쪽 프로세스 안에서 끝납니다. 이 성질 덕분에 읽기를 애플리케이션 옆으로 끌어올 수 있는 것이고, 반대로 SQLite가 한 번에 하나의 writer만 허용한다는 성질은 그대로 남습니다. Turso를 평가할 때는 이 두 성질이 내 워크로드에 각각 얼마나 중요한지를 보면 됩니다.
설치·프로젝트 생성
# CLI
curl -sSfL https://get.tur.so/install.sh | bash
turso auth signup
# 첫 DB
turso db create my-app --location nrt # 도쿄
turso db show my-app
turso db tokens create my-app
SDK (Node/Bun/Deno/Workers 공용):
pnpm add @libsql/client
기본 사용
import { createClient } from "@libsql/client"
const db = createClient({
url: process.env.TURSO_URL!, // libsql://xxx.turso.io
authToken: process.env.TURSO_TOKEN!,
})
await db.execute(`
CREATE TABLE IF NOT EXISTS posts (
id INTEGER PRIMARY KEY,
title TEXT NOT NULL,
body TEXT,
created_at INTEGER DEFAULT (strftime('%s', 'now'))
)
`)
await db.execute({
sql: "INSERT INTO posts (title, body) VALUES (?, ?)",
args: ["Hello Turso", "First post"],
})
const { rows } = await db.execute("SELECT * FROM posts ORDER BY id DESC LIMIT 10")
console.log(rows)
SQLite이므로 ? placeholder·LIMIT·strftime 등 표준 문법 모두 그대로 쓸 수 있습니다.
args로 값을 넘기는 방식은 선택이 아니라 필수로 생각해야 합니다. 템플릿 문자열로 SQL에 값을 끼워 넣으면 SQL 인젝션이 생기는 것은 다른 DB와 같습니다. SQLite 특유의 주의점도 있습니다. SQLite는 동적 타입이라 INTEGER 컬럼에 문자열 "42"를 넣어도 에러가 나지 않고, 컬럼 선언은 “선호 타입(affinity)“일 뿐입니다. 엄격하게 막고 싶다면 CREATE TABLE ... STRICT로 만들어야 합니다. 또 반환되는 rows에서 SQLite INTEGER가 JavaScript number로 변환되므로 2^53을 넘는 ID는 정밀도를 잃을 수 있고, 이때는 클라이언트 옵션 intMode: "bigint"를 쓰면 됩니다.
트랜잭션
const tx = await db.transaction("write")
try {
await tx.execute("UPDATE posts SET title = ? WHERE id = ?", ["new", 1])
await tx.execute("INSERT INTO audit (action) VALUES (?)", ["update"])
await tx.commit()
} catch (err) {
await tx.rollback()
throw err
}
Batch
await db.batch([
"DELETE FROM posts WHERE id = 1",
{ sql: "INSERT INTO posts (title) VALUES (?)", args: ["B"] },
], "write")
Batch는 single round-trip으로 여러 문장을 보냅니다.
트랜잭션과 batch의 차이는 네트워크 왕복 횟수입니다. 원격 DB에서 대화형 트랜잭션(db.transaction())은 문장마다 왕복하면서 primary의 쓰기 잠금을 쥐고 있으므로, 그 사이 다른 클라이언트의 쓰기는 모두 기다립니다. 서울에서 도쿄 primary로 왕복 30ms라면 문장 다섯 개짜리 트랜잭션이 150ms 이상 잠금을 쥐는 셈입니다. 그래서 “앞 쿼리 결과를 보고 다음 쿼리를 정해야 하는” 경우에만 대화형 트랜잭션을 쓰고, 미리 정해진 여러 문장은 batch(..., "write")로 보내는 편이 좋습니다. batch도 하나의 트랜잭션으로 실행되어 중간 문장이 실패하면 전체가 롤백됩니다. 대화형 트랜잭션은 서버 쪽에 시간 제한이 있어 오래 열어 두면 만료되므로, 트랜잭션 안에서 외부 API 호출 같은 느린 작업을 하지 않아야 합니다.
임베디드 복제 — 지연 0ms 읽기
import { createClient } from "@libsql/client"
const db = createClient({
url: "file:local-replica.db", // 로컬 파일
syncUrl: process.env.TURSO_URL, // 원격 primary
authToken: process.env.TURSO_TOKEN,
syncInterval: 60, // 60초마다 자동 pull
})
// 즉시 쿼리 (로컬 파일에서 0ms)
const { rows } = await db.execute("SELECT * FROM posts LIMIT 10")
// 쓰기는 자동으로 원격으로 전송
await db.execute({
sql: "INSERT INTO posts (title) VALUES (?)",
args: ["From edge"],
})
// 수동 동기화 (최신 데이터 필요 시)
await db.sync()
Node/Bun의 장기 실행 프로세스에 잘 맞습니다. Next.js의 standalone 서버, long-running 워커, Astro SSR 등이 대표 활용처입니다.
임베디드 복제에서 가장 헷갈리는 것은 일관성입니다. 이 인스턴스가 직접 쓴 데이터는 쓰기 직후 바로 읽을 수 있도록(read-your-writes) 클라이언트가 처리해 주지만, 다른 인스턴스가 쓴 데이터는 다음 동기화 전까지 보이지 않습니다. syncInterval: 60이면 최대 1분 늦게 보일 수 있다는 뜻입니다. 서버 두 대 뒤에 로드밸런서가 있는 구조에서 “방금 저장했는데 새로고침하면 사라졌다가 다시 나타나는” 현상이 이것입니다. 재고 수량이나 결제 상태처럼 최신 값이 반드시 필요한 읽기는 sync()를 먼저 호출하거나 원격 클라이언트로 직접 읽어야 합니다.
로컬 파일 경로도 운영에서 문제가 됩니다. 컨테이너가 재시작되면 파일이 사라져 처음부터 전체를 다시 받으므로, 큰 DB라면 영구 볼륨에 두어야 시작 시간이 짧아집니다. 반대로 여러 프로세스가 같은 복제 파일을 동시에 열면 동기화가 꼬일 수 있어, 프로세스마다 별도 파일을 두는 것이 안전합니다.
멀티테넌트 아키텍처
Turso의 가장 독창적인 가치 제안입니다. 테넌트당 완전히 분리된 SQLite DB:
import { createClient as createAdmin } from "@tursodatabase/api"
const admin = createAdmin({
org: "my-org",
token: process.env.TURSO_API_TOKEN!,
})
// 회원가입 시 DB 생성
async function createTenant(tenantId: string) {
const name = `tenant-${tenantId}`
await admin.databases.create(name, {
group: "default",
schema: "tenant-template", // 템플릿 DB로부터 스키마 복제
})
const token = await admin.databases.createToken(name, {
expiration: "never",
authorization: "full-access",
})
// token을 테넌트 메타 테이블에 저장
return { dbName: name, token }
}
- 스토리지·권한·백업이 테넌트별 분리
- 한 테넌트의 쓰기 잠금이 다른 테넌트의 쓰기를 막지 않음 (SQLite의 단일 writer 제약이 테넌트 단위로 나뉨)
- 해지 시 DB 삭제로 해당 테넌트 데이터를 통째로 제거 가능
- 테넌트 수가 많아도 쓰지 않는 DB는 비용이 작음
테넌트별 DB의 대가는 스키마 변경입니다. 공유 DB라면 마이그레이션 한 번으로 끝나지만, 테넌트가 1000개면 마이그레이션도 1000번 실행해야 하고 중간에 실패한 테넌트만 옛 스키마로 남는 상황을 관리해야 합니다. 위 코드의 schema 옵션처럼 스키마 전용 DB를 부모로 두고 자식 DB에 변경을 전파하는 기능이 이 문제를 줄이기 위한 것입니다. 또 “모든 테넌트의 월별 매출 합계” 같은 테넌트 횡단 쿼리는 DB를 하나씩 돌며 모아야 하므로, 분석용 데이터는 별도 저장소로 내보내는 설계가 필요합니다.
토큰 관리도 주의해야 합니다. 예제의 expiration: "never" + full-access 토큰은 유출되면 그 테넌트 DB 전체가 노출되므로, 메타 테이블에 저장할 때 암호화하고 가능하면 만료 기간을 두고 주기적으로 재발급하는 편이 안전합니다.
Vector Search (libSQL Native)
libSQL은 SQLite에 F32/F16 벡터 컬럼과 ANN 인덱스를 네이티브로 추가했습니다.
CREATE TABLE documents (
id INTEGER PRIMARY KEY,
title TEXT,
embedding F32_BLOB(1024)
);
CREATE INDEX docs_idx ON documents(
libsql_vector_idx(embedding, 'metric=cosine')
);
-- 유사도 검색 (ANN 인덱스 사용: vector_top_k가 인덱스에서 가까운 rowid를 돌려줌)
SELECT d.id, d.title, vector_distance_cos(d.embedding, vector32(?)) AS score
FROM vector_top_k('docs_idx', vector32(?), 10) AS v
JOIN documents d ON d.rowid = v.id;
-- 인덱스 없이 정확한 전체 탐색 (행이 적을 때)
SELECT id, title FROM documents
ORDER BY vector_distance_cos(embedding, vector32(?))
LIMIT 10;
두 쿼리의 차이가 중요합니다. ORDER BY vector_distance_cos(...)는 모든 행과 거리를 계산하는 정확한 전체 탐색이라 행이 수만 개를 넘으면 느려집니다. vector_top_k는 libsql_vector_idx로 만든 근사 최근접 이웃(ANN) 인덱스를 사용하므로 빠르지만, 이름 그대로 근사치라 드물게 실제 최근접 결과를 놓칠 수 있습니다. pgvector의 <=> 같은 연산자를 쓰는 예제를 그대로 옮기면 libSQL에서는 문법 에러가 나므로 주의하세요.
import { createClient } from "@libsql/client"
const db = createClient({ ... })
async function addDoc(title: string, text: string) {
const embedding = await embed(text) // OpenAI/Workers AI 등
await db.execute({
sql: "INSERT INTO documents (title, embedding) VALUES (?, vector32(?))",
args: [title, JSON.stringify(embedding)],
})
}
async function search(query: string) {
const qvec = await embed(query)
const { rows } = await db.execute({
sql: `
SELECT d.id, d.title, vector_distance_cos(d.embedding, vector32(?)) AS score
FROM vector_top_k('docs_idx', vector32(?), 10) AS v
JOIN documents d ON d.rowid = v.id`,
args: [JSON.stringify(qvec), JSON.stringify(qvec)],
})
return rows
}
“DB 한 통에 관계형+벡터” 가 동시에 가능해 RAG 애플리케이션의 스택이 단순해집니다. 문서 메타데이터와 임베딩이 같은 트랜잭션 안에 있으므로, 별도 벡터 DB를 둘 때 생기는 “원본은 지웠는데 벡터는 남아 있는” 불일치가 없습니다. 다만 F32_BLOB(1024)의 1024는 임베딩 모델의 출력 차원과 정확히 같아야 합니다. 나중에 차원이 다른 모델로 바꾸면 컬럼과 인덱스를 새로 만들고 전체를 다시 임베딩해야 하므로, 모델 이름과 차원을 함께 기록해 두는 편이 좋습니다.
Cloudflare Workers 통합
import { Hono } from "hono"
import { createClient } from "@libsql/client/web"
type Bindings = { TURSO_URL: string; TURSO_TOKEN: string }
const app = new Hono<{ Bindings: Bindings }>()
app.get("/posts", async (c) => {
const db = createClient({
url: c.env.TURSO_URL,
authToken: c.env.TURSO_TOKEN,
})
const { rows } = await db.execute("SELECT * FROM posts ORDER BY id DESC LIMIT 20")
return c.json(rows)
})
export default app
Workers에서는 임베디드 복제가 불가능하므로 모든 쿼리가 DB 리전까지 왕복합니다. Workers는 사용자와 가까운 전 세계 데이터센터에서 실행되지만 DB는 한 리전에 있으므로, 유럽 사용자의 요청을 처리하는 Worker가 도쿄 DB에 쿼리하면 대륙 간 왕복이 그대로 붙습니다. 한 요청에서 쿼리를 여러 번 순차로 보내면 이 지연이 곱해지므로 batch로 묶는 것이 특히 중요합니다. 요청 핸들러 안에서 createClient를 매번 호출해도 HTTP 기반이라 연결 비용 문제는 크지 않지만, 쿼리 수를 줄이는 것이 체감 성능을 가장 크게 좌우합니다.
Next.js App Router
// lib/db.ts
import { drizzle } from "drizzle-orm/libsql"
import { createClient } from "@libsql/client"
const client = createClient({
url: process.env.TURSO_URL!,
authToken: process.env.TURSO_TOKEN!,
})
export const db = drizzle(client)
// app/posts/page.tsx
import { db } from "@/lib/db"
import { posts } from "@/db/schema"
import { desc } from "drizzle-orm"
export default async function Page() {
const rows = await db.select().from(posts).orderBy(desc(posts.id)).limit(20)
return <ul>{rows.map(p => <li key={p.id}>{p.title}</li>)}</ul>
}
Drizzle ORM의 libSQL 드라이버는 일급 지원이며, Prisma도 driver adapter(@prisma/adapter-libsql)로 연결할 수 있습니다. Next.js 개발 서버는 핫 리로드 때마다 모듈을 다시 평가하므로 클라이언트가 여러 번 만들어질 수 있지만, HTTP 기반 원격 클라이언트는 상주 연결이 없어 Postgres 드라이버처럼 연결 수가 폭증하는 문제는 거의 없습니다. 임베디드 복제 클라이언트를 쓴다면 이야기가 다르므로 globalThis에 캐시해 하나만 유지하는 편이 좋습니다.
Drizzle 스키마 & 마이그레이션
// db/schema.ts
import { sql } from "drizzle-orm"
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core"
export const posts = sqliteTable("posts", {
id: integer("id").primaryKey({ autoIncrement: true }),
title: text("title").notNull(),
body: text("body"),
createdAt: integer("created_at").default(sql`(strftime('%s', 'now'))`).notNull(),
})
pnpm drizzle-kit generate
pnpm drizzle-kit migrate
백업·복구
Turso는 point-in-time 복구를 지원합니다(보존 기간은 요금제에 따라 다름).
# 즉시 덤프
turso db shell my-app .dump > backup.sql
# 특정 시점 상태로 새 DB 생성 (원본은 그대로 두고 복제본으로 복구)
turso db create my-app-restore --from-db my-app --timestamp 2026-04-10T12:00:00Z
특정 시점 복구가 기존 DB를 덮어쓰지 않고 새 DB로 만들어진다는 점을 기억하세요. 복구본을 확인한 뒤 애플리케이션의 접속 URL을 바꾸거나 필요한 데이터만 옮기는 절차가 필요하므로, 장애 대응 문서에 이 순서를 미리 적어 두는 편이 좋습니다. .dump 결과는 일반 SQL 텍스트라 다른 SQLite 도구로 그대로 읽을 수 있어, 특정 벤더에 묶이지 않는 백업이 됩니다.
보안 / 멀티 DB 토큰
# 읽기 전용 토큰
turso db tokens create my-app --read-only
# 만료 기간 지정
turso db tokens create my-app --expiration 7d
각 클라이언트(BFF/워커)에 필요한 최소 권한 토큰만 발급하세요. 특히 DB 토큰을 브라우저로 내려보내면 안 됩니다. HTTP로 직접 쿼리할 수 있는 구조라 프런트엔드에서 DB에 바로 붙고 싶어지지만, 토큰이 노출되는 순간 누구나 그 권한으로 임의의 SQL을 실행할 수 있습니다. 읽기 전용 토큰이라도 모든 테이블을 읽을 수 있으므로, 클라이언트 요청은 항상 서버나 Worker를 거쳐 필요한 쿼리만 노출하는 것이 원칙입니다. 토큰을 교체해야 할 때는 turso db tokens invalidate로 그 DB의 기존 토큰을 한 번에 무효화할 수 있습니다.
성능 팁
- 임베디드 복제 쓰기 — 가능한 모든 장기 실행 환경에서 활성화
- 인덱스 — SQLite의 쿼리 플래너는 작지만 강력;
EXPLAIN QUERY PLAN적극 사용 - 배치 쓰기 —
db.batch()로 여러 INSERT를 한 번에 - WAL 체크포인트 — Turso는 자동 관리
- JSON 컬럼 —
TEXT+json_extract로 간단한 문서 저장 패턴 - Vector 인덱스 빌드 — 대량 insert 후 한 번에
CREATE INDEX
한계·주의
- 큰 쓰기 처리량: SQLite 기반이라 단일 writer. 초당 수만 write가 필요하면 Postgres/Mysql이 적합
- 복잡한 동시 트랜잭션: Serializable 수준이지만 Postgres만큼의 MVCC는 아님
- 쿼리 능력: Window function·CTE는 있으나 Postgres만큼 풍부하지는 않음
- Workers 임베디드 복제 불가: fs 없음 → 일반 원격 모드만
트러블슈팅
SQLITE_BUSY
여러 writer가 동시에 접근 시 발생. Turso는 기본적으로 큐잉하지만 장기적으로는 write path를 serialize하거나 재시도 백오프 구현. 이 에러가 자주 보인다면 대부분 잠금을 오래 쥐는 대화형 트랜잭션이 원인입니다. 트랜잭션 안에서 네트워크 왕복을 여러 번 하거나 외부 API를 호출하고 있지 않은지 먼저 확인하고, 가능한 것은 batch로 바꾸세요. 로컬 SQLite 파일을 직접 쓰는 개발 환경에서는 PRAGMA busy_timeout = 5000;으로 잠금을 기다리는 시간을 늘리는 것도 방법입니다.
임베디드 복제 파일 증가
WAL + 여러 복제 포인트로 파일이 커질 수 있음. 주기적 PRAGMA wal_checkpoint(TRUNCATE) 또는 파일 재생성.
벡터 검색 속도 저하
- ANN 인덱스가 실제로 사용되는지
EXPLAIN QUERY PLAN확인 - 임베딩 차원이 너무 높으면(>1536) 검색 성능 저하 → 차원 축소
Workers에서 timeout
- 원격 리전이 너무 멀 때.
turso db locations list로 가까운 그룹 선택 - 쿼리가 실제로 큰지 확인 →
.limit(N)강제
마무리
Turso는 엣지·서버리스·테넌트 분리라는 요구에 SQLite를 서버형으로 확장해 답하는 DB입니다. 임베디드 복제는 읽기를 애플리케이션 프로세스 안으로 끌어오고, 테넌트별 DB는 격리와 삭제를 단순하게 만듭니다. 반대로 단일 writer, 동기화 지연, 테넌트별 스키마 마이그레이션이라는 대가도 분명합니다. 읽기 중심이고 약간의 복제 지연을 허용하는 서비스라면 가볍게 시작하기 좋은 선택이고, 동시 쓰기가 많거나 강한 일관성이 필요한 핵심 트랜잭션 데이터라면 Postgres 계열과 함께 비교해 보는 것이 좋습니다.
자주 묻는 질문 (FAQ)
Q. 쓰기 요청이 몰릴 때 SQLITE_BUSY 오류가 나면 어떻게 하나요?
A. SQLite 계열은 동시에 하나의 writer만 쓰기를 진행할 수 있어서 여러 writer가 동시에 접근하면 이 오류가 날 수 있습니다. Turso가 기본적으로 쓰기를 큐잉하지만, 애플리케이션 쪽에서도 재시도 백오프를 구현하고 쓰기 경로를 가능한 한 직렬화하는 편이 안정적입니다. 여러 건을 쓸 때는 개별 요청 대신 트랜잭션이나 batch로 묶어 쓰기 횟수 자체를 줄이는 것도 도움이 됩니다.
같이 보면 좋은 글
- Astro DB — Turso·LibSQL 기반 서버리스 데이터베이스
- Cloudflare Workers AI | Edge에서 AI 모델 실행·Vectorize·D1
- Drizzle ORM — 타입 안전·SQL 친화 ORM