Turso(libSQL) 시작하기: Edge 복제 SQLite와 Drizzle·Cloudflare Workers 연동

이 글의 핵심

SQLite는 가볍지만 파일 하나에 묶여 있어 서버리스·엣지 환경의 여러 인스턴스가 공유하기 어렵습니다. Turso는 libSQL 서버와 리전별 읽기 복제본으로 이 한계를 보완하지만, 쓰기는 Primary로 모이므로 읽기 위주 서비스에서 효과가 큽니다. 복제본을 어디에 둘지, Embedded Replicas가 어떤 상황에 맞는지 판단할 수 있게 정리했습니다.

이 글의 핵심

Turso는 SQLite 기반 Edge Database입니다. 쓰기는 Primary에 모으고 읽기는 사용자와 가까운 복제본(또는 앱 프로세스 옆의 embedded replica)에서 처리해 네트워크 왕복을 줄이는 구조이며, Drizzle ORM으로 타입 안전하게 연동할 수 있습니다. LibSQL로 SQLite를 확장해 동시 쓰기와 원격 접속을 지원합니다.

Turso란?

Turso는 2022년 Glauber Costa가 설립한 SQLite 기반 서버리스 데이터베이스입니다. 팀이 LibSQL(오픈소스 포크)을 중심으로 원격·복제·프로토콜을 정리해 두었기 때문에, 기존에 SQLite에 익숙한 개발자는 학습 곡선이 짧으며, 엣지 런타임(Cloudflare Workers, Vercel Edge, Deno Deploy 등)과도 궁합이 좋습니다.

핵심 특징

1. Edge에 자동 복제

단일 리전 DB:
- Primary 한 곳 (예: 서울)
- 미국 사용자의 읽기도 태평양 왕복

Turso:
- 여러 위치의 복제본 또는 embedded replica
- 읽기는 가까운 복제본/로컬 파일에서 처리
- 쓰기는 여전히 Primary까지 왕복

읽기 지연이 줄어드는 이유는 단순히 물리적 거리 때문입니다. 서울에 있는 DB를 미국에서 조회하면 쿼리 자체보다 네트워크 왕복이 더 오래 걸리는데, 복제본이 가까이 있으면 그 왕복이 짧아집니다. embedded replica는 한 걸음 더 나아가 앱 프로세스 안에 SQLite 파일을 두고 동기화하므로 읽기가 네트워크를 타지 않습니다. 반대로 복제는 비동기라서 방금 쓴 값을 다른 복제본에서 바로 못 읽는 복제 지연이 생길 수 있습니다.

2. SQLite 기반

-- SQLite와 동일한 문법
CREATE TABLE users (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  name TEXT NOT NULL,
  email TEXT UNIQUE
);

INSERT INTO users (name, email) VALUES ('Alice', '[email protected]');
SELECT * FROM users;

3. LibSQL 확장

  • 동시 쓰기 지원
  • 원격 접속 (HTTP API)
  • WebAssembly 내장
  • 벡터 검색 (AI 임베딩)

4. 무료 플랜

  • 개인 프로젝트 규모는 무료 플랜으로 시작 가능
  • DB 수·스토리지·월간 읽기 행 한도는 요금제 개편마다 바뀌므로 공식 가격 페이지에서 확인

핵심 아키텍처: LibSQL과 Edge 복제

LibSQL은 Turso 팀이 만든 SQLite의 오픈소스 갈래이며, 여기에 HTTP·WebSocket 기반 프로토콜, 원격 연결, 임베디드(embedded) 복제, 때로는 서버 측 확장이 얹어져 있습니다. 데이터베이스 파일이 한 대의 디스크에만 묶여 있던 기존 SQLite와 달리, Turso는 중앙(또는 기본) 데이터베이스를 두며, 그 변경을 Edge 리전의 복제본(replica)으로 전파하는 모델을 취합니다.

