`bun:sqlite`와 WAL로 단일 VPS에서 SQLite를 백엔드로 세우기
Bun 런타임을 열어 import { Database } from "bun:sqlite" 한 줄만 적으면 별도 드라이버 설치 없이 SQLite와 대화할 수 있습니다. 이 사실은 알고 있어도, PRAGMA를 어떻게 조합해야 실제로 안정된 처리량이 나오는지, 동시 쓰기 경합을 어떻게 다뤄야 하는지는 예제 코드에서 잘 다뤄지지 않는 편입니다. 이 글은 그 빈틈을 채우는 데 초점을 둡니다.
bun:sqlite는 SQLite C 라이브러리를 런타임에 정적으로 링크하고, Node.js 네이티브 애드온이 거치는 N-API 경계를 통과하지 않습니다. Bun 공식 문서와 Bun 1.2 릴리스 노트에서 이 구조와 better-sqlite3 대비 벤치마크가 정리돼 있으니, 수치 자체는 공식 자료를 원문 그대로 참고하는 편이 안전합니다. 이 글은 특정 배수 주장을 반복하기보다, 그 구조를 실제 API 서버에서 활용할 때 필요한 설정과 트레이드오프를 다룹니다.
경량 백엔드나 CLI 도구를 TypeScript로 빠르게 프로토타이핑하는 개발자, 그리고 단일 VPS로 시작해 트래픽이 늘어도 한동안 버티고 싶은 팀을 염두에 두고 썼습니다.
bun:sqlite가 다른 SQLite 클라이언트와 다른 지점
Node.js의 네이티브 애드온은 N-API를 통해 JavaScript 런타임과 C++ 코드 사이를 왕복하는 마샬링 비용을 가집니다. Bun은 런타임이 Zig로 작성돼 있고 SQLite C 라이브러리를 내장하기 때문에 별도의 애드온 경계를 넘지 않습니다.
구체적인 벤치마크 수치는 워크로드마다 크게 다르므로, 자신의 쿼리 패턴으로 직접 재보는 편이 실무에서는 훨씬 유용합니다. Bun 공식이 제시한 조건과 결과는 1.2 릴리스 노트의 벤치마크 섹션에서 확인할 수 있습니다.
WAL 모드가 바꾸는 것
기본 저널 방식(rollback journal)에서는 쓰기 트랜잭션이 DB 파일에 잠금을 걸어 리더를 차단합니다. WAL(Write-Ahead Logging)은 변경분을 별도 -wal 파일에 먼저 기록하고, 리더는 -shm 공유 메모리 인덱스를 참고해 특정 페이지의 최신 버전이 WAL에 있는지 확인한 뒤 WAL 또는 메인 DB 파일에서 읽습니다.
핵심 성질은 SQLite WAL 공식 문서가 정리한 "리더는 라이터를 막지 않고, 라이터는 리더를 막지 않는다"는 것입니다. 성능 개선 폭은 읽기/쓰기 비율, 동시 리더 수, 페이지 크기, 트랜잭션 길이에 따라 크게 달라지므로 배수 형태로 일반화하기는 어렵습니다. 다만 라이터-리더 상호 대기가 사라지면서 SQLITE_BUSY가 발생하는 조건이 훨씬 좁아진다는 정성적 이점은 확실합니다.
여기서 놓치기 쉬운 점은 WAL 모드에서도 동시 쓰기 트랜잭션은 여전히 하나만 허용된다는 것입니다. 리더-라이터 간섭은 사라져도, 라이터-라이터 경합은 그대로 남습니다. 이 부분은 뒤에서 다시 다룹니다.
query.as(Class) — 결과를 타입 안전한 인스턴스로
bun:sqlite에는 쿼리 결과를 특정 클래스 인스턴스로 매핑하는 .as() 메서드가 있습니다. Bun 공식 SQLite 문서에 예제와 함께 정리돼 있으니 최신 API 시그니처는 문서를 확인하는 편이 정확합니다.
import { Database } from "bun:sqlite";
class User {
id!: number;
name!: string;
email!: string;
createdAt!: string;
}
const db = new Database("app.db");
const getUser = db.query("SELECT * FROM users WHERE id = $id").as(User);
const user = getUser.get({ $id: 1 });Drizzle이나 Prisma를 붙이기 전 단계에서 이 정도만으로도 결과 타입을 상당 부분 안전하게 다룰 수 있습니다.
PRAGMA 기본 세트
프로덕션 후보로 쓰려면 최소한의 PRAGMA 조합이 필요합니다. 아래는 읽기 다수 + 쓰기 소수 워크로드에 무난한 시작점입니다.
import { Database } from "bun:sqlite";
function openDatabase(path: string) {
const db = new Database(path);
db.run("PRAGMA journal_mode = WAL");
db.run("PRAGMA synchronous = NORMAL");
db.run("PRAGMA cache_size = -64000");
db.run("PRAGMA foreign_keys = ON");
db.run("PRAGMA busy_timeout = 5000");
return db;
}
export const db = openDatabase("app.db");synchronous = NORMAL은 전원 손실 시 마지막 트랜잭션을 잃을 가능성이 있지만, DB 파일 손상 위험은 없습니다. 금융성 데이터라면FULL이 더 안전합니다.OFF는 전원 손실 시 DB 파일 자체가 손상될 수 있으므로 임시 캐시가 아닌 이상 피하는 편이 낫습니다.busy_timeout = 5000은 라이터가 경합하는 경우 SQLite가 5초까지 내부적으로 재시도하도록 지시합니다. 이 값이 없으면 즉시SQLITE_BUSY가 튀어나옵니다.cache_size = -64000은 페이지 캐시로 64MB를 잡습니다. 사용 가능한 RAM과 워킹셋을 보고 조정하세요.
준비된 구문 캐싱
db.query(sql)은 동일한 SQL 문자열에 대해 내부적으로 컴파일된 Statement를 캐시합니다. 파라미터가 달라도 같은 Statement가 재사용되므로, 루프 안에서 반복적으로 db.query(...)를 호출해도 SQL 바이트코드가 매번 재컴파일되지는 않습니다. 다만 코드 명확성 측면에서 Statement를 한 번 뽑아두고 참조하는 편이 읽기에 좋습니다.
const findProduct = db.query("SELECT * FROM products WHERE id = $id");
for (const item of items) {
findProduct.get({ $id: item.id });
}배치 삽입과 트랜잭션
트랜잭션 없이 행을 하나씩 삽입하면 매 삽입마다 암묵적 트랜잭션이 열리고 닫히는 오버헤드가 누적됩니다. db.transaction()으로 감싸면 Bun이 BEGIN/COMMIT을 처리해 줍니다.
import { Database } from "bun:sqlite";
const db = new Database("app.db");
db.run("PRAGMA journal_mode = WAL");
db.run("PRAGMA synchronous = NORMAL");
db.run("PRAGMA busy_timeout = 5000");
db.run(`
CREATE TABLE IF NOT EXISTS events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
type TEXT NOT NULL,
payload TEXT,
created_at TEXT DEFAULT (datetime('now'))
)
`);
const insertEvent = db.query(
"INSERT INTO events (type, payload) VALUES ($type, $payload)"
);
const batchInsert = db.transaction(
(rows: Array<{ type: string; payload: string }>) => {
for (const row of rows) {
insertEvent.run({ $type: row.type, $payload: row.payload });
}
}
);
const events = Array.from({ length: 10_000 }, (_, i) => ({
type: "click",
payload: JSON.stringify({ index: i }),
}));
const start = performance.now();
batchInsert(events);
console.log(`삽입 완료: ${(performance.now() - start).toFixed(1)}ms`);여러 라이터가 경합할 가능성이 있다면 db.run("BEGIN IMMEDIATE")로 시작해 즉시 write lock을 잡는 방식이 안전합니다. BEGIN(=DEFERRED)은 첫 쓰기 시점에 잠금을 승격하는데, 이 승격 과정에서 다른 라이터와 겹치면 SQLITE_BUSY_SNAPSHOT이 발생할 수 있습니다.
Hono와 조합한 REST API
아래는 검증을 최소한으로 넣은 개념적 예시입니다. 실서비스에서는 Zod 등으로 스키마 검증을 추가하는 편이 안전합니다.
import { Hono } from "hono";
import { Database, SQLiteError } from "bun:sqlite";
const db = new Database("tasks.db");
db.run("PRAGMA journal_mode = WAL");
db.run("PRAGMA synchronous = NORMAL");
db.run("PRAGMA foreign_keys = ON");
db.run("PRAGMA busy_timeout = 5000");
db.run(`
CREATE TABLE IF NOT EXISTS tasks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
done INTEGER DEFAULT 0,
created_at TEXT DEFAULT (datetime('now'))
)
`);
const queries = {
list: db.query("SELECT * FROM tasks ORDER BY created_at DESC"),
get: db.query("SELECT * FROM tasks WHERE id = $id"),
insert: db.query("INSERT INTO tasks (title) VALUES ($title) RETURNING *"),
update: db.query("UPDATE tasks SET done = $done WHERE id = $id RETURNING *"),
delete: db.query("DELETE FROM tasks WHERE id = $id"),
};
const app = new Hono();
app.get("/tasks", (c) => c.json(queries.list.all()));
app.get("/tasks/:id", (c) => {
const task = queries.get.get({ $id: Number(c.req.param("id")) });
if (!task) return c.json({ error: "Not found" }, 404);
return c.json(task);
});
app.post("/tasks", async (c) => {
let body: unknown;
try {
body = await c.req.json();
} catch {
return c.json({ error: "Invalid JSON" }, 400);
}
const title = (body as { title?: unknown })?.title;
if (typeof title !== "string" || title.trim() === "") {
return c.json({ error: "title required" }, 400);
}
const task = queries.insert.get({ $title: title });
return c.json(task, 201);
});
app.patch("/tasks/:id", async (c) => {
const { done } = await c.req.json<{ done: boolean }>();
const task = queries.update.get({
$id: Number(c.req.param("id")),
$done: done ? 1 : 0,
});
if (!task) return c.json({ error: "Not found" }, 404);
return c.json(task);
});
app.delete("/tasks/:id", (c) => {
queries.delete.run({ $id: Number(c.req.param("id")) });
return c.body(null, 204);
});
app.onError((err, c) => {
if (err instanceof SQLiteError && err.code === "SQLITE_BUSY") {
return c.json({ error: "DB busy, retry" }, 503);
}
throw err;
});
export default { port: 3000, fetch: app.fetch };외부 의존성은 hono 하나뿐입니다. DB 클라이언트, 연결 풀, ORM은 붙지 않습니다. 인프로세스 접근이라 네트워크 왕복이 없어 레이턴시가 낮은 경향이 있지만, 실제 값은 쿼리 복잡도와 인덱스 유무에 좌우되므로 자기 워크로드에서 측정한 값이 있어야 의미가 있습니다.
CLI 도구에서의 동기 API
bun:sqlite의 API는 동기적이라 CLI 스크립트와 궁합이 좋습니다.
#!/usr/bin/env bun
import { Database } from "bun:sqlite";
const db = new Database(`${process.env.HOME}/.mytool/history.db`);
db.run("PRAGMA journal_mode = WAL");
db.run(`
CREATE TABLE IF NOT EXISTS history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
command TEXT NOT NULL,
ran_at TEXT DEFAULT (datetime('now'))
)
`);
const command = process.argv.slice(2).join(" ");
if (command === "list") {
const rows = db
.query("SELECT command, ran_at FROM history ORDER BY ran_at DESC LIMIT 20")
.all() as { command: string; ran_at: string }[];
for (const row of rows) console.log(`[${row.ran_at}] ${row.command}`);
} else if (command) {
db.query("INSERT INTO history (command) VALUES ($cmd)").run({ $cmd: command });
console.log(`기록됨: ${command}`);
}bun build --compile로 묶으면 SQLite 드라이버까지 포함한 단일 바이너리가 만들어져 배포가 간단합니다.
동시 쓰기와 SQLITE_BUSY 다루기
WAL 모드는 리더-라이터 간섭을 없애지만, 두 개 이상의 쓰기 트랜잭션이 동시에 진행될 수는 없습니다. Hono 서버가 여러 요청을 병렬로 받고 각 요청이 쓰기를 시도하면 잠금 경합이 발생하며, busy_timeout 안에 잠금을 획득하지 못하면 SQLITE_BUSY가 반환됩니다.
실무적 대응은 세 가지 층에서 조합할 수 있습니다.
- PRAGMA
busy_timeout: SQLite 내부 재시도로 짧은 경합을 흡수합니다. 위 예제의 5초는 안전 여유값이며, 실제로는 요청 SLA에 맞춰 낮게 잡는 편이 응답 지연을 줄이는 데 유리합니다. - 애플리케이션 레벨 재시도:
SQLITE_BUSY발생 시 지수 백오프로 몇 차례 다시 시도합니다. 멱등성 있는 쓰기에만 안전하게 적용할 수 있습니다. - 직렬화 큐: 쓰기 요청을 단일 큐로 모아 순차 처리합니다. 프로세스 내 async queue(예: p-queue)나 인메모리 채널로 구현할 수 있고, 경합 자체를 없애기 때문에 예측 가능한 지연이 나옵니다.
읽기 요청은 이 경합의 영향을 받지 않으므로, "쓰기는 직렬 큐, 읽기는 자유"라는 단순한 분리만으로도 대부분의 로직이 정리됩니다.
WAL 체크포인트를 다루는 방식
-wal 파일은 SQLite가 조건에 따라 자동으로 체크포인트를 수행하지만, 트래픽이 몰릴 때는 계속 커질 수 있습니다. 수동 체크포인트를 걸어두는 편이 안전한데, 모드 선택과 실패 처리를 함께 고려해야 합니다.
type CheckpointResult = { busy: number; log: number; checkpointed: number };
function checkpoint(mode: "PASSIVE" | "TRUNCATE" = "PASSIVE") {
const row = db
.query(`PRAGMA wal_checkpoint(${mode})`)
.get() as CheckpointResult;
if (row.busy !== 0) {
console.warn(`체크포인트 미완료 (mode=${mode}): busy=${row.busy}`);
}
return row;
}
setInterval(() => checkpoint("PASSIVE"), 5 * 60 * 1000);
setInterval(() => checkpoint("TRUNCATE"), 60 * 60 * 1000);PASSIVE는 활성 리더/라이터를 방해하지 않고 가능한 만큼만 병합합니다. 실패해도 다음 주기에 재시도하면 되므로 상시 호출에 적합합니다.TRUNCATE는 병합 후 WAL 파일을 잘라 디스크를 회수하지만, 활성 리더가 있으면 대기하거나busy > 0을 반환합니다. 트래픽 피크 시 시간 기반으로 호출하면 대기가 길어질 수 있으니, 유휴 시간대에 실행하거나 결과의busy필드를 확인해 재시도하는 편이 안전합니다.PRAGMA wal_autocheckpoint = N값(기본 1000 페이지)도 병행해서 조정하면 자동 체크포인트 주기를 제어할 수 있습니다.
이 스택이 맞는 경우, 넘어가야 하는 경우
"local-first"라는 표현은 Ink & Switch의 정의처럼 데이터가 클라이언트 디바이스에 1차 저장되고 CRDT 등으로 동기화되는 아키텍처를 가리키는 용어입니다. 이 글이 다루는 것은 그 패턴이 아니라 단일 서버에 임베디드 DB를 두는 구조이므로, 앞으로는 그렇게 부르는 편이 정확합니다.
| 항목 | 임베디드 SQLite 적합 | 다른 선택이 나은 경우 |
|---|---|---|
| 배포 형태 | 단일 프로세스, 단일 노드 | 다중 서버 수평 확장 |
| 워크로드 | 읽기 다수 + 쓰기 소수 | 지속적 동시 쓰기 경합 |
| 데이터 규모 | 수십 GB까지 안정적 | 수백 GB 이상, 파티셔닝 필요 |
| 팀 규모 | 1~3인, 초기 스타트업 | 전담 DBA 조직 존재 |
| 고가용성 | 백업 + 재시작으로 충분 | 자동 페일오버 필수 |
자주 겪는 함정
- 여러 프로세스가 같은 SQLite 파일에 동시 쓰기를 시도하는 구성. 단일 프로세스 원칙을 지키는 편이 안전합니다.
synchronous = OFF로 성능을 짜내려는 시도. 전원 손실 시 파일 자체가 손상될 수 있습니다.query.all()로 대량 결과를 뽑고 루프 안에서JSON.parse를 반복하는 패턴. SQLite가 아니라 JS 레이어가 병목이 됩니다.
운영 시 판단 기준
이 스택을 실제로 굴릴 때 반복해서 결정해야 하는 지점은 세 가지 정도로 좁혀집니다.
언제 Litestream 같은 복제 도구를 켤 것인가. 데이터가 사용자에게 노출되는 시점, 즉 "잃으면 곤란한" 상태부터입니다. 사이드 프로젝트 단계라도 사용자 계정이 생기면 Litestream으로 S3에 스트리밍 복제를 시작하는 편이 나중에 후회를 덜어줍니다. 복제 오버헤드는 WAL을 추적해 업로드하는 방식이라 쓰기 QPS가 극단적으로 높지 않다면 체감되지 않습니다.
WAL 체크포인트 주기는 무엇으로 정하는가. 두 신호를 보면 됩니다. -wal 파일 크기가 지속적으로 수백 MB를 넘거나, PRAGMA wal_checkpoint의 반환값에서 busy 필드가 지속적으로 0이 아니면 주기가 너무 길거나 모드 선택이 잘못된 것입니다. 상시 PASSIVE + 유휴 시간대 TRUNCATE 조합에서 시작해 파일 크기 그래프를 보고 조정하는 편이 실용적입니다.
언제 PostgreSQL로 넘어갈 것인가. 쓰기 경합을 큐로도 못 삼키는 시점, 데이터가 백업 윈도우 안에 복원되기 어려운 크기로 커지는 시점, 또는 애플리케이션을 여러 노드로 나눠야 하는 시점입니다. 그 전까지는 임베디드 SQLite로 얻는 단순함이 대체로 이깁니다.
bun:sqlite가 매력적인 이유는 극단적인 성능 배수가 아니라, 이 세 가지 판단을 미룰 수 있는 "충분히 좋은" 지점이 예상보다 뒤에 있다는 것입니다. 초기부터 PostgreSQL + 연결 풀 + ORM을 세팅하는 대신, TypeScript 파일 몇 개와 SQLite 파일 하나로 시작해 실제 부하가 나타날 때 넘어가도 늦지 않습니다.
참고 자료