Bun 1.2 내장 SQLite로 단일 인스턴스 백엔드의 세션·캐시 레이어를 다루는 법
프로젝트를 시작할 때마다 반복되는 패턴이 있었습니다. "세션 저장 어떻게 하지? → Redis 붙이자 → Upstash 가입 → 환경변수 설정 → 콜드 스타트 걱정..." 이 루프가 너무 익숙해서 당연하게 받아들이고 있었는데, Bun 1.2를 쓰기 시작하면서 생각이 바뀌었습니다.
먼저 용어 정리부터 짚고 갈게요. 이 글에서 다루는 대상은 Fly.io·Railway처럼 상시 기동되는 단일 컨테이너 위의 Bun 서버입니다. 엄밀히 말해 이것은 FaaS(AWS Lambda, Vercel Functions, Cloudflare Workers) 의미의 "서버리스 함수"는 아닙니다. FaaS 환경은 요청 사이에 프로세스가 동결되거나 재활용되기 때문에 인메모리 SQLite를 신뢰할 수 있는 스토어로 쓰기 어렵고, 또 Cloudflare Workers는 뒤에서 설명하듯 네이티브 바인딩 자체가 실행되지 않습니다. 그래서 이 글은 "외부 세션 스토어를 붙이는 대신 단일 프로세스 안에서 SQLite로 끝내는" 실무 패턴을 다룹니다.
Bun 1.2(2026년 7월 기준으로 이미 오래된 릴리스입니다)부터 bun:sqlite가 런타임에 내장되면서, npm install 없이 그리고 네트워크 왕복 없이 단일 프로세스 안에서 SQLite를 세션·캐시 스토어로 쓸 수 있게 되었습니다. 인메모리 모드(new Database(":memory:"))라면 단순 K-V TTL 캐시 용도로는 외부 Redis를 붙이지 않고도 충분히 대체할 수 있습니다(정렬된 집합·pub/sub·Lua 스크립트 같은 Redis의 고급 기능은 없습니다. 이 부분은 뒤에서 다시 짚습니다).
이 글에서 다루는 것은 세 가지입니다. 상시 기동 서버에서의 인메모리 세션 스토어, TTL 기반 결과 캐시(Cache Stampede 대응 포함), Fly.io 퍼시스턴트 볼륨 위의 파일 기반 세션. 그리고 어디서 멈춰야 하는지도 함께 정리했습니다.
왜 지금 SQLite-first를 다시 보는가
세션 = Redis라는 등식은 오래 유지된 상식이었지만, 최근 몇 년 사이 조금씩 흔들리고 있습니다.
Redis 라이선스 변경 이후 Valkey 같은 Redis 포크가 등장했고, Dragonfly처럼 Redis 프로토콜과 호환되지만 처음부터 C++로 재작성된 별도 프로젝트도 자리를 잡으면서 선택지가 복잡해졌습니다. 이런 분위기에서 "단일 인스턴스 서비스라면 굳이 외부 K-V 스토어를 두지 않아도 되지 않나"라는 질문이 다시 나오고 있고, Cloudflare D1이 엣지 런타임에서 SQLite 시맨틱을 노출하면서 SQLite에 대한 신뢰도도 올라간 상태입니다.
Node.js도 v22.5.0부터 node:sqlite를 실험적으로 내장했습니다. 런타임 레벨에서 SQLite를 품는 흐름은 Bun만의 이야기가 아닙니다.
bun:sqlite가 갖는 특징
bun:sqlite는 Zig로 작성된 네이티브 바인딩을 통해 동기식 인터페이스를 제공합니다. better-sqlite3의 API 설계를 계승하기 때문에 기존 Node.js 코드를 옮길 때 학습 비용이 낮습니다. 한 가지 실용적으로 마음에 드는 점은 db.query()가 컴파일된 SQL 바이트코드를 인스턴스 단위로 자동 캐시한다는 것입니다. 반면 db.prepare()는 자동 캐시를 사용하지 않기 때문에, 반복 실행이 필요한 문장은 모듈 상단에서 한 번 준비해 두고 재사용하는 패턴을 씁니다. 이 글의 코드는 모두 후자의 방식을 따릅니다.
성능은 워크로드에 따라 편차가 크기 때문에 여기서 "N배"라는 숫자를 단정하지는 않겠습니다. 실제 수치가 필요하면 Bun 공식 SQLite 문서와 Bun 1.2 릴리스 노트를 각자의 워크로드 조건과 함께 확인하기를 권합니다.
import { Database } from "bun:sqlite";
const db = new Database(":memory:");두 가지 모드, 두 가지 상황
SQLite를 세션·캐시 스토어로 쓰는 방식은 크게 두 갈래입니다.
인메모리 모드는 프로세스 수명과 동일한 휘발성 캐시입니다. 만료 있는 OTP 코드, 레이트 리미팅 카운터, 외부 API 응답 캐시처럼 잃어도 되는 데이터에 적합합니다. 파일 기반 모드는 영속 볼륨을 마운트해 재시작 후에도 데이터를 보존할 때 씁니다. 볼륨을 붙이지 않고 컨테이너 내부 경로에 두면 재배포마다 초기화된다는 점은 반드시 기억해야 합니다.
패턴 1: 상시 기동 서버의 인메모리 세션 스토어
단일 Bun 프로세스 안에서 JWT 보조 세션이나 임시 OTP 코드를 관리하는 패턴입니다.
import { Database } from "bun:sqlite";
const db = new Database(":memory:");
db.run(`
CREATE TABLE sessions (
id TEXT PRIMARY KEY,
data TEXT NOT NULL,
expires_at INTEGER NOT NULL
)
`);
const upsert = db.prepare(
"INSERT OR REPLACE INTO sessions VALUES ($id, $data, $expires)"
);
const get = db.prepare(
"SELECT data FROM sessions WHERE id = $id AND expires_at > $now"
);
const del = db.prepare("DELETE FROM sessions WHERE id = $id");
export function setSession(id: string, data: unknown, ttlMs: number) {
upsert.run({
$id: id,
$data: JSON.stringify(data),
$expires: Date.now() + ttlMs,
});
}
export function getSession<T>(id: string): T | null {
const row = get.get({ $id: id, $now: Date.now() }) as { data: string } | null;
return row ? (JSON.parse(row.data) as T) : null;
}
export function deleteSession(id: string) {
del.run({ $id: id });
}await가 없습니다. 처음 봤을 때 "이게 맞아?" 싶었는데, bun:sqlite의 동기식 설계가 N-API 오버헤드를 제거하기 위한 의도적 선택임을 이해하고 나서야 익숙해졌습니다.
만료 처리는 조회 쿼리에서 이미 expires_at > $now 조건으로 걸러내므로, 별도의 정리 작업이 없어도 논리적으로는 문제가 없습니다. 다만 인메모리 테이블이 무한히 커지는 것을 막고 싶을 때가 있습니다. 이때 흔히 쓰이는 setInterval 패턴은 상시 기동 서버(Fly.io/Railway의 컨테이너)에서는 잘 동작하지만, 요청 사이에 프로세스가 동결되는 진짜 FaaS 환경에서는 신뢰할 수 없다는 점을 유의해야 합니다.
const cleanup = db.prepare("DELETE FROM sessions WHERE expires_at <= $now");
setInterval(() => {
cleanup.run({ $now: Date.now() });
}, 60_000);FaaS 환경으로 옮겨야 한다면 이 정리 작업을 요청 처리 경로 안에서 확률적으로 실행하거나(예: 1/100 확률로 청소), 별도 크론에 위임하는 방식이 더 안전합니다.
패턴 2: TTL 결과 캐시와 Cache Stampede 대응
외부 API나 느린 DB 쿼리 결과를 SQLite에 저장해 두는 패턴입니다. 만료 체크를 SQL 조건으로 처리하는 게 핵심입니다.
import { Database } from "bun:sqlite";
const db = new Database(":memory:");
db.run(`
CREATE TABLE cache (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
expires_at INTEGER NOT NULL
)
`);
const cacheGet = db.prepare(
"SELECT value FROM cache WHERE key = $key AND expires_at > $now"
);
const cacheSet = db.prepare(
"INSERT OR REPLACE INTO cache VALUES ($key, $value, $expires)"
);가장 단순한 형태는 이렇게 됩니다.
export async function withCache<T>(
key: string,
ttlMs: number,
fetcher: () => Promise<T>
): Promise<T> {
const cached = cacheGet.get({ $key: key, $now: Date.now() }) as
| { value: string }
| null;
if (cached) return JSON.parse(cached.value) as T;
const result = await fetcher();
cacheSet.run({
$key: key,
$value: JSON.stringify(result),
$expires: Date.now() + ttlMs,
});
return result;
}여기서 짚고 넘어가야 할 문제가 하나 있습니다. SQL 실행은 동기지만 fetcher()는 비동기이기 때문에, 캐시가 만료된 순간 같은 키에 대한 여러 요청이 동시에 도착하면 전부 fetcher()를 호출해 버립니다. 이른바 Cache Stampede입니다. 저부하 서비스에서는 눈에 안 띄지만, 트래픽이 조금만 몰려도 백엔드를 두들기는 원인이 됩니다.
프로세스 내부에서만 방어한다면, 진행 중인 Promise를 키 단위로 붙잡아 두는 in-flight 맵으로 충분합니다.
const inflight = new Map<string, Promise<unknown>>();
export async function withCache<T>(
key: string,
ttlMs: number,
fetcher: () => Promise<T>
): Promise<T> {
const cached = cacheGet.get({ $key: key, $now: Date.now() }) as
| { value: string }
| null;
if (cached) return JSON.parse(cached.value) as T;
const existing = inflight.get(key) as Promise<T> | undefined;
if (existing) return existing;
const pending = (async () => {
try {
const result = await fetcher();
cacheSet.run({
$key: key,
$value: JSON.stringify(result),
$expires: Date.now() + ttlMs,
});
return result;
} finally {
inflight.delete(key);
}
})();
inflight.set(key, pending);
return pending;
}요청 시점의 흐름은 이렇게 정리됩니다.
패턴 3: Fly.io 퍼시스턴트 볼륨과 파일 모드
재시작 후에도 세션을 유지해야 한다면 파일 기반 모드와 퍼시스턴트 볼륨을 조합합니다. fly.toml에서 볼륨을 마운트하는 부분입니다.
[mounts]
source = "sqlite_data"
destination = "/data"Bun 코드에서는 마운트된 경로를 사용합니다.
import { Database } from "bun:sqlite";
import { mkdirSync } from "fs";
mkdirSync("/data", { recursive: true });
const db = new Database("/data/sessions.db");
db.run("PRAGMA journal_mode = WAL");
db.run("PRAGMA synchronous = NORMAL");
db.run(`
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
data TEXT NOT NULL,
expires_at INTEGER NOT NULL
)
`);CREATE TABLE IF NOT EXISTS를 쓰는 이유는 재시작 시 테이블이 이미 존재하기 때문입니다. 인메모리 모드와 달리 초기화 코드는 멱등성을 가져야 합니다. 처음에 이걸 빠뜨려서 재시작할 때마다 오류가 나던 경험이 있습니다.
한 가지 더 짚어야 할 것이 있습니다. WAL 모드를 켜면 SQLite는 /data/sessions.db 외에 /data/sessions.db-wal과 /data/sessions.db-shm 파일을 함께 만듭니다. 볼륨 백업 스크립트가 .db만 복사하도록 짜여 있다면 정합성이 깨질 수 있고, 데이터를 복사해서 이관할 때 이 두 파일을 빠뜨리면 마지막 체크포인트 이후의 쓰기가 유실됩니다. 백업 전에 PRAGMA wal_checkpoint(TRUNCATE)를 호출해 WAL을 본 파일로 병합한 뒤 복사하거나, 세 파일을 함께 스냅샷하는 방식을 권합니다. 참고로 NFS 마운트 위에서는 WAL 자체를 쓸 수 없으니 이런 스토리지를 붙일 계획이라면 미리 확인해야 합니다.
어디서 멈춰야 하는가
이 패턴이 맞는 상황과 맞지 않는 상황을 분명히 구분하는 게 이 글에서 가장 중요한 부분입니다.
| 항목 | 인메모리 모드 | 파일 기반 모드 | 비고 |
|---|---|---|---|
| 영속성 | 없음 (프로세스 수명) | 있음 (볼륨 의존) | |
| 수평 확장 | 불가 | 불가 | 멀티 인스턴스 간 공유 불가 |
| WAL 모드 | 해당 없음 | 가능 | NFS에서는 사용 불가 |
| Cloudflare Workers | 미지원 | 미지원 | D1으로 전환 필요 |
| 쓰기 동시성 | 단일 라이터 | 단일 라이터 | 쓰기 집중 워크로드에 병목 |
| 설정 복잡도 | 매우 낮음 | 낮음 (볼륨 설정 필요) |
멀티 인스턴스 환경에서는 적합하지 않습니다. 인스턴스가 여러 개로 스케일 아웃되면 각자 독립된 SQLite를 가지게 됩니다. 세션이 인스턴스 1에 저장됐는데 다음 요청이 인스턴스 2로 라우팅되면 세션을 찾지 못합니다. 이 지점에서는 Upstash Redis나 외부 DB가 맞습니다.
Cloudflare Workers에서는 bun:sqlite가 동작하지 않습니다. 네이티브 바인딩이 Workers 런타임에서 실행될 수 없기 때문입니다. Cloudflare 환경이라면 D1으로 전환해야 하는데, 여기서 한 가지 주의할 점이 있습니다. D1은 Promise 기반 비동기 API입니다. bun:sqlite·better-sqlite3의 동기식 코드를 D1으로 옮기는 것은 await 한 줄 붙이는 정도가 아니라, 트랜잭션 경계·에러 처리·제어 흐름을 다시 설계하는 작업에 가깝습니다. "언젠가 옮기면 되지"라는 가정을 두고 인터페이스를 설계할 계획이라면, 처음부터 비동기 시그니처로 감싸 두는 편이 낫습니다.
"Redis 수준의 캐시"라는 표현은 쓰지 않겠습니다. SQLite 인메모리는 단순 K-V TTL 캐시 용도로는 훌륭한 대체재지만, Redis의 pub/sub, 원자적 INCR/SETNX, 정렬된 집합, Lua 스크립팅 같은 프리미티브는 없습니다. 실시간 랭킹, 분산 락, 이벤트 팬아웃 같은 요구가 있다면 여전히 Redis(또는 대체 서버) 쪽이 맞습니다.
자주 부딪히는 실수: 배포 시 데이터가 날아가는 경우
Fly.io나 Railway에서 SQLite 파일을 영속 볼륨 없이 컨테이너 내부 경로에 두면, 재배포할 때마다 데이터가 초기화됩니다. 처음 겪으면 꽤 당황스럽습니다. 볼륨 마운트 경로와 초기화 스크립트의 멱등성은 선택이 아닙니다.
의사결정 흐름
정리하면 이렇게 됩니다.
단일 인스턴스이고, Cloudflare Workers가 아니며, 단순 K-V TTL 캐시로 충분한 요구라면 bun:sqlite는 외부 스토어 없이도 충분한 세션·캐시 레이어를 제공합니다. 제로 의존성, 제로 네트워크 왕복, 동기식 API. 세팅할 게 적으니 틀릴 것도 적습니다.
다음으로 해볼 만한 것
이 글을 읽고 무엇을 해볼지 하나만 고른다면, 지금 운영 중인 서비스의 세션·캐시 코드에서 실제로 필요한 Redis 프리미티브를 나열해 보는 것을 권합니다. 목록이 SET/GET/EXPIRE/DEL 정도로만 끝난다면, bun:sqlite 인메모리 모드로 옮겨 보고 인프라 한 조각을 덜어낼 좋은 후보입니다. 반대로 INCR나 ZADD, pub/sub이 들어 있다면 이 글의 패턴은 맞지 않으니 무리해서 이식하지 않는 편이 낫습니다.