Edge 복제(edge replication)의 감각은 이렇게 잡으면 이해가 빠릅니다. 쓰기는 보통 Primary에 모이고, 읽기는 가까운 복제본에서 처리해 RTT(왕복 지연)를 줄입니다. 앱이 서울이든 오리건이든, DB 클라이언트가 지리적으로 가깝다고 판단한 엔드포인트에 붙게 설계할 수 있으며, JAMstack·서버리스·엣지 함수처럼 한 리전에 고정되지 않는 워크로드에 특히 잘 맞습니다. 물론 “항상 0ms”는 마케팅적 수치이며, 실제로는 캐시·연결 풀·쿼리 복잡도에 따라 달라집니다. 다만 읽기 위주나 쓰기 빈도가 낮은 제품(블로그, 대시보드, B2B 툴 프로토타입)에서는 체감이 상당히 큽니다.

정리하면, SQLite 엔진 + LibSQL의 네트워킹/복제가 Turso의 뼈대이며, Turso Cloud는 호스팅·인증·리전·토큰을 한 번에 제공하는 상위 계층이라고 보면 됩니다.


SQLite의 한계와 Turso의 보완

로컬 파일 하나에 붙는 전통 SQLite의 대표적 제약은 다음과 비슷합니다.

  • 동시 쓰기: 단일 writer에 가깝다는 인상(대규모 동시 쓰기엔 RDB·분산 DB가 유리).
  • 원격 접속: 기본엔 OS 파일 핸들이 전제라, “클라우드 DB URL 하나”로 쓰기엔 애초에 설계가 다름.
  • 운영: 백업·리전·장애 복구를 직접 다져야 함.

Turso(및 libSQL 클라이언트)는 여기에 HTTP/RPC 스타일 접속과 호스티드 복제로 답을 붙입니다. 즉, “파일 락 하나”로 끝나는 로컬 앱이 아니라, 서비스로 제공되는 SQLite에 가깝게 쓰는 패턴이 됩니다. 다만 트랜잭션이 복수 리전에 동시에 강하게 일관되어야 한다거나, OLTP에 초당 엄청난 쓰기가 몰리는 시스템이면, 여전히 PostgreSQL·Cockroach·Spanner 쪽을 먼저 비교하는 편이 안전합니다. Turso는 읽기 확장 + 엣지 친화 + SQLite 친숙도에 강점이 있는 선택지입니다.


Edge 데이터베이스란?

Edge 데이터베이스는 계산(Edge Functions)이 사용자 가까이 가는 것처럼, 데이터 읽기 경로도 지연과 홉을 줄이는 방향으로 설계한 데이터베이스/배치 모델을 넓게 부르는 말에 가깝습니다. Turso는 그중에서도 지역 복제본을 앞에 두며, SQLite 호환으로 앱 코드 변경을 최소화하려는 쪽에 속합니다.

장점(실무 관점):

  • API 레이어가 엣지에 퍼져 있을 때 DB도 멀리 두지 않으려는 니즈를 충족.
  • 읽기 비중이 높은 서비스(목록, 설정, 캐시·세션 부근)에 유리.
  • 인프라 단순화: 작은 팀이 Postgres 리전·연결 풀·빗장까지 한꺼번에 들이는 부담을 줄일 수 있음.

주의할 점:

  • 쓰기 일관성·지연은 아키텍처를 봐야 합니다(Primary-Replica 구조의 일반적 트레이드오프).
  • Postgres 전용 확장이나 복잡한 락·동시성 시나리오가 핵심이면, SQLite 계열이 맞는지 먼저 검증이 필요합니다.

Turso 시작하기

1. CLI 설치

# Turso CLI 설치 (macOS/Linux)
curl -sSfL https://get.tur.so/install.sh | bash

# Windows
iwr https://get.tur.so/install.ps1 -useb | iex

# 로그인
turso auth login

# 버전 확인
turso --version

2. 데이터베이스 생성

# DB 생성
turso db create my-database

# 생성된 DB 목록
turso db list

# DB URL 확인
turso db show my-database

