RLS 정책 버그 하나가 전 테넌트 데이터를 노출시킬 때 — Turso libSQL의 DB-per-tenant와 Drizzle ORM 동적 연결로 폭발 반경을 줄이는 법
멀티테넌트 SaaS를 처음 설계할 때 대부분의 팀은 RLS(Row Level Security)로 시작합니다. 이유는 명확합니다. 데이터베이스 하나를 운영하면 되고, PostgreSQL의 정책 언어로 tenant_id 기반 행 단위 접근 제어를 구현할 수 있으며, Supabase 같은 플랫폼은 아예 이 패턴을 기본값으로 밀어줍니다.
그런데 RLS에는 조용히 따라오는 위험이 있습니다. 다음 코드를 보겠습니다.
-- 정책은 올바르게 작성됐습니다
ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON invoices
USING (tenant_id = current_setting('app.current_tenant'));// ORM 레이어에서 세션 변수를 설정합니다
async function getInvoices(tenantId: string) {
// SET (세션 레벨)을 쓰면 커넥션 풀이 이 커넥션을 재사용할 때
// 이전 테넌트 값이 그대로 남아있습니다
await pool.query(`SET app.current_tenant = '${tenantId}'`);
return pool.query('SELECT * FROM invoices');
}SET LOCAL을 쓰지 않으면 설정이 세션에 남습니다. 커넥션 풀이 같은 커넥션을 다음 요청에 재사용할 때, app.current_tenant는 이전 테넌트 값 그대로입니다. tenant_B 요청인데 tenant_A의 데이터가 반환됩니다. 이것만이 아닙니다. ENABLE ROW LEVEL SECURITY만으로는 테이블 소유자가 RLS를 우회합니다. FORCE ROW LEVEL SECURITY를 추가해야 소유자에게도 정책이 적용되는데, 이 줄 하나를 빠뜨리면 DB 소유자 권한으로 연결된 ORM이 모든 테넌트 데이터에 접근합니다.
정책 로직 버그, SET LOCAL 누락, ORM의 예상치 못한 쿼리 생성 — 어느 경로로든 단일 DB 안에 있는 모든 테넌트의 데이터가 한꺼번에 노출됩니다. 보안 업계 용어로 "폭발 반경(blast radius)"이 극도로 넓습니다.
Turso libSQL의 DB-per-tenant 아키텍처는 이 특정 위협 경로를 구조에서 제거합니다. RLS 정책 자체가 없으니 정책 버그도 없습니다. 다만, 위협이 사라지는 게 아니라 테넌트→DB 라우팅 레이어로 이동합니다. 라우팅 버그나 인증 오류가 생기면 잘못된 테넌트의 DB에 연결될 수 있습니다. 이 트레이드오프를 정확히 이해하는 게 중요합니다.
이 글에서는 세 가지 격리 전략의 의사결정 기준, Turso Platform API로 테넌트 온보딩을 자동화하는 코드, Drizzle ORM을 요청마다 동적으로 연결하는 패턴을 살펴봅니다. 작성 시점 기준으로 신규 사용자에게 지원이 중단된 Multi-DB Schemas 대신 마이그레이션을 직접 순회하는 러너 스크립트도 함께 다룹니다.
핵심 개념
libSQL과 Turso: SQLite를 클라우드로
libSQL은 Turso가 관리하는 SQLite의 C 기반 오픈소스 포크입니다. SQLite 자체는 오픈소스이지만 외부 기여를 허용하지 않아서, Turso가 포크를 만들어 오픈 기여 모델을 도입했습니다. 파일 하나가 곧 데이터베이스라는 SQLite의 핵심 모델을 유지하면서, 원격 접속(WebSocket/HTTP), 임베디드 레플리카, 벡터 검색, 암호화 같은 기능을 추가했습니다.
libSQL 서버 컴포넌트(sqld)는 Rust로 구현되어 있으며, SQLite의 완전한 Rust 재구현은 Limbo(현 Turso Database)라는 별도 프로젝트로 진행 중입니다. 이 글의 모든 코드 예시는 C 기반 libSQL을 사용하는 @libsql/client SDK를 기준으로 합니다.
임베디드 레플리카는 실제 운영에서 중요한 개념입니다. 원격 Turso 데이터베이스(primary)를 어플리케이션 서버 로컬 파일로 복제해두면, 읽기는 로컬 파일에서 마이크로초 수준으로 처리하고 쓰기만 primary로 보냅니다. 쓰기 후 로컬 레플리카에 동기화되기까지 수 밀리초 지연이 있어, 쓰기 직후 읽기 일관성에 주의해야 합니다.
Turso는 libSQL 위에서 동작하는 엣지 데이터베이스 플랫폼입니다. 파일 기반 구조 덕분에 유휴 DB가 프로세스를 점유하지 않고 스토리지 비용만 발생합니다. 작성 시점 기준 Developer 플랜($4.99/월)에서 DB 수는 무제한이며, 스토리지는 $0.75/GB로 과금됩니다. 가격 정책은 변경될 수 있으므로 공식 Pricing 페이지를 확인하는 걸 권장합니다.
멀티테넌트 격리 전략 3가지 비교
| 전략 | 격리 수준 | 폭발 반경 | 운영 복잡도 | 비용 |
|---|---|---|---|---|
| RLS | 논리적 | 전체 테넌트 | 낮음 | 낮음 |
| Schema-per-tenant | 논리적 | 동일 인스턴스 내 | 중간 | 낮음~중간 |
| DB-per-tenant | 물리적 | 해당 테넌트만 (라우팅 레이어 제외) | 높음 | Turso로 역전 |
DB-per-tenant는 전통적인 PostgreSQL 환경에서 운영 복잡도가 너무 높아 현실적이지 않았습니다. Turso가 뒤집어놓은 게 바로 이 비용·운영 구조입니다.
의사결정 기준
어떤 전략을 선택할지 판단하는 흐름을 먼저 살펴보겠습니다.
RLS 관리 역량이 부족한 팀에게 Schema-per-tenant를 권장하는 이유가 있습니다. RLS의 실패 지점은 정책 언어 오류로, 미묘하고 블라스트 반경이 넓습니다. Schema-per-tenant는 정책 언어 대신 스키마 프로비저닝(기계적이고 자동화 가능)이 운영 부담입니다. 테넌트 수가 적을 때는 이 오버헤드가 감당 가능하고, 인스턴스 내 격리이므로 RLS 정책 버그보다 실수의 영향 범위가 제한됩니다. 테넌트 수가 수백 개를 넘어가면 스키마 관리도 버거워지므로 DB-per-tenant가 적합합니다.
DB-per-tenant를 선택해야 하는 상황을 구체적으로 정리하면 이렇습니다.
- 금융·헬스케어·B2B SaaS처럼 데이터 침해 영향 범위를 물리적으로 제한해야 할 때
- EU 테넌트는 유럽 리전, 미국 테넌트는 US 리전 — GDPR/HIPAA 데이터 거주지 요건
- 테넌트별 독립 백업·복원이나 서로 다른 스키마 구조가 필요할 때
- 엣지 배포 환경에서 읽기 지연 최소화가 핵심 요건일 때
실전 적용
1. 테넌트 온보딩 자동화 — Clerk 웹훅 + Turso Platform API
사용자가 가입하면 전용 DB를 자동으로 프로비저닝하는 패턴입니다.
Next.js App Router 기준 코드입니다. Svix 서명 검증을 코드에 직접 포함했습니다. 이 단계를 건너뛰면 미검증 웹훅을 받아 임의의 DB 프로비저닝 요청이 실행될 수 있습니다.
// app/api/webhooks/clerk/route.ts
import { Webhook } from 'svix';
import { createClient } from '@tursodatabase/api';
const turso = createClient({
org: process.env.TURSO_ORG!,
token: process.env.TURSO_API_TOKEN!,
});
export async function POST(req: Request) {
const payload = await req.text();
const headers = {
'svix-id': req.headers.get('svix-id') ?? '',
'svix-timestamp': req.headers.get('svix-timestamp') ?? '',
'svix-signature': req.headers.get('svix-signature') ?? '',
};
const wh = new Webhook(process.env.CLERK_WEBHOOK_SECRET!);
let event: ReturnType<typeof wh.verify>;
try {
event = wh.verify(payload, headers) as { type: string; data: { id: string } };
} catch {
return Response.json({ error: 'Invalid signature' }, { status: 400 });
}
if (event.type === 'user.created') {
const userId = event.data.id;
const dbName = `tenant-${userId.replace(/_/g, '-')}`;
const dbResult = await turso.databases.create(dbName, { group: 'default' });
// databases.create()가 반환하는 hostname을 직접 사용합니다
// 문자열 조립으로 URL을 만들면 명명 규칙 변경 시 일관성이 깨집니다
const hostname = dbResult.hostname;
const tokenResult = await turso.databases.createToken(dbName);
// hostname과 토큰을 함께 저장합니다
await storeCredentialsForTenant(userId, { hostname, authToken: tokenResult.jwt });
}
return Response.json({ received: true });
}storeCredentialsForTenant는 Vercel KV, Upstash Redis, 별도 메타 DB 등 팀 상황에 맞게 구현하면 됩니다. 토큰을 환경변수에 하드코딩하지 마세요. 테넌트 수가 50개를 넘어가는 순간 환경변수 방식은 관리가 불가능해집니다.
2. 요청마다 Drizzle 인스턴스 동적 생성
RLS와 가장 다른 지점이 여기입니다. 요청에서 테넌트를 식별한 뒤 해당 DB에 연결하는 인스턴스를 그때그때 만들어야 합니다.
// lib/db.ts
import { createClient } from '@libsql/client';
import { drizzle } from 'drizzle-orm/libsql';
import * as schema from './schema';
// Node.js 장기 실행 서버에서는 인스턴스를 프로세스 생애주기 동안 재사용합니다
// 서버리스(Vercel Functions, Lambda): 웜 인스턴스에서는 캐시가 유지되지만
// 콜드 스타트마다 초기화됩니다 — 여전히 요청 내 중복 연결은 방지됩니다
// Cloudflare Workers 등 엣지 환경: 요청 간 상태가 없으므로
// 이 Map 캐시가 동작하지 않습니다. 외부 KV나 waitUntil 패턴을 사용하세요
const instanceCache = new Map<string, ReturnType<typeof drizzle>>();
export async function getTenantDB(tenantId: string) {
if (instanceCache.has(tenantId)) {
return instanceCache.get(tenantId)!;
}
const { hostname, authToken } = await getCredentialsForTenant(tenantId);
const client = createClient({
url: `libsql://${hostname}`,
authToken,
});
const db = drizzle(client, { schema });
instanceCache.set(tenantId, db);
return db;
}실제 API 핸들러에서는 이렇게 씁니다.
// app/api/invoices/route.ts
import { getTenantDB } from '@/lib/db';
import { invoices } from '@/lib/schema';
import { auth } from '@clerk/nextjs/server';
export async function GET() {
const { userId } = await auth(); // App Router에서 auth()는 비동기입니다
if (!userId) return Response.json({ error: 'Unauthorized' }, { status: 401 });
// userId가 곧 테넌트 ID — DB 격리라서 tenant_id 필터가 필요 없습니다
const db = await getTenantDB(userId);
const result = await db.select().from(invoices);
return Response.json(result);
}where(eq(invoices.tenantId, userId)) 같은 필터가 없습니다. 해당 DB 자체가 그 테넌트의 것이기 때문입니다. RLS 정책도, tenant_id 컬럼도 필요 없습니다. 단, getTenantDB(userId)에서 잘못된 userId가 전달되거나 인증 레이어에 버그가 있으면 잘못된 테넌트의 DB에 연결됩니다. 이것이 라우팅 레이어로 이동한 위협 지점입니다. 인증 미들웨어가 이 레이어의 게이트키퍼입니다.
3. Drizzle 스키마 정의
테넌트별 DB라도 스키마 정의 자체는 모든 테넌트가 동일합니다.
// lib/schema.ts
import { sqliteTable, text, integer, real } from 'drizzle-orm/sqlite-core';
export const invoices = sqliteTable('invoices', {
id: text('id').primaryKey(),
amount: real('amount').notNull(),
status: text('status', { enum: ['pending', 'paid', 'overdue'] }).notNull(),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
});
export const customers = sqliteTable('customers', {
id: text('id').primaryKey(),
name: text('name').notNull(),
email: text('email').notNull().unique(),
});tenant_id 컬럼이 없습니다. 스키마가 훨씬 단순해집니다.
4. 멀티테넌트 마이그레이션 러너 직접 만들기
작성 시점 기준으로 Multi-DB Schemas 기능이 Turso 신규 사용자에게 지원 중단됐습니다. 부모 스키마 DB에 마이그레이션을 한 번 실행하면 모든 자식 DB에 전파되던 기능인데, 이제는 직접 순회해야 합니다. Platform API로 자동화하면 실제로는 관리 가능한 수준입니다.
테넌트 수가 수천 개라면 동시 실행 수를 제한해야 합니다. 아래 코드는 p-limit으로 동시 20개를 유지합니다.
// scripts/migrate-all-tenants.ts
import { createClient as createTursoApiClient } from '@tursodatabase/api';
import { createClient } from '@libsql/client';
import { drizzle } from 'drizzle-orm/libsql';
import { migrate } from 'drizzle-orm/libsql/migrator';
import pLimit from 'p-limit';
const turso = createTursoApiClient({
org: process.env.TURSO_ORG!,
token: process.env.TURSO_API_TOKEN!,
});
async function migrateAllTenants() {
const databases = await turso.databases.list();
// 테넌트 DB만 필터링 — 메타 DB나 다른 용도의 DB가 섞여 있을 수 있습니다
const tenantDbs = databases.filter((db) => db.name.startsWith('tenant-'));
console.log(`총 ${tenantDbs.length}개 테넌트 DB에 마이그레이션을 실행합니다.`);
const limit = pLimit(20); // 동시 20개로 제한
const results = await Promise.allSettled(
tenantDbs.map((db) =>
limit(async () => {
const tokenResult = await turso.databases.createToken(db.name);
// databases.list()가 반환하는 hostname을 그대로 사용합니다
const client = createClient({
url: `libsql://${db.hostname}`,
authToken: tokenResult.jwt,
});
try {
const drizzleDb = drizzle(client);
await migrate(drizzleDb, { migrationsFolder: './drizzle' });
} finally {
// 성공·실패 여부와 관계없이 커넥션을 닫습니다
client.close();
}
return db.name;
})
)
);
// Promise.allSettled를 쓴 이유: 한 테넌트 실패가 나머지를 멈추지 않아야 합니다
results.forEach((result, index) => {
if (result.status === 'fulfilled') {
console.log(`✅ ${result.value} 완료`);
} else {
console.error(`❌ ${tenantDbs[index].name} 실패:`, result.reason);
}
});
}
migrateAllTenants().catch(console.error);장단점 분석
장점
| 항목 | 실제 의미 |
|---|---|
| RLS 정책 위협 경로 제거 | RLS 정책 버그, SET LOCAL 누락, 소유자 우회 — 이 경로 자체가 없어짐 |
| 데이터 침해 범위 제한 | 침해가 발생해도 해당 테넌트 DB 파일 하나만 영향 (라우팅 레이어 버그는 별도) |
| 성능 노이즈 차단 | 한 테넌트의 무거운 쿼리가 다른 테넌트 응답 시간에 영향 없음 |
| 컴플라이언스 단순화 | Database Groups로 테넌트별 데이터 리전 제어, 개별 삭제·감사 가능 |
| 저비용 다수 DB | 유휴 DB는 스토리지 비용만 발생, DB 수 자체는 과금 기준 아님 |
| 쿼리 단순화 | tenant_id 컬럼, RLS 정책, 퍼미션 로직 불필요 |
| 초저지연 읽기 | 임베디드 레플리카 활용 시 로컬 파일에서 직접 읽어 마이크로초 수준 지연 |
Database Groups는 여기서 중요한 개념입니다. Turso의 Group은 여러 리전에 걸쳐 데이터베이스 집합을 배포하는 단위입니다. EU 테넌트용 그룹을 eu-west 리전에, US 테넌트용 그룹을 us-east 리전에 만들면, 각 그룹에 속한 DB는 해당 리전에 데이터가 유지됩니다. GDPR의 데이터 거주지 요건을 DB 생성 시점에 그룹만 지정하는 것으로 충족할 수 있습니다.
단점
| 항목 | 실제 영향 |
|---|---|
| 라우팅 레이어가 새 위협 지점 | 인증 버그나 tenantId 오류 → 잘못된 DB 연결, RLS와 구조적으로 유사한 위험 |
| 단일 쓰기 모델 | SQLite 상속 한계로 쓰기 집중 워크로드에서 PostgreSQL 대비 병목 가능 |
| 마이그레이션 직접 관리 | Multi-DB Schemas 지원 중단 이후 모든 테넌트 DB에 순회 적용 필요 |
| 크로스테넌트 집계 어려움 | 전체 테넌트 통계 집계에 복잡도 증가, 대규모 분석은 별도 솔루션 필요 |
| 레플리카 동기화 지연 | 쓰기 후 임베디드 레플리카까지 수 ms 지연, 쓰기 직후 읽기 일관성 주의 |
| 기능 성숙도 | Stored Procedure, LISTEN/NOTIFY 미지원, OLAP 부적합 |
| 플랫폼 의존도 | Turso 생태계 lock-in, 자체 호스팅 시 운영 복잡도 존재 |
크로스테넌트 집계 관련해서 Multi-Database Attach가 부분적인 해결책이 됩니다. SQLite의 ATTACH DATABASE와 유사하게 Turso에서도 여러 원격 DB를 하나의 쿼리 컨텍스트에 붙여 사용할 수 있는 기능입니다. 소수 테넌트 간 집계에는 유효하지만, 수천 개 테넌트 전체 집계에는 적합하지 않습니다. 어드민 대시보드에서 전체 테넌트 데이터를 복잡하게 분석해야 한다면 별도 분석 파이프라인을 계획하는 게 현실적입니다.
실무에서 흔한 실수
쓰기 직후 바로 읽기
임베디드 레플리카 사용 시 쓰기 후 수 밀리초 안에 같은 데이터를 읽으면 오래된 값이 올 수 있습니다. 중요한 플로우에서는 sync() 호출로 명시적 동기화를 기다리거나, 쓰기와 읽기를 같은 연결(원격 primary)에서 처리하는 것이 안전합니다.
환경변수로 테넌트 토큰 관리
테넌트가 10개일 때는 TURSO_TOKEN_tenant123 같은 환경변수가 통할 수 있습니다. 50개가 넘는 순간 관리가 불가능해집니다. 처음부터 KV 스토어나 Vault에 토큰을 저장하는 구조로 시작하세요.
마이그레이션 드리프트
테넌트가 수백 개라면 새 마이그레이션 배포 시 일부 테넌트 DB에 적용이 누락될 수 있습니다. 마이그레이션 러너를 CI/CD 파이프라인에 포함시키고, 각 테넌트의 마이그레이션 상태를 메타 DB에서 추적하는 게 장기적으로 안전합니다.
크로스테넌트 분석 요건 과소평가
"어드민 대시보드에서 전체 매출 합산만 보면 돼"로 시작했다가 분석 요건이 점점 복잡해지는 경우가 많습니다. 복잡한 크로스테넌트 집계가 핵심 기능이라면 DB-per-tenant를 선택하기 전에 먼저 검토가 필요합니다.
DB-per-tenant를 선택할 시점
Turso libSQL의 DB-per-tenant는 RLS가 해결하지 못하는 특정 위협 경로를 구조에서 제거합니다. RLS 정책 버그, SET LOCAL 누락, 소유자 우회 — 이런 실수가 발생할 가능성 자체가 없어집니다. 대신 위협 지점은 테넌트→DB 라우팅 레이어로 이동하므로, 인증 미들웨어의 신뢰도가 핵심이 됩니다.
쓰기 집중 워크로드, 복잡한 크로스테넌트 분석, 마이그레이션 자동화 역량이 아직 갖춰지지 않았다면 RLS나 Supabase가 여전히 현실적인 선택입니다. 트레이드오프를 알고 고르는 것이 중요합니다.
지금 시작해본다면 이 순서가 도움이 됩니다.
1단계 — 로컬 연동 확인
@libsql/client와 drizzle-orm을 설치한 뒤 file:./test.db로 로컬 SQLite 파일에 연결해 스키마와 쿼리가 제대로 동작하는지 먼저 확인합니다.
2단계 — Turso Developer 플랜에서 테넌트 DB 2~3개 직접 만들어보기 Platform API로 DB를 생성하고 토큰을 발급하는 흐름을 손으로 한 번 해보면, 온보딩 자동화 코드가 훨씬 쉽게 그려집니다. Vercel의 "Turso Per User Starter" 템플릿이 이 흐름을 가장 잘 보여줍니다.
3단계 — 마이그레이션 러너를 CI/CD에 연결 개발 초기부터 전체 테넌트 마이그레이션 러너를 배포 파이프라인에 넣어두는 게 좋습니다. 테넌트가 수백 개가 된 뒤에 추가하면 첫 실행 자체가 큰 이벤트가 됩니다.
참고 자료
- Turso 공식 멀티테넌시 페이지
- Turso 블로그: Database Per Tenant Architectures Get Production Friendly Improvements
- Turso 블로그: Multi-Tenant E-Commerce Architecture with Turso
- Turso 블로그: Creating a multitenant SaaS service with Turso, Remix, and Drizzle
- Turso 블로그: Introducing the Turso Per-User Starter
- Turso 블로그: Working with Clerk and per-user databases
- Turso 공식 문서: libSQL 개요
- Turso 공식 문서: Platform API
- Turso 공식 문서: Embedded Replicas
- Drizzle ORM 공식 문서: Drizzle with Turso
- Drizzle ORM 공식 문서: Connect Turso Cloud
- Turso 공식 문서: Drizzle ORM 연동
- GitHub: turso-api-client-ts
- GitHub: libSQL 오픈소스 저장소
- Vercel 템플릿: Turso Per User Starter
- DEV Community: Distributed SQLite — Why LibSQL and Turso are the New Standard in 2026
- aliasghar.me: Multi-tenant SaaS — RLS vs schema-per-tenant vs database-per-tenant