bun:sqlite와 WAL 모드로 상주 엣지 프로세스에서 외부 DB 왕복 없애기
서버리스나 엣지 함수에서 외부 데이터베이스를 쓰다 보면 콜드스타트 때마다 아찔한 경험을 하게 됩니다. 런타임 초기화 시간에 DB 연결 풀 재설정, 여기에 지리적으로 떨어진 DB까지의 네트워크 왕복이 겹치면 첫 요청이 수백 ms에서 1초 가까이 밀리는 상황이 벌어집니다. 저도 처음엔 그냥 "어쩔 수 없는 세금"이라 여기고 넘어갔는데, Bun 1.2와 bun:sqlite를 써보면서 생각이 꽤 달라졌습니다.
bun:sqlite는 별도의 데이터베이스 서버 프로세스 없이 HTTP 서버와 SQLite가 동일 프로세스에서 동작합니다. DB 연결이 사실상 로컬 파일 핸들이라 연결 수립 시간 자체가 없고, WAL 모드를 올리면 읽기와 쓰기가 서로 블로킹하지 않아 단일 인스턴스 안에서도 꽤 괜찮은 동시성을 확보할 수 있습니다.
이 글에서는 bun:sqlite의 동작 원리부터 WAL 모드의 실제 효과, 코드 패턴, 그리고 언제 PostgreSQL이나 분산 SQLite로 갈아타야 하는지까지 다룹니다. 상주 프로세스형 엣지 런타임(Fly.io Machine, 자체 컨테이너 등)에서 외부 DB 의존을 줄이려는 분이라면 읽어볼 만합니다.
먼저 짚어야 할 것 — "서버리스"와 "엣지"는 다르다
이 글의 접근이 유효한 실행 환경을 먼저 못박고 시작하겠습니다. bun:sqlite로 로컬 파일에 데이터를 쓰려면 프로세스가 상주하면서 지속적인 파일시스템을 갖고 있어야 합니다.
- AWS Lambda나 Vercel Functions처럼 완전 에페메럴한 실행 환경에서는 SQLite 파일이 함수 종료와 함께 사라집니다.
/tmp에 쓸 수는 있지만 인스턴스 사이에 공유되지 않고, 언제 사라질지도 보장되지 않습니다. - 반면 Fly.io Machines처럼 볼륨이 붙는 상주 프로세스, 자체 컨테이너 오케스트레이션, 또는 로컬 상태를 갖는 엣지 워크로드에서는 정상적으로 동작합니다.
- Cloudflare Workers는 D1이나 Durable Objects SQLite를 별도로 제공하는 구조이므로 이 글에서 말하는
bun:sqlite파일 접근과는 결이 다릅니다.
즉 이 글은 "람다형 서버리스에서 마법처럼 콜드스타트가 사라진다"는 이야기가 아니라, 상주 프로세스로 돌아가는 엣지·경량 백엔드에서 외부 DB로 나가는 왕복을 걷어내자는 얘기입니다.
SQLite가 다시 프로덕션 무대에 오르는 이유
임베디드 DB라는 인식이 바뀌고 있다
저도 얼마 전까지는 SQLite를 테스트·프로토타입용으로만 여겼습니다. 그런데 최근 몇 년 사이 Turso(libSQL), Cloudflare D1, Fly.io LiteFS가 나란히 프로덕션 성숙도에 도달하면서 WAL 기반 복제를 활용한 분산 SQLite가 진지한 선택지가 됐습니다. 일부 커뮤니티에서 "Post-PostgreSQL" 같은 표현이 돌기도 하는데, 이건 좀 과장된 마케팅 어휘에 가깝고 실제로는 "특정 워크로드에서 SQLite가 다시 매력적인 선택지가 됐다" 정도로 보는 게 정확합니다.
로컬 파일 접근과 원격 DB 왕복은 애초에 다른 층위
외부 DB를 쓰는 구조의 가장 큰 비용은 DB 엔진 자체가 아니라 네트워크 왕복과 연결 관리입니다. 로컬 PostgreSQL도 마이크로초 수준으로 응답합니다. 문제는 서버리스·엣지에서 흔히 쓰는 원격 관리형 DB가 지리적으로 떨어져 있어, 왕복 자체가 수십 ms를 잡아먹는다는 점입니다.
bun:sqlite의 로컬 퍼스트 접근은 이 왕복 층을 통째로 제거합니다.
Bun 런타임 자체의 프로세스 기동 시간은 약 8ms 수준으로 보고됩니다. 다만 이 8ms는 Bun 프로세스 자체의 시작 시간이지, bun:sqlite를 쓴다고 애플리케이션 전체 콜드스타트가 8ms가 된다는 뜻은 아닙니다. 실제 서비스 콜드스타트는 여기에 서버 초기화·스키마 마이그레이션·워밍업 쿼리 시간이 얹힙니다. 그럼에도 외부 DB 커넥션 수립 왕복이 사라지는 것 자체가 상당한 절감입니다.
bun:sqlite와 WAL 모드 — 핵심 개념
bun:sqlite가 better-sqlite3와 다른 점
bun:sqlite는 better-sqlite3에서 영감을 받은 동기식 API를 제공합니다. npm install 없이 import { Database } from "bun:sqlite" 한 줄이면 되고, 네이티브 애드온이 아니라 Bun 바이너리에 정적으로 링크되어 있어서 패키지 호환성 문제가 생길 여지가 없습니다.
성능에 대해 흔히 인용되는 **"better-sqlite3 대비 46배, Deno SQLite 대비 89배"**는 Bun 1.2 소개 커뮤니티 벤치마크에서 나온 수치입니다. 다만 쿼리 종류·데이터 규모·하드웨어 조건이 공개되어 있지 않은 커뮤니티 측정이라, 재현할 때는 자기 워크로드에서 직접 재보는 게 좋습니다. API 자체는 동기식이지만 로컬 파일 I/O 위에서 도는 구조라 대부분의 실제 워크로드에서 문제가 되지 않습니다.
WAL 모드가 필요한 이유
SQLite 기본 저널 모드(롤백 모드)에서는 쓰기 중에 읽기가 블로킹됩니다. 동시 요청이 들어오는 서버 환경에서는 이게 병목이 됩니다. WAL(Write-Ahead Logging)은 변경 사항을 WAL 파일에 먼저 기록하고 체크포인트 시점에 메인 DB 파일에 반영하는 방식으로, 읽기와 쓰기가 서로 블로킹하지 않습니다.
Bun 공식 문서 기준으로 WAL 모드에서 약 70,000 reads/s · 3,600 writes/s를 낼 수 있다고 언급합니다. 활성화는 단 한 줄입니다.
import { Database } from "bun:sqlite";
const db = new Database("app.db");
db.run("PRAGMA journal_mode = WAL");주의할 점: WAL은 파일시스템 상의 WAL 파일에 의존합니다. :memory: DB에는 WAL을 걸어도 SQLite가 무시하고 memory 모드를 유지합니다. 인메모리에서는 WAL을 설정할 이유 자체가 없습니다.
세팅부터 고처리량 패턴까지
기본 세팅
import { Database } from "bun:sqlite";
const db = new Database("./data/app.db", { create: true });
db.run("PRAGMA journal_mode = WAL");
db.run("PRAGMA synchronous = NORMAL");
db.run("PRAGMA foreign_keys = ON");
db.run(`
CREATE TABLE IF NOT EXISTS events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
type TEXT NOT NULL,
payload TEXT,
created_at INTEGER DEFAULT (unixepoch())
)
`);PRAGMA synchronous = NORMAL은 성능을 올려주지만 OS 크래시 상황에서 직전 트랜잭션이 손실될 수 있습니다. 데이터 내구성이 최우선이라면 기본값인 FULL을 유지하는 편이 안전합니다.
wal_autocheckpoint는 자주 튜닝 예시로 등장하지만 기본값이 1000 페이지이므로 그 값 그대로 설정하는 건 의미가 없습니다. 실제 조정은 체크포인트 빈도를 늘리거나(예: 매우 쓰기 헤비한 워크로드에서 값을 낮춤) 줄일 필요가 있을 때만 하는 게 맞습니다.
Bun HTTP 서버와 통합
import { Database } from "bun:sqlite";
const db = new Database("./data/app.db", { create: true });
db.run("PRAGMA journal_mode = WAL");
const insertEvent = db.query(
"INSERT INTO events (type, payload) VALUES ($type, $payload) RETURNING id"
);
const getRecentEvents = db.query(
"SELECT * FROM events ORDER BY created_at DESC LIMIT $limit"
);
Bun.serve({
port: 3000,
async fetch(req) {
const url = new URL(req.url);
if (req.method === "POST" && url.pathname === "/events") {
const body = await req.json();
const result = insertEvent.get({
$type: body.type,
$payload: JSON.stringify(body.payload),
});
return Response.json(result);
}
if (req.method === "GET" && url.pathname === "/events") {
const limit = Number(url.searchParams.get("limit") ?? "20");
const events = getRecentEvents.all({ $limit: limit });
return Response.json(events);
}
return new Response("Not Found", { status: 404 });
},
});db.query()는 동일한 SQL 문자열에 대해 PreparedStatement를 재사용하도록 캐싱합니다. 매 요청마다 파싱 비용이 다시 발생하지 않습니다.
대량 삽입은 트랜잭션이 핵심
트랜잭션 없는 개별 INSERT는 각 커밋마다 fsync가 발생해 매우 느립니다(synchronous가 FULL이든 NORMAL이든 커밋 지점에서 디스크 동기화 비용이 붙습니다). db.transaction()으로 묶으면 하나의 커밋으로 처리되어 밀리초 수준까지 떨어집니다.
interface LogEntry {
level: string;
message: string;
ts: number;
}
const insertLog = db.query(
"INSERT INTO logs (level, message, ts) VALUES ($level, $message, $ts)"
);
const bulkInsert = db.transaction((entries: LogEntry[]) => {
for (const entry of entries) {
insertLog.run({
$level: entry.level,
$message: entry.message,
$ts: entry.ts,
});
}
});
bulkInsert(largeLogBatch);인메모리 DB로 프로세스 내 상태 관리
외부 Redis 없이 인스턴스 안에서 잠깐 쓸 캐시나 세션 상태를 관리할 때 유용합니다. 앞서 언급했듯 :memory:에는 WAL이 적용되지 않으므로 걸지 않습니다.
import { Database } from "bun:sqlite";
const cache = new Database(":memory:");
cache.run(`
CREATE TABLE kv (
key TEXT PRIMARY KEY,
value TEXT,
expires_at INTEGER
)
`);
const setCache = cache.query(
"INSERT OR REPLACE INTO kv (key, value, expires_at) VALUES ($key, $value, $exp)"
);
const getCache = cache.query(
"SELECT value FROM kv WHERE key = $key AND (expires_at IS NULL OR expires_at > unixepoch())"
);
function set(key: string, value: unknown, ttlSeconds?: number) {
setCache.run({
$key: key,
$value: JSON.stringify(value),
$exp: ttlSeconds ? Math.floor(Date.now() / 1000) + ttlSeconds : null,
});
}
function get<T>(key: string): T | null {
const row = getCache.get({ $key: key }) as { value: string } | null;
return row ? JSON.parse(row.value) : null;
}Drizzle ORM 연동
타입 안전성이 필요하다면 Bun 문서에서도 언급하는 Drizzle ORM을 얹을 수 있습니다.
import { drizzle } from "drizzle-orm/bun-sqlite";
import { Database } from "bun:sqlite";
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core";
import { desc } from "drizzle-orm";
const sqlite = new Database("app.db");
sqlite.run("PRAGMA journal_mode = WAL");
const db = drizzle(sqlite);
const events = sqliteTable("events", {
id: integer("id").primaryKey({ autoIncrement: true }),
type: text("type").notNull(),
payload: text("payload"),
createdAt: integer("created_at", { mode: "timestamp" }),
});
const recent = db
.select()
.from(events)
.orderBy(desc(events.createdAt))
.limit(10)
.all();트레이드오프 — 어떤 워크로드에 맞고 어디서 갈아타야 하는가
정성적 비교
| 항목 | 로컬 bun:sqlite (WAL) | 원격 관리형 DB |
|---|---|---|
| 접근 경로 | 동일 프로세스 파일 핸들 | 네트워크 왕복 |
| 커넥션 수립 | 없음 (파일 오픈) | TCP + 인증 라운드트립 |
| 처리량 상한 (WAL) | ~70,000 reads/s · ~3,600 writes/s | DB 스펙과 링크 대역에 의존 |
| 의존성 | Bun 런타임 내장 | 드라이버·풀·네트워크 |
| 스케일아웃 | 단일 인스턴스 중심 | 자연스러운 다중 인스턴스 공유 |
수치는 DB 엔진 자체 성능이라기보다 접근 경로(로컬 파일 vs 네트워크)의 차이로 읽는 게 정확합니다. 원격이 아닌 동일 노드에 얹힌 PostgreSQL이라면 SQLite와 지연시간 자체는 크게 다르지 않습니다.
WAL 모드의 조건부 단점
WAL이 항상 유리하지는 않습니다. 단일 클라이언트에서 쓰기 전용 워크로드가 이어지는 경우, WAL 파일 관리 오버헤드 때문에 롤백 모드가 오히려 유리한 상황도 있습니다. 다만 얼마나 차이가 나는지는 SQLite 공식 WAL 문서의 '단점' 절과 자기 워크로드 실측을 함께 보고 판단하는 게 낫습니다.
플랫폼별 동작 차이도 존재합니다. Bun은 macOS에서는 시스템 SQLite를, Linux에서는 자체 빌드한 SQLite를 정적 링크해 사용합니다. 그래서 SQLite 버전이 달라질 수 있고, 이로 인해 PRAGMA 기본값이나 확장 지원 여부가 다를 수 있으므로 프로덕션 플랫폼에서 실제로 확인해봐야 합니다. (참고로 WAL 파일 유지 정책 자체는 플랫폼 무관하게 표준 SQLite 동작을 따르며, 완전한 체크포인트가 끝날 때까지 유지됩니다.)
적합/부적합 워크로드
| 잘 맞는 경우 | 피해야 할 경우 |
|---|---|
| 엣지 상주 프로세스의 로컬 캐시 | 여러 컨테이너가 동일 파일 공유 필요 |
| 인스턴스 내부 임시 상태 | 다중 쓰기 프로세스 |
| 읽기 헤비 API | 대형 데이터셋(수십 GB 이상) |
| 상주형 작업 큐 상태 저장 | 엄격한 RPO를 요구하는 결제·회계 도메인 |
| 오프라인 퍼스트 클라이언트 | 에페메럴 서버리스 실행 환경(Lambda 등) |
자주 보는 실수
트랜잭션 없이 루프 삽입. 각 커밋마다 fsync가 발생해 처리량이 수십 배 차이 납니다. db.transaction()으로 감싸주세요.
WAL 체크포인트 방치. WAL 파일이 무한정 커지면 성능이 떨어집니다. 트래픽이 낮은 시간대에 PRAGMA wal_checkpoint(TRUNCATE)를 수동으로 돌리거나, 워크로드 특성에 맞게 wal_autocheckpoint 값을 조정하세요.
synchronous = OFF 남용. OS 크래시 시 DB 파일 자체가 손상될 수 있습니다. NORMAL까지는 허용 범위, OFF는 피하는 게 좋습니다.
단일 노드를 넘어서야 할 때
bun:sqlite의 한계는 명확합니다. 단일 쓰기 프로세스만 허용하고, 여러 컨테이너가 동일 파일을 공유할 수 없습니다. 수평 확장이 필요해지면 두 가지 방향이 있습니다.
Turso(libSQL) — SQLite의 오픈소스 포크로, WAL 스트리밍 복제를 얹었습니다. 임베디드 레플리카 모드에서는 bun:sqlite와 유사하게 로컬에서 읽기를 처리하면서 쓰기만 원격 primary로 보내는 구조가 가능합니다.
Cloudflare D1 — Workers 생태계에 통합된 SQLite입니다. 다만 용량 상한이 있고 Workers 런타임에 종속됩니다.
bun:sqlite에서 libSQL 계열로의 이전은 API 형태가 유사해서 마이그레이션 부담이 상대적으로 낮습니다. 초기부터 분산 옵션을 도입하기보다는 bun:sqlite로 시작하고 병목이 실제로 관측될 때 이전하는 전략도 충분히 합리적입니다.
어떤 신호가 오면 갈아탈 것인가
정리하면, bun:sqlite는 "SQLite를 빠르게 쓸 수 있다" 정도가 아니라, 상주형 엣지 프로세스에서 외부 DB 왕복을 걷어내는 아키텍처 선택지로 볼 만합니다. WAL 모드가 단일 노드 내 읽기·쓰기 동시성을 확보해주고, db.transaction()을 제대로 쓰면 대량 삽입도 충분히 소화합니다.
실무에서 판단이 어렵다면 이렇게 트리거를 잡아두면 편합니다.
- 지금 도입 검토 — 상주 프로세스이고, 단일 노드에서 읽기 헤비이며, 데이터 규모가 예측 가능한 범위(수 GB 이내)일 때.
- 분산 SQLite(Turso/D1)로 이전 신호 — 멀티 리전 읽기 지연이 사용자에게 체감되기 시작하거나, 임베디드 레플리카가 있으면 해결될 트래픽 패턴이 생겼을 때.
- 관리형 PostgreSQL로 이전 신호 — 다중 인스턴스가 동일 쓰기 경로를 공유해야 하거나, 쓰기 QPS가 단일 프로세스 한계에 다가가거나, 데이터셋이 수십 GB 규모로 커질 때.
- 애초에 다른 선택이 나은 경우 — AWS Lambda류 완전 에페메럴 실행 환경, 엄격한 RPO를 요구하는 도메인.
레퍼런스로는 Trigger.dev가 Node.js에서 Bun으로 옮기며 처리량 개선을 보고한 사례가 종종 인용됩니다. 수치가 자기 워크로드에 그대로 적용된다는 보장은 없으니, 판단은 항상 자기 트래픽 패턴 기준으로 실측해보시길 권합니다.