Turso + Drizzle ORM으로 테넌트별 독립 SQLite DB를 엣지에서 운영하는 법
멀티테넌트 SaaS를 처음 설계할 때 Row-Level Security를 당연하게 생각했습니다. PostgreSQL에 RLS 정책 걸고, 쿼리마다 tenant_id 필터 붙이고 넘어갔는데 — 몇 달 뒤 대형 테넌트의 쿼리 하나가 전체 테이블을 잠그면서 다른 테넌트들이 느려지는 경험을 했습니다. 격리가 논리적이었을 뿐, 물리적으로는 하나의 DB였으니까요.
그때부터 Database-per-Tenant 패턴을 진지하게 들여다보게 됐고, 이 접근을 실용적으로 만들어주는 조합을 발견했습니다. Turso(libSQL) + Drizzle ORM + Cloudflare Workers입니다. 이 글은 왜 이 스택인지, 어떻게 연결하는지, 어디서 막히는지 순서대로 다룹니다.
대상은 엣지 환경에서 SaaS를 구축하거나 구축 예정인 풀스택·백엔드 개발자입니다.
핵심 개념
Turso는 "네트워크를 탄 SQLite"다
SQLite는 파일 기반 DB입니다. 프로세스 내부에 붙어서 동작하고, 네트워크 연결이라는 개념 자체가 없어요. 그래서 단일 머신 밖으로 나가는 순간 한계에 부딪힙니다.
libSQL은 SQLite를 오픈소스로 포크해서 네트워크 프로토콜과 복제 레이어를 얹은 겁니다. SQL 문법은 SQLite와 100% 호환되면서, HTTP로 어디서든 접근할 수 있게 됐습니다. Turso는 이 libSQL을 Fly.io 인프라 위에서 30개 이상의 리전에 걸쳐 관리형 서비스로 제공합니다. (Cloudflare의 300개+ PoP 네트워크와는 별개의 인프라입니다.)
SQLite (파일 기반, 단일 머신)
↓ fork + 네트워크 레이어 추가
libSQL (오픈소스, HTTP 프로토콜 지원)
↓ 관리형 서비스화
Turso (Fly.io 기반, 30+ 리전)SQLite 생태계 도구를 그대로 쓸 수 있다는 점이 실질적인 이득입니다. sqlite3 CLI, DB Browser for SQLite, 기존 마이그레이션 스크립트 — 전부 호환됩니다.
멀티테넌트 격리 방식 비교
격리 방식은 크게 세 가지로 나뉩니다:
| 방식 | 격리 수준 | 관리 복잡도 | 크로스테넌트 쿼리 | Turso 적합성 |
|---|---|---|---|---|
| 단일 DB + RLS | 논리적 | 낮음 | 쉬움 | 낮음 |
| 스키마별 격리 | 논리적 | 중간 | 중간 | 낮음 |
| DB-per-Tenant | 물리적 | 높음 (→ 자동화로 해결) | 별도 파이프라인 필요 | 최적 |
Turso가 Database-per-Tenant에 적합한 이유는 단순합니다. 아이들 상태 DB는 스토리지 비용만 발생하고, 콜드 스타트 패널티도 없습니다. PostgreSQL에서 테넌트마다 인스턴스를 띄우면 비용이 기하급수적으로 뛰지만, Turso는 무료 플랜부터 상당수의 DB를 무료로 허용합니다. 정확한 플랜별 한도는 Turso 가격 페이지에서 직접 확인하세요. 요금제는 자주 변경됩니다.
아키텍처 전체 흐름
핵심은 메타 DB입니다. 테넌트 목록과 각 테넌트의 DB URL, Auth Token을 저장하는 중앙 레지스트리 역할을 합니다. 요청이 들어오면 Worker 안에서 메타 DB를 조회하고, 그 정보로 해당 테넌트 DB에 연결합니다.
신규 테넌트 프로비저닝은 Worker 안이 아니라 별도 백엔드 서비스나 관리용 스크립트에서 실행됩니다. Workers에는 파일 시스템이 없어서 마이그레이션 SQL 파일을 직접 읽는 방식을 사용할 수 없기 때문입니다. 이 구분이 뒤에서 중요하게 작동합니다.
Drizzle ORM — Workers 환경에서 주의할 점
Drizzle은 TypeScript-first SQL 쿼리 빌더입니다. libSQL을 네이티브로 지원하는데, Workers 환경에서 반드시 다른 임포트 경로를 써야 합니다.
// ❌ Node.js 환경 전용 — Workers에서 동작 안 함
import { createClient } from '@libsql/client';
// ✅ Cloudflare Workers / 엣지 환경
import { createClient } from '@libsql/client/web';이걸 놓치면 Workers 배포 후 런타임 에러가 납니다. 로컬에서는 잘 돌아가니까 원인 파악이 늦어질 수 있습니다.
Embedded Replicas — Workers에서는 쓸 수 없다
Turso의 매력적인 기능 중 하나가 Embedded Replicas입니다. 원격 Turso DB와 동기화되는 로컬 SQLite 파일을 앱 프로세스 안에서 직접 실행해서, 읽기를 네트워크 없이 서브밀리초로 처리하는 구조입니다.
Cloudflare Workers는 무상태(stateless) 환경이라 영구 파일 시스템이 없습니다. Workers에서는 HTTP 기반 원격 연결만 가능합니다. 가장 가까운 Turso 리전으로 HTTP 요청이 가는 방식이에요. 네트워크 홉은 있지만, 리전이 분산되어 있어 현실적으로 수십 밀리초 이내입니다.
Embedded Replicas는 Node.js나 Bun으로 직접 서버를 운영할 때 적합합니다.
실전 적용
프로젝트 셋업
pnpm add drizzle-orm @libsql/client hono
pnpm add -D drizzle-kit wrangler @tursodatabase/apiwrangler.toml 설정:
name = "my-saas-worker"
main = "src/index.ts"
compatibility_date = "2025-01-01"
[vars]
META_DB_URL = "libsql://meta-db-org.turso.io"
[[kv_namespaces]]
binding = "TENANT_CACHE"
id = "your-kv-namespace-id"
# 시크릿은 wrangler secret put으로 설정
# META_DB_AUTH_TOKEN스키마 정의
메타 DB와 테넌트 DB 스키마를 분리해서 관리합니다:
// src/schema/meta.ts — 테넌트 레지스트리
import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core';
export const tenants = sqliteTable('tenants', {
id: text('id').primaryKey(), // 테넌트 슬러그 (예: 'acme-corp')
dbUrl: text('db_url').notNull(),
dbAuthToken: text('db_auth_token').notNull(), // ⚠️ 아래 보안 주의사항 참고
plan: text('plan').notNull().default('free'),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
});// src/schema/tenant.ts — 각 테넌트 DB 스키마
import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core';
export const users = sqliteTable('users', {
id: text('id').primaryKey(),
email: text('email').notNull().unique(),
name: text('name'),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
});
export const projects = sqliteTable('projects', {
id: text('id').primaryKey(),
name: text('name').notNull(),
ownerId: text('owner_id').notNull().references(() => users.id),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
});보안 주의:
dbAuthToken을 메타 DB에 평문으로 저장하면, 메타 DB가 노출되는 순간 모든 테넌트 DB에 접근이 가능해집니다. 프로덕션에서는 Cloudflare KV에 암호화해서 저장하거나 AWS Secrets Manager, HashiCorp Vault 같은 외부 시크릿 관리 서비스를 연동하는 방식을 권장합니다. 또한 Turso 토큰을 발급할 때 최소 권한(읽기 전용 등)으로 제한하는 것이 좋습니다.
drizzle.config.ts 설정
// drizzle.config.ts — 메타 DB 마이그레이션
import { defineConfig } from 'drizzle-kit';
export default defineConfig({
dialect: 'turso',
schema: './src/schema/meta.ts',
out: './drizzle/meta',
dbCredentials: {
url: process.env.META_DB_URL!,
authToken: process.env.META_DB_AUTH_TOKEN!,
},
});테넌트 DB 마이그레이션은 별도 config 파일로 관리합니다:
// drizzle.tenant.config.ts — 테넌트 DB 마이그레이션
import { defineConfig } from 'drizzle-kit';
export default defineConfig({
dialect: 'turso',
schema: './src/schema/tenant.ts',
out: './drizzle/tenant',
dbCredentials: {
url: process.env.TENANT_DB_URL!,
authToken: process.env.TENANT_DB_AUTH_TOKEN!,
},
});신규 테넌트 온보딩 — DB 동적 생성
이 코드는 Cloudflare Workers가 아니라 별도 백엔드 서비스나 관리용 스크립트에서 실행합니다. Workers에는 파일 시스템이 없어서 마이그레이션 폴더를 읽는 migrate()를 직접 사용할 수 없기 때문입니다.
// scripts/provision-tenant.ts
// Node.js / Bun 환경에서 실행 (Workers 아님)
import { createClient } from '@libsql/client';
import { TursoClient } from '@tursodatabase/api';
import { drizzle } from 'drizzle-orm/libsql';
import { migrate } from 'drizzle-orm/libsql/migrator';
import { tenants } from '../src/schema/meta';
export async function provisionTenant(
metaDb: ReturnType<typeof drizzle>,
turso: TursoClient,
tenantId: string,
) {
// 1. Turso Platform API로 새 DB 생성
const db = await turso.databases.create({
name: `tenant-${tenantId}`,
group: 'default',
});
// 2. Auth Token 발급
// 프로퍼티 이름(db.name, db.hostname 등)은 @tursodatabase/api 버전마다
// 다를 수 있으니 설치 후 타입 정의를 직접 확인하세요.
const token = await turso.databases.createToken(db.name);
const dbUrl = `libsql://${db.hostname}`;
const dbAuthToken = token.jwt;
// 3. 새 DB에 스키마 마이그레이션 적용
const tenantClient = createClient({ url: dbUrl, authToken: dbAuthToken });
const tenantDrizzle = drizzle(tenantClient);
await migrate(tenantDrizzle, { migrationsFolder: './drizzle/tenant' });
// 4. 메타 DB에 테넌트 정보 등록 (프로덕션에서는 토큰 암호화 후 저장)
await metaDb.insert(tenants).values({
id: tenantId,
dbUrl,
dbAuthToken,
plan: 'free',
createdAt: new Date(),
});
return { dbUrl, dbAuthToken };
}@tursodatabase/api 외에 turso-auto 라이브러리도 온디맨드 DB 생성을 지원합니다. Platform API를 직접 다루지 않아도 되는 더 높은 수준의 추상화를 제공하는데, API가 빠르게 변하는 초기 라이브러리이므로 도입 전 GitHub 저장소에서 현재 API 형태를 확인하는 것을 권장합니다.
Hono + Cloudflare Workers 라우터
실제 요청 처리 부분입니다. 매 요청마다 메타 DB를 조회하면 레이턴시가 두 배가 되므로, Workers KV를 캐시 레이어로 두는 패턴을 미들웨어에 직접 포함했습니다:
// src/index.ts
import { Hono } from 'hono';
import { drizzle } from 'drizzle-orm/libsql';
import { createClient } from '@libsql/client/web'; // Workers에서는 반드시 /web
import { eq } from 'drizzle-orm';
import { tenants } from './schema/meta';
import { users, projects } from './schema/tenant';
type Env = {
Bindings: {
META_DB_URL: string;
META_DB_AUTH_TOKEN: string;
TENANT_CACHE: KVNamespace;
};
Variables: {
tenantDb: ReturnType<typeof drizzle>;
};
};
const app = new Hono<Env>();
// 테넌트 미들웨어 — KV 캐시 → 메타 DB 순서로 조회
app.use('/api/*', async (c, next) => {
const tenantId = c.req.header('X-Tenant-ID');
if (!tenantId) return c.json({ error: 'Tenant ID required' }, 400);
let dbUrl: string;
let dbAuthToken: string;
// 1. KV 캐시 먼저 확인
const cached = await c.env.TENANT_CACHE.get(tenantId);
if (cached) {
({ dbUrl, dbAuthToken } = JSON.parse(cached));
} else {
// 2. 캐시 미스 시 메타 DB 조회
const metaClient = createClient({
url: c.env.META_DB_URL,
authToken: c.env.META_DB_AUTH_TOKEN,
});
const metaDb = drizzle(metaClient);
const [tenant] = await metaDb
.select()
.from(tenants)
.where(eq(tenants.id, tenantId))
.limit(1);
if (!tenant) return c.json({ error: 'Tenant not found' }, 404);
dbUrl = tenant.dbUrl;
dbAuthToken = tenant.dbAuthToken;
// 3. KV에 캐싱 (TTL 60초)
await c.env.TENANT_CACHE.put(
tenantId,
JSON.stringify({ dbUrl, dbAuthToken }),
{ expirationTtl: 60 },
);
}
const tenantClient = createClient({ url: dbUrl, authToken: dbAuthToken });
c.set('tenantDb', drizzle(tenantClient));
await next();
});
app.get('/api/users', async (c) => {
const db = c.get('tenantDb');
const result = await db.select().from(users);
return c.json(result);
});
app.post('/api/projects', async (c) => {
const db = c.get('tenantDb');
const body = await c.req.json<{ name: string; ownerId: string }>();
const [project] = await db
.insert(projects)
.values({
id: crypto.randomUUID(),
name: body.name,
ownerId: body.ownerId,
createdAt: new Date(),
})
.returning();
return c.json(project, 201);
});
export default app;요청 흐름 시퀀스
제약 및 한계
실제 써보면서 느낀 장점들
| 장점 | 설명 |
|---|---|
| 진정한 데이터 격리 | 물리적으로 독립된 파일. 노이지 네이버 문제 없음 |
| GDPR 대응 단순화 | 테넌트 DB 삭제 = 해당 고객 데이터 완전 삭제 |
| 비용 효율 | 아이들 DB는 스토리지 비용만, 인스턴스당 과금 없음 |
| 타입 안전성 | Drizzle로 스키마부터 쿼리까지 완전한 타입 추론 |
| 이식성 | Cloudflare에 종속되지 않음. Bring Your Own Cloud 가능 |
| 운영 단순성 | 커넥션 풀링, 복잡한 설정 없음 |
현실적인 한계
| 한계 | 실제 영향 |
|---|---|
| 단일 쓰기 락 | WAL 모드 기준 수천 TPS 수준이 현실적 한계. 이벤트 로깅·실시간 분석에 부적합 |
| 크로스 테넌트 집계 불가 | 관리자 대시보드 구현 시 별도 ETL 파이프라인 필요 |
| 복잡한 SQL 분석 쿼리 한계 | 윈도우 함수, 풀텍스트 서치 지원이 PostgreSQL 수준 미달 |
| Workers에서 Embedded Replicas 미지원 | HTTP 왕복 지연 발생. 수십 밀리초 범위 |
| 에코시스템 성숙도 | Stack Overflow 답변·프로덕션 검증 패턴 부족 |
실무에서 자주 나오는 실수들
1. 임포트 경로 혼동
// Workers에서 이걸 쓰면 배포 후 런타임 에러
import { createClient } from '@libsql/client';
// 이걸 써야 합니다
import { createClient } from '@libsql/client/web';2. Multi-DB Schemas deprecated 놓침
Turso가 예전에 제공하던 Multi-DB Schemas 기능(부모 스키마 DB에서 자식 DB로 스키마 변경을 자동 전파)은 신규 사용자에게 deprecated 처리되었습니다. 오래된 튜토리얼을 참고할 때 주의하세요. 지금은 turso-auto 또는 개별 마이그레이션 적용 방식이 권장됩니다.
3. 프로비저닝 로직을 Worker 안에 두려는 시도
migrate()는 파일 시스템에서 SQL 파일을 읽습니다. Workers에는 파일 시스템이 없으므로, 프로비저닝 로직은 반드시 Workers 바깥에서 실행해야 합니다. 신규 테넌트 가입 흐름은 별도 백엔드 서비스나 큐 워커로 구성하세요.
어느 경우에 이 스택을 택하면 좋을까
마치며
이 스택을 한 줄로 정리하면: 쓰기 부하가 적고, 테넌트별 완전 격리가 필요하고, 엣지 성능이 중요한 SaaS에서 PostgreSQL 대비 운영 복잡도와 비용을 동시에 줄이는 조합입니다.
읽기 중심 워크로드와 멀티테넌트 격리 요건이 맞물리는 환경에서는 PostgreSQL보다 실질적인 이점이 있습니다. 단, 크로스 테넌트 집계가 필요한 분석 기능은 별도 파이프라인을 처음부터 설계에 포함시키는 게 좋습니다. 이 부분만 처음에 간과하지 않으면, 나머지 아키텍처는 비교적 명확하게 정리됩니다.
지금 시작하는 3단계:
- Turso 계정 생성 + 메타 DB 세팅 —
turso db create meta-db로 테넌트 레지스트리 DB를 만들고, 로컬에서 Drizzle 마이그레이션 적용 - 테넌트 프로비저닝 로직 구현 —
@tursodatabase/api로 신규 테넌트 가입 시 DB 자동 생성 + 스키마 적용 흐름을 Node.js 백엔드 서비스나 큐 워커로 구성 - Hono Worker 배포 —
@libsql/client/web임포트 확인 후wrangler deploy로 배포, Workers KV 캐싱 미들웨어 동작 검증
참고 자료
- Turso 공식 문서 — Database Per Tenant
- Turso 블로그 — Multi-Tenant E-Commerce Architecture with Turso
- Turso 블로그 — Creating a multitenant SaaS service with Turso, Remix, and Drizzle
- Turso 블로그 — Working with Clerk and per-user databases
- GitHub — tursodatabase/turso-auto
- Drizzle ORM 공식 문서 — Connect Turso
- Drizzle ORM 공식 튜토리얼 — Drizzle with Turso
- Cloudflare Workers 공식 문서 — Turso Third-Party Integration
- Cloudflare Workers 공식 튜토리얼 — Connect to Turso using Workers
- GitHub — hono-cloudflare-worker-turso 예시