# 토큰 생성
turso db tokens create my-database

3. SQL 실행

# 인터랙티브 SQL 쉘
turso db shell my-database

# 쉘에서 SQL 실행
sqlite> CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT);
sqlite> INSERT INTO users (name) VALUES ('Alice');
sqlite> SELECT * FROM users;

Node.js/TypeScript 통합

@libsql/client 사용

npm install @libsql/client
// src/db/client.ts
import { createClient } from '@libsql/client';

export const turso = createClient({
  url: process.env.TURSO_DATABASE_URL!,
  authToken: process.env.TURSO_AUTH_TOKEN!,
});

// 쿼리 실행
const result = await turso.execute('SELECT * FROM users');
console.log(result.rows);

// 파라미터 바인딩
const user = await turso.execute({
  sql: 'SELECT * FROM users WHERE id = ?',
  args: [1],
});

// 트랜잭션
await turso.batch([
  { sql: 'INSERT INTO users (name) VALUES (?)', args: ['Alice'] },
  { sql: 'INSERT INTO posts (title, user_id) VALUES (?, ?)', args: ['Hello', 1] },
]);

실전 CRUD (TypeScript)

스키마가 items 테이블 하나일 때, Create / Read / Update / Delete를 @libsql/client로만 깔끔히 묶은 예시입니다. API 라우트(Next Route Handler, Hono, Fastify 등)에서 그대로 가져다 써도 됩니다.

// src/lib/itemsRepository.ts
import { createClient, type InArgs } from '@libsql/client';

const client = createClient({
  url: process.env.TURSO_DATABASE_URL!,
  authToken: process.env.TURSO_AUTH_TOKEN!,
});

export type Item = {
  id: number;
  title: string;
  body: string | null;
  createdAt: string;
};

// 테이블은 미리: CREATE TABLE items (...);
const rowToItem = (row: Record<string, unknown>): Item => ({
  id: Number(row.id),
  title: String(row.title),
  body: row.body == null ? null : String(row.body),
  createdAt: String(row.created_at),
});

/** Create */
export async function createItem(title: string, body?: string) {
  const r = await client.execute({
    sql: `INSERT INTO items (title, body) VALUES (?, ?) RETURNING id, title, body, created_at`,
    args: [title, body ?? null] as InArgs,
  });
  if (!r.rows[0]) throw new Error('insert failed');
  return rowToItem(r.rows[0] as Record<string, unknown>);
}

/** Read (단일) */
export async function getItemById(id: number) {
  const r = await client.execute({
    sql: `SELECT id, title, body, created_at FROM items WHERE id = ? LIMIT 1`,
    args: [id],
  });
  return r.rows[0] ? rowToItem(r.rows[0] as Record<string, unknown>) : null;
}

/** Read (목록) */
export async function listItems(limit = 50) {
  const r = await client.execute({
    sql: `SELECT id, title, body, created_at FROM items ORDER BY id DESC LIMIT ?`,
    args: [limit],
  });
  return r.rows.map((row) => rowToItem(row as Record<string, unknown>));
}

/** Update */
export async function updateItem(id: number, title: string, body?: string) {
  await client.execute({
    sql: `UPDATE items SET title = ?, body = ? WHERE id = ?`,
    args: [title, body ?? null, id] as InArgs,
  });
  return getItemById(id);
}

/** Delete */
export async function deleteItem(id: number) {
  await client.execute({
    sql: `DELETE FROM items WHERE id = ?`,
    args: [id],
  });
}

위 패턴의 장점은 ORM 없이도 바인딩·배치·에러 흐름을 제어할 수 있다는 점입니다. 팀이 Drizzle/Prisma를 이미 쓰면 아래 Drizzle ORM 통합 쪽이 유지보수에 더 맞을 수 있습니다.


Drizzle ORM 통합

설치 및 설정

npm install drizzle-orm @libsql/client
npm install -D drizzle-kit
// drizzle.config.ts
import type { Config } from 'drizzle-kit';

