Bun 1.2 내장 SQLite로 테넌트별 DB 파일을 분리하고 WAL과 커넥션 풀을 설계하기
SaaS 백엔드에서 멀티테넌트 DB 설계를 고민하다 보면 초기엔 대부분 단일 DB에 tenant_id 컬럼을 붙이는 방식으로 시작합니다. 저도 그랬고요. 문제는 시간이 지날수록 슬금슬금 나타납니다. 어느 테넌트가 무거운 집계 쿼리를 돌리면 전체 응답 시간이 출렁이고, 실수로 WHERE 절에 tenant_id를 빠뜨리면 다른 테넌트 데이터를 건드리게 됩니다. 경험해본 분들은 아실 겁니다 — 그 오후는 정말 길었습니다.
Database-per-Tenant 패턴, 즉 테넌트마다 독립된 DB 파일을 하나씩 주는 방식은 데이터 혼입 가능성을 구조적으로 차단하고 성능 격리도 동시에 달성합니다. 하지만 PostgreSQL로 구현하려면 테넌트마다 커넥션 풀을 따로 관리해야 해서 오히려 복잡도가 올라갑니다. 여기에 Bun의 bun:sqlite 내장 드라이버가 현실적인 선택지를 열어줬습니다. 외부 패키지 없이 SQLite를 네이티브로 구동하고, WAL 모드를 활성화하면 다수의 읽기 연결이 단일 쓰기 연결과 서로를 차단하지 않고 동시에 동작합니다. 이를 테넌트별로 독립 적용하면 테넌트 간 쓰기 경쟁이 완전히 사라집니다.
이 글에서는 테넌트별 .db 파일 분리, WAL + PRAGMA 설정, LRU 기반 인스턴스 풀 구현, 그리고 스키마 마이그레이션 전략까지 실제 코드와 함께 살펴봅니다. Bun으로 넘어오는 중이거나 SQLite를 프로덕션에서 진지하게 고려 중인 Node.js/TypeScript 개발자라면 바로 적용해볼 만한 내용입니다.
왜 지금 이 조합인가
bun:sqlite — 런타임과 함께 배포되는 내장 드라이버
bun:sqlite는 Bun 초기 버전부터 함께 제공된 내장 모듈로, JavaScriptCore 엔진과 직접 통합되어 N-API를 거치지 않고 네이티브로 SQLite를 호출합니다. 별도의 npm 패키지가 필요 없어 배포 파이프라인이 단순해지고, better-sqlite3처럼 네이티브 바인딩을 빌드하는 단계도 없습니다.
성능이 얼마나 빠른지에 대한 벤치마크는 워크로드에 따라 편차가 크기 때문에 특정 배수를 인용하는 대신, N-API 레이어를 건너뛰고 엔진에 직결된 구조 자체를 이점으로 보는 편이 정직합니다. 실제 워크로드에서의 이득은 각자 프로파일링으로 확인해야 합니다.
SQLite-as-Primary-DB 흐름
Cloudflare D1, Turso(libSQL), Litestream을 중심으로 SQLite를 서버 프로덕션 DB로 쓰는 흐름이 눈에 띄게 자리 잡고 있습니다. 특히 소규모·중규모 SaaS에서 "테넌트당 SQLite 파일" 아키텍처가 현실적 대안으로 주목받고 있으며, node:sqlite가 Node.js v22.5+에 실험적으로 도입된 것도 같은 방향의 신호입니다.
SQLite는 임베디드 DB이므로 TCP 커넥션 풀이 불필요하고, 비활성 테넌트 파일은 핸들을 닫아두면 메모리를 차지하지 않습니다. 수백 개의 소규모 테넌트를 다루는 SaaS에서 PostgreSQL보다 운영 오버헤드가 훨씬 낮은 이유가 여기 있습니다.
아키텍처: 테넌트별 파일 + 중앙 메타 DB
기본 디렉터리 구조는 간단합니다.
data/
├── central.db # 테넌트 목록, 스키마 버전 관리
└── tenants/
├── org-abc.db # 테넌트별 독립 파일
├── org-abc.db-wal # WAL 파일 (자동 생성·관리됨)
├── org-def.db
└── org-def.db-walcentral.db는 테넌트 등록 정보와 스키마 버전을 관리하는 메타 저장소 역할을 합니다. 실제 테넌트 데이터는 각자의 파일에 격리됩니다. HTTP 요청이 들어오면 tenantId를 추출해 해당 파일을 열거나 풀에서 꺼내 씁니다.
테넌트 간에는 파일 자체가 다르므로 데이터 혼입이 구조적으로 불가능합니다. 테넌트를 삭제할 때도 해당 .db와 -wal 파일만 지우면 됩니다.
WAL 모드: 동시성 문제의 구조적 해법
SQLite 기본 저널 모드(DELETE 모드)에서는 쓰기가 진행되는 동안 읽기도 차단됩니다. 단일 사용자 환경에서는 괜찮지만, 여러 요청이 동시에 들어오는 서버 환경에서는 SQLITE_BUSY 오류가 튀어나옵니다.
WAL 모드는 변경사항을 메인 .db 파일 대신 별도의 -wal 파일에 먼저 기록하고, 체크포인트 시점에 병합합니다. 덕분에 다수의 읽기 연결과 단일 쓰기 연결이 서로를 차단하지 않고 동시에 동작합니다.
busy_timeout은 잠금 발생 시 즉시 실패하지 않고 지정 시간만큼 재시도하며 대기하도록 만드는 설정입니다. 오류를 원천 제거하는 것이 아니라 순간적 경합을 흡수해주는 완충 장치입니다. 쓰기 경합이 심한 워크로드에서는 여전히 SQLITE_BUSY가 발생할 수 있어, 애플리케이션 레이어의 재시도 로직도 함께 고려해야 합니다. 저도 처음엔 busy_timeout 없이 WAL만 켜놨다가 동시 쓰기 상황에서 바로 잠금 오류를 맞닥뜨렸는데, 이 설정 하나가 얼마나 큰 완충 역할을 하는지 그때 실감했습니다.
핵심 PRAGMA 조합
PRAGMA journal_mode = WAL; -- WAL 모드 활성화
PRAGMA synchronous = NORMAL; -- 성능과 내구성의 균형
PRAGMA busy_timeout = 5000; -- 잠금 대기 최대 5초 (ms)
PRAGMA wal_autocheckpoint = 1000; -- 1000 페이지마다 자동 체크포인트 (기본값)
PRAGMA cache_size = -64000; -- 연결당 64MB 캐시synchronous = NORMAL은 WAL 모드에서 대부분의 운영 환경에 수용 가능한 내구성을 제공하는 절충점입니다. 다만 FULL과 달리 매 커밋마다 fsync를 강제하지 않기 때문에, 갑작스러운 전원 장애 시 아직 체크포인트되지 않은 최근 커밋 일부가 손실될 가능성은 남아 있습니다(자세한 조건은 SQLite PRAGMA 문서의 synchronous 항목 참고). 금융 원장처럼 마지막 커밋의 완전한 내구성이 절대 조건이라면 FULL을 검토해야 합니다.
wal_autocheckpoint = 1000은 사실 SQLite 기본값과 동일합니다. 명시 설정은 "우리 팀이 이 값을 의식하고 유지한다"는 의도 표기 정도의 의미이고, 이 값을 명시하지 않았다고 해서 WAL이 무한정 커지는 것은 아닙니다. WAL 파일이 비정상적으로 비대화되는 실제 원인은 뒤에서 따로 다룹니다.
커넥션 풀 구현: LRU 인스턴스 관리
PostgreSQL/MySQL과 달리 SQLite는 TCP 연결이 없습니다. 대신 파일 기반 멀티테넌트 환경에서는 열린 파일 핸들 수를 제한해야 합니다. OS의 ulimit 기본값은 보통 1024이고, 테넌트마다 writer 1개 + reader N개를 열면 핸들 소모가 빠르게 늘어납니다. 테넌트 수가 수백을 넘어가면 모든 DB를 동시에 열어두기 어려워 LRU 기반으로 인스턴스를 관리해야 합니다.
JavaScript의 Map은 삽입 순서를 유지합니다. 접근 시 해당 항목을 삭제하고 재삽입하면 최근 사용된 항목이 항상 뒤에 오는 LRU 동작을 구현할 수 있습니다.
1 Writer + N Readers 패턴
WAL 모드의 이점을 최대로 살리려면 테넌트별로 쓰기 전용 인스턴스 1개와 읽기 전용 인스턴스 여러 개를 분리하는 것이 좋습니다. bun:sqlite에서는 같은 파일을 readonly: true 옵션으로 여러 번 열 수 있습니다.
// tenant-db-pool.ts
import { Database } from "bun:sqlite";
import { mkdirSync } from "fs";
const PRAGMA_WRITER = `
PRAGMA journal_mode = WAL;
PRAGMA synchronous = NORMAL;
PRAGMA busy_timeout = 5000;
PRAGMA wal_autocheckpoint = 1000;
PRAGMA cache_size = -64000;
`;
const PRAGMA_READER = `
PRAGMA busy_timeout = 5000;
PRAGMA cache_size = -64000;
`;
function applySchema(db: Database): void {
db.exec(`
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL UNIQUE,
created_at INTEGER NOT NULL DEFAULT (unixepoch())
);
CREATE TABLE IF NOT EXISTS events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
type TEXT NOT NULL,
data TEXT,
created_at INTEGER NOT NULL DEFAULT (unixepoch())
);
CREATE INDEX IF NOT EXISTS idx_events_type ON events (type);
`);
}
interface TenantConnections {
writer: Database;
readers: Database[];
nextReader: number;
}
export class TenantDBPool {
private connections = new Map<string, TenantConnections>();
private readonly dbDir: string;
private readonly maxTenants: number;
private readonly readerCount: number;
constructor(options: {
dbDir: string;
maxTenants?: number;
readerCount?: number;
}) {
this.dbDir = options.dbDir;
this.maxTenants = options.maxTenants ?? 100;
this.readerCount = options.readerCount ?? 3;
mkdirSync(this.dbDir, { recursive: true });
}
private openTenant(tenantId: string): TenantConnections {
const path = `${this.dbDir}/${tenantId}.db`;
const writer = new Database(path, { create: true });
writer.exec(PRAGMA_WRITER);
applySchema(writer);
const readers = Array.from({ length: this.readerCount }, () => {
const r = new Database(path, { readonly: true });
r.exec(PRAGMA_READER);
return r;
});
return { writer, readers, nextReader: 0 };
}
private getConnections(tenantId: string): TenantConnections {
if (this.connections.has(tenantId)) {
const conns = this.connections.get(tenantId)!;
this.connections.delete(tenantId);
this.connections.set(tenantId, conns);
return conns;
}
if (this.connections.size >= this.maxTenants) {
const oldestKey = this.connections.keys().next().value!;
this.closeTenant(oldestKey);
}
const conns = this.openTenant(tenantId);
this.connections.set(tenantId, conns);
return conns;
}
getWriter(tenantId: string): Database {
return this.getConnections(tenantId).writer;
}
getReader(tenantId: string): Database {
const conns = this.getConnections(tenantId);
const reader = conns.readers[conns.nextReader % conns.readers.length];
conns.nextReader++;
return reader;
}
closeTenant(tenantId: string): void {
const conns = this.connections.get(tenantId);
if (!conns) return;
conns.writer.close();
conns.readers.forEach((r) => r.close());
this.connections.delete(tenantId);
}
closeAll(): void {
for (const tenantId of [...this.connections.keys()]) {
this.closeTenant(tenantId);
}
}
}사용 예시
// app.ts
import { TenantDBPool } from "./tenant-db-pool";
const pool = new TenantDBPool({
dbDir: "./data/tenants",
maxTenants: 200,
readerCount: 3,
});
// 읽기 — reader 인스턴스 사용
const events = pool
.getReader("org-abc")
.query("SELECT * FROM events WHERE type = ? ORDER BY created_at DESC LIMIT 50")
.all("login");
// 쓰기 — transaction 래퍼 필수
const writer = pool.getWriter("org-abc");
writer.transaction(() => {
writer
.query("INSERT INTO events (type, data) VALUES (?, ?)")
.run("login", JSON.stringify({ userId: 42 }));
writer
.query("UPDATE users SET last_seen = ? WHERE id = ?")
.run(Date.now(), 42);
})();db.transaction(fn)은 함수를 반환합니다. 마지막 ()로 즉시 실행하거나 반환된 함수를 저장해 재사용할 수도 있습니다. 예외가 발생하면 자동으로 롤백됩니다.
readerCount는 예상 동시 읽기 요청 수와 CPU 코어 수를 기준으로 시작해 조정하면 됩니다. 코어 수를 크게 넘겨도 SQLite 단일 파일 특성상 실질적 이득은 크지 않다는 것이 커뮤니티의 일반적인 관찰입니다.
스키마 마이그레이션 전략
멀티테넌트 파일 분리 아키텍처에서 스키마 마이그레이션은 파일 수만큼 적용해야 하므로 자동화 없이는 관리가 어렵습니다.
신규 테넌트는 파일을 처음 열 때 자동 초기화됩니다. 위 코드에서 applySchema()를 openTenant() 안에서 호출하므로 신규 테넌트는 항상 최신 스키마로 시작합니다. CREATE TABLE IF NOT EXISTS 패턴으로 멱등성을 보장했기 때문에 반복 실행해도 안전합니다.
기존 테넌트 DB에 변경이 생기면 일괄 마이그레이션 스크립트를 씁니다. 주의할 점은 ALTER TABLE ... ADD COLUMN이 SQLite에서 멱등하지 않다는 것입니다. 컬럼이 이미 존재하면 duplicate column name 오류로 스크립트가 중단됩니다. 각 테넌트의 스키마 버전을 추적하는 방식이 가장 안전합니다.
// migrate-all.ts
import { Database } from "bun:sqlite";
import { readdirSync } from "fs";
interface Migration {
version: number;
sql: string;
}
const MIGRATIONS: Migration[] = [
{ version: 1, sql: `ALTER TABLE events ADD COLUMN metadata TEXT;` },
];
function ensureMigrationTable(db: Database): void {
db.exec(`
CREATE TABLE IF NOT EXISTS schema_migrations (
version INTEGER PRIMARY KEY,
applied_at INTEGER NOT NULL DEFAULT (unixepoch())
);
`);
}
function currentVersion(db: Database): number {
const row = db
.query("SELECT COALESCE(MAX(version), 0) AS v FROM schema_migrations")
.get() as { v: number };
return row.v;
}
function applyMigrations(db: Database): number[] {
ensureMigrationTable(db);
const from = currentVersion(db);
const pending = MIGRATIONS.filter((m) => m.version > from);
const applied: number[] = [];
for (const m of pending) {
db.transaction(() => {
db.exec(m.sql);
db.query("INSERT INTO schema_migrations (version) VALUES (?)").run(m.version);
})();
applied.push(m.version);
}
return applied;
}
const files = readdirSync("./data/tenants").filter((f) => f.endsWith(".db"));
for (const file of files) {
const db = new Database(`./data/tenants/${file}`);
db.exec("PRAGMA busy_timeout = 5000;");
try {
const applied = applyMigrations(db);
console.log(`${file}: 적용 ${applied.length}건 (${applied.join(", ") || "없음"})`);
} catch (err) {
console.error(`실패: ${file}`, err);
} finally {
db.close();
}
}파일 수가 수천 개로 늘어나면 drizzle-orm이나 kysely의 마이그레이션 시스템으로 넘어가는 것도 자연스러운 선택입니다. 위 예시는 그 방향으로 가기 전 최소 형태의 버전 추적 뼈대입니다.
트레이드오프
장단점 한눈에
| 항목 | 내용 |
|---|---|
| 완전한 데이터 격리 | 테넌트 간 혼입 구조적 불가. 삭제는 파일 삭제로 완결 |
| 성능 격리 | 한 테넌트의 heavy query가 다른 테넌트에 영향 없음 |
| 비용 효율 | 비활성 테넌트 핸들을 닫으면 메모리 점유 없음 |
| 무의존성 배포 | bun:sqlite는 npm 패키지 불필요 |
| 백업 단순성 | 파일 단위 백업·복원·이전 용이 |
| WAL 동시성 | 읽기-쓰기 비차단 동시 접근 가능 |
| ⚠ 파일 핸들 제한 | OS ulimit 의존. 수천 테넌트 시 LRU 풀 필수 |
| ⚠ WAL 파일 비대화 | 장기 실행 읽기 트랜잭션이 스냅샷을 잡고 있으면 체크포인트가 지연됨 |
| ⚠ 크로스-테넌트 쿼리 불가 | 집계 분석은 ATTACH DATABASE 우회 필요 |
| ⚠ 마이그레이션 복잡도 | 파일 수만큼 적용해야 해 자동화 필수 |
| ⚠ 단일 서버 가정 | 수평 확장 시 Litestream·Turso 전환 검토 |
실무에서 자주 보이는 실수
1. transaction() 없이 여러 쿼리 실행
// 위험: 중간 실패 시 부분 적용 상태가 됩니다
writer.query("UPDATE accounts SET balance = ? WHERE id = ?").run(newBalance, id);
writer.query("INSERT INTO ledger ...").run(...);
// 안전: 원자성 보장됩니다
writer.transaction(() => {
writer.query("UPDATE accounts SET balance = ? WHERE id = ?").run(newBalance, id);
writer.query("INSERT INTO ledger ...").run(...);
})();2. WAL 파일이 계속 커진다면 자동 체크포인트를 의심하기 전에 장기 실행 읽기 트랜잭션을 의심하기
wal_autocheckpoint는 기본값 1000 페이지로 이미 활성화되어 있습니다. 그럼에도 WAL 파일이 계속 커진다면 대개 원인은 다른 쪽입니다. 장기 실행 읽기 트랜잭션이 이전 스냅샷을 붙잡고 있으면 SQLite는 그 스냅샷에 필요한 WAL 페이지를 재활용하지 못해 체크포인트를 미룹니다. 배치 조회나 커넥션 누수로 트랜잭션이 열린 채 방치되는 지점부터 점검해야 합니다. 정기적으로 PRAGMA wal_checkpoint(TRUNCATE)를 트래픽 저부하 시간대에 돌리는 것도 완화책으로 함께 쓸 수 있습니다.
3. 읽기 쿼리에도 writer 사용
WAL의 1 Writer + N Readers 이점을 얻으려면 읽기는 반드시 getReader()로 받은 인스턴스를 써야 합니다. writer 하나로 읽기와 쓰기를 모두 처리하면 동시성 이득이 크게 줄어듭니다.
4. 서버 시작 시 모든 테넌트 DB를 한 번에 열기
200개 테넌트를 시작 시 모두 열어두면 초기화 시간도 길어지고 파일 핸들도 낭비됩니다. 요청이 들어올 때 lazy하게 열고 LRU 풀로 관리하는 것이 맞습니다.
보안 고려사항: tenantId 검증
파일 경로 세그먼트에 tenantId를 그대로 넣기 때문에, 검증 없이 요청 값을 흘리면 경로 순회 공격이 성립할 수 있습니다. ../../etc/passwd 같은 값이 들어오는 최악의 경우뿐 아니라, 존재하지 않는 테넌트 파일을 임의로 생성해 디스크를 채우는 남용도 가능합니다.
풀에 진입하기 전에 반드시 whitelist 방식으로 검증하세요.
const TENANT_ID_PATTERN = /^[a-zA-Z0-9-]{1,64}$/;
function validateTenantId(tenantId: string): void {
if (!TENANT_ID_PATTERN.test(tenantId)) {
throw new Error("유효하지 않은 테넌트 ID");
}
}
// 미들웨어 진입 지점에서 항상 먼저 실행
validateTenantId(req.tenantId);
const db = pool.getReader(req.tenantId);여기에 더해 인증 계층에서 요청 사용자가 해당 tenantId에 접근 권한이 있는지 확인하는 로직은 별도로 있어야 합니다. 문자열 형식이 유효하다는 것과 그 테넌트에 접근해도 된다는 것은 다른 문제입니다.
마무리
bun:sqlite + WAL + LRU 인스턴스 풀 조합은 소규모·중규모 SaaS에서 PostgreSQL 없이도 견고한 멀티테넌트 아키텍처를 구성할 수 있게 해줍니다. 테넌트 수가 수십~수백 수준이고 단일 서버로 트래픽을 소화할 수 있다면 운영 복잡도 대비 얻는 가치가 큽니다.
경험상 조정 시점의 신호는 대략 이렇습니다. 서버 로그에 SQLITE_BUSY가 반복적으로 찍히기 시작하면 busy_timeout을 늘리기보다 먼저 쓰기 트랜잭션 범위가 지나치게 넓지 않은지 살펴보세요. 트랜잭션 안에서 네트워크 호출이나 JSON 직렬화 같은 무거운 작업을 하고 있으면 다른 최적화보다 그것부터 걷어내는 게 효과가 큽니다. 파일 핸들 수가 ulimit에 근접해 오류가 나기 시작하면 그때가 maxTenants를 낮추거나 readerCount를 줄일 시점입니다. WAL 파일 크기가 며칠에 걸쳐 우상향한다면 앞서 언급한 장기 실행 읽기 트랜잭션을 먼저 의심하는 편이 빠릅니다.
한계도 몇 가지 미리 인지해두는 것이 좋습니다. 테넌트 수가 수천 이상으로 늘면 LRU 풀과 ulimit 관리가 복잡해지고, LRU 퇴거가 자주 발생해 파일 열기 비용이 누적됩니다. 수평 확장이 필요해지는 시점이 오면 Litestream이나 Turso 같은 분산 SQLite 솔루션이 자연스러운 다음 단계입니다. 크로스-테넌트 분석 쿼리가 핵심 기능이라면 처음부터 단일 DB + 스키마 분리 방식을 고려하는 게 나을 수 있습니다.
참고 자료
공식 문서
- SQLite - Bun 공식 문서
- bun:sqlite API Reference
- Pragma statements supported by SQLite
- Write-Ahead Logging (WAL) - SQLite 공식 문서