export default {
  schema: './src/db/schema.ts',
  out: './drizzle',
  driver: 'turso',
  dbCredentials: {
    url: process.env.TURSO_DATABASE_URL!,
    authToken: process.env.TURSO_AUTH_TOKEN!,
  },
} satisfies Config;

스키마 정의

// src/db/schema.ts
import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core';

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(),
});

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),
  createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
});

Drizzle 사용

// src/db/index.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!,
  authToken: process.env.TURSO_AUTH_TOKEN!,
});

export const db = drizzle(client, { schema });

// 사용 예제
const allUsers = await db.select().from(schema.users);

const newUser = await db.insert(schema.users).values({
  name: 'Alice',
  email: '[email protected]',
  createdAt: new Date(),
}).returning();

Cloudflare Workers에서 사용

// worker.ts
import { Hono } from 'hono';
import { drizzle } from 'drizzle-orm/libsql';
import { createClient } from '@libsql/client/web';
import * as schema from './db/schema';

interface Env {
  TURSO_DATABASE_URL: string;
  TURSO_AUTH_TOKEN: string;
}

const app = new Hono<{ Bindings: Env }>();

app.get('/users', async (c) => {
  const client = createClient({
    url: c.env.TURSO_DATABASE_URL,
    authToken: c.env.TURSO_AUTH_TOKEN,
  });
  
  const db = drizzle(client, { schema });
  const users = await db.select().from(schema.users);
  
  return c.json(users);
});

export default app;

Vercel·Netlify와의 통합

Vercel이나 Netlify에 올릴 때는, Turso 쪽은 보통 환경 변수 두 개(TURSO_DATABASE_URL, TURSO_AUTH_TOKEN)로 끝납니다. 제가 사이드 프로젝트·소규모 MVP를 올릴 때의 루틴은 대략 이렇습니다.

  1. Turso 대시보드/CLI에서 DB와 토큰을 만들고, 배포 콘솔에 동일 키로 넣습니다.
  2. Edge/Node 런타임이 갈리는 프레임워크라면, DB에 붙는 코드가 어디서 실행되는지만 확인한다(예: Next.js의 edge vs nodejs). WebAssembly 전용 클라이언트 분기(@libsql/client/web)가 필요한 환경이 있는지 체크합니다.
  3. 빌드 시 마이그레이션을 돌릴지, CI에서만 돌릴지 팀 규칙을 정합니다. Drizzle이면 drizzle-kit push 또는 SQL 파일 배포 흐름이 흔합니다.
  4. 콜드 스타트가 잦은 서버리스라면, 연결을 매 요청마다 새로 열지 말고 모듈 스코프에 클라이언트를 두는 패턴이 유효하다(플랫폼 제한이 있으면 그에 맞게).

Netlify Edge Functions를 쓸 때도 개념은 비슷하며, Node API 라우트에 Turso를 두는 구성이 가장 덜 까다로웠습니다. “배포 자체”보다, 런타임이 TCP·fetch 제약을 어떻게 갖느냐를 한 번씩 훑어보는 게 시간을 아낍니다.


복제 설정과 다중 리전 전략

읽기 복제본 추가

# 복제본 생성 (Tokyo 리전)
turso db replicas create my-database --location nrt

# 복제본 목록
turso db replicas list my-database

# 결과:
# - iad (Primary)
# - nrt (Replica)
# - syd (Replica)

다중 리전 전략을 잡을 때는, 제품이 “읽기 많고 쓰기 적다”는지부터 솔직하게 씁니다. 예를 들어 쓰기는 한 Primary 근처에 두며, 전역 사용자에게는 읽기 복제를 붙이는 식이면 Turso의 복제 모델과 잘 맞습니다. 반대로 강한 동시 쓰기나 같은 행에 대한 경쟁이 끊이지 않으면, 앱 레벨 큐나 집계 지연 같은 설계를 같이 가져가야 합니다.

리전을 여러 개 두었다면, 앱이 어느 URL로 붙는지(호스트/라우팅)도 정리해 두어야 합니다. CDN 뒤에 API가 흩어져 있을수록, “가까운 DB 엔드포인트”를 고르는 쪽이 지연에 유리한 경우가 많습니다. 다만 이 부분은 사용자 정의 DNS·프록시와도 엮이므로, 문서에 리전 맵을 짧게라도 남기는 것을 권합니다.

Embedded Replicas

// 로컬에 SQLite 복제본 생성 (오프라인 지원)
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, // 60초마다 동기화
});

// 로컬 DB에서 읽기 (즉시)
const users = await client.execute('SELECT * FROM users');

// 동기화
await client.sync();

Embedded Replica는 “디바이스나 한 프로세스 안에 로컬 SQLite 파일을 두며, 주기적·수동 동기화로 Turso와 맞춘다”는 패턴입니다. 읽기는 로컬에서 즉시 처리하며, 쓰기는 정책에 맞게 Turso 쪽으로 보내는 식의 앱(오프라인 퍼스트에 가깝다면 더욱)에 잘 맞습니다. syncInterval을 너무 촘촘하게 잡으면 모바일·엣지에서 배터리·네트워크 비용이 늘 수 있으니, 쓰기 빈도·일관성 요구를 보며 조절하는 편이 좋습니다. 프로덕션에 올리기 전에는 동기화 실패·충돌 시 UI·재시도 정책을 꼭 정해두세요.


가격·무료 티어 활용

Turso는 무료 플랜으로 DB 여러 개와 일정한 월간 읽기 한도·스토리지를 제공해 왔지만, 구체적인 한도는 요금제 개편 때마다 바뀌어 왔습니다. 다만 SaaS 요금은 수시로 변할 수 있으니, 배포 전 항상 Turso 가격 페이지와 대시보드를 다시 확인하세요.

무료 티어를 쓸 때 제가 챙기는 팁은 다음과 같습니다.

  • DB를 남용해 수백 개로 쪼개기보다, 스키마·환경(staging) 단위로 합리적으로 나누기.
  • 읽기 폭주가 예상되면, 캐시 레이어(HTTP 캐시, KV, CDN)와 역할을 나눠 DB 읽기 횟수를 절약하기.
  • 마이그레이션과 로컬 스택을 맞춰 두어, 실수로 스테이징/프로덕을 뒤섞지 않기.

유료로 넘어갈 때는 리전·스토리지·읽기/쓰기 단가뿐 아니라, 지원·SLA·백업 주기가 팀에 맞는지도 같이 보시길 권합니다.


프로덕션에서 느낀 점

실제로는, Turso는 마법의 해결책이 아니라 잘 맞는 문제가 있는 도구입니다. 저·중 트래픽, 읽기 중심, SQLite·Drizzle 생태에 이미 투자한 팀이면 DX가 좋으며, 온콜이 “왜 DB 커넥션이…”로 시작하는 밤을 줄이는 데도 도움이 됐습니다. 반면 초기부터 PostgreSQL 락인(특정 확장, JSON 연산, 복잡한 동시성)이 강한 시스템이면, 이주 비용을 따져야 합니다.

운영 측면에서 의외로 큰 이득은 “로컬은 SQLite, 클라우드는 Turso”처럼 같은 SQL 머릿속으로 dev/prod를 맞출 수 있다는 점이었습니다. 마이그레이션을 CI에 묶고, 시드 스크립트만 지키면, 팀 온보딩이 빨랐습니다. 한편 쓰기 지연이나 리전 정책을 제품 요구와 잘 맞는지, PoC로 스파이크한 뒤 본다고 생각지 않으면 나중에 아픈 지점이 됩니다. 저는 대시보드·내부툴·토이프로젝트엔 상당히 자신 있게 꺼내고, 정산·재고처럼 쓰기 경쟁이 격한 도메인은 다른 DB와 비교표를 먼저 그립니다.


같이 보면 좋은 글