Redis 없이 SQLite로 캐시 레이어 만들기 — bun:sqlite로 WAL·TTL·커넥션 분리를 한 파일에
프로젝트 초기에 Redis를 붙이고 나면, 어느 순간 그게 당연한 줄 알게 됩니다. 캐시라는 개념 자체가 Redis와 동의어처럼 느껴지기 시작하는 거죠. 저도 그랬습니다. 그러다 1인 프로젝트에서 Redis 인스턴스 유지 비용이 서버 비용보다 비싸진 걸 발견했고, 단일 서버에서 초당 수백 건을 처리하는 API가 매번 네트워크를 거쳐 Redis를 조회하는 게 정말 맞는 선택인지 의문이 들었습니다.
Bun 1.2부터 bun:sqlite가 런타임에 직접 내장되면서, 이 고민에 다른 선택지가 생겼습니다. 이 글에서는 외부 인프라 없이 파일 하나짜리 SQLite 캐시를 밑바닥부터 짜보고, 그 과정에서 마주친 함정도 솔직히 정리해보겠습니다.
왜 지금 SQLite가 캐시 후보로 다시 올라왔는가
런타임이 드라이버를 직접 품기 시작했다
솔직히 몇 년 전만 해도 "SQLite로 캐시?"라고 하면 반응이 시큰둥했습니다. better-sqlite3를 설치하고 node-gyp 빌드가 실패해서 한 시간을 날려본 경험을 한두 번 하면 자연스레 포기하게 되는 흐름이었죠.
그런데 2026년 기준으로 상황이 달라졌습니다. Bun 1.2가 bun:sqlite를 일급 시민으로 탑재했고, Node.js도 22.5.0부터 node:sqlite를 실험적으로 도입했습니다. 런타임 내장 SQLite가 서버사이드 JavaScript에서 흔한 선택지가 된 셈입니다.
Bun의 bun:sqlite는 Zig로 구현되어 있고, API는 better-sqlite3에서 영감을 받아 동기(synchronous) 스타일이라 익숙한 사람이라면 거의 배움 없이 쓸 수 있습니다. 정확한 성능 수치는 Bun 공식 SQLite 문서에서 자기 환경에 맞춰 확인하는 걸 권합니다. 벤치마크는 워크로드마다 달라서, 남의 숫자보다 자기 앱에서 직접 재보는 게 훨씬 유용합니다.
Redis-free 아키텍처가 늘어나는 이유
외부 Redis 없이 SQLite 기반 로컬 캐시를 택하는 사례가 늘어나는 데는 뚜렷한 이유가 있습니다.
| 이유 | 설명 |
|---|---|
| 인프라 비용·운영 부담 | Redis 인스턴스 관리, failover, 모니터링이 사라집니다 |
| 네트워크 왕복 없는 조회 | 같은 프로세스 내 SQLite 조회는 네트워크 RTT가 없습니다 |
| 재시작 후 캐시 유지 | 인메모리 캐시와 달리 프로세스가 죽어도 캐시 데이터가 남습니다 |
Redis를 완전히 대체한다는 뜻은 아닙니다. 다중 서버 간 캐시 공유가 필요하거나 쓰기 경합이 극단적으로 많은 워크로드에서는 여전히 Redis가 맞습니다. 이 글은 "단일 서버 또는 단일 머신 멀티 프로세스" 범위를 전제로 합니다.
아키텍처 설계 — 세 가지 핵심 결정
본격적으로 코드를 짜기 전에, 설계상 반드시 이해해야 할 세 가지가 있습니다.
결정 1: WAL 모드를 반드시 켜야 하는 이유
SQLite 기본 저널 모드(DELETE)에서는 쓰기가 진행될 때 읽기가 차단됩니다. 캐시처럼 읽기가 압도적으로 많은 워크로드에서 이건 치명적입니다.
WAL(Write-Ahead Logging) 모드는 변경 사항을 .db-wal 별도 파일에 먼저 기록하고, 나중에 메인 DB 파일에 통합합니다. 핵심은 독자(Reader)가 작성자(Writer)를 차단하지 않고, 작성자도 독자를 차단하지 않는다는 점입니다. 다수의 동시 읽기 트랜잭션과 단일 쓰기 트랜잭션을 지원합니다.
체크포인트 주기는 wal_autocheckpoint PRAGMA로 조정할 수 있고, 기본값은 1000페이지입니다(고정 동작이 아니라 언제든 바꿀 수 있습니다). 활성화 자체는 한 줄입니다.
db.run("PRAGMA journal_mode = WAL");WAL을 켰다고 끝이 아닙니다. 복수 프로세스나 복수 커넥션이 동시에 쓰기를 시도하면 SQLite는 기본적으로 SQLITE_BUSY를 즉시 반환합니다. 이걸 재시도로 흡수하려면 PRAGMA busy_timeout을 함께 설정해야 합니다. 아래 createCacheDB에 포함시킬 겁니다.
결정 2: Write 커넥션과 Read 커넥션을 분리
이게 저도 처음엔 헷갈렸던 부분입니다. SQLite는 동시 쓰기 트랜잭션이 하나만 허용됩니다. 여러 커넥션이 모두 쓰기를 시도하면 SQLITE_BUSY가 빈발합니다.
Evan Schwartz의 글에서 잘 설명하듯이, 최적 패턴은 단일 Write 커넥션 + 별도 Read 커넥션입니다. Write는 애플리케이션 레벨에서 순차 처리하고, Read는 별도 커넥션에서 처리합니다.
한 가지 짚고 넘어가겠습니다. 이 글의 부제에서 "커넥션 풀"이라는 표현을 뺀 이유가 여기 있습니다. 흔히 "connection pooling"이라고 부르는 구조는 동일 역할 커넥션을 여러 개 유지하며 대여/반납하는 방식인데, SQLite에서 이걸 쓰기 쪽에 그대로 적용하면 오히려 성능이 나빠집니다. 그래서 이 글은 "풀링"이 아니라 커넥션 분리(connection separation) 로 접근합니다. Read 커넥션을 여러 개 유지하는 진짜 풀 구현도 가능하지만, 단일 프로세스 이벤트 루프에서 동기 API를 쓰는 이상 실질적 이득은 크지 않습니다.
결정 3: TTL 만료는 별도 타이머 없이 쿼리로 처리
백그라운드 워커나 setInterval로 만료 항목을 지우는 방식보다, 조회 쿼리 자체에 만료 조건을 넣는 게 훨씬 깔끔합니다. SQLite의 unixepoch() 함수를 활용하면 됩니다.
-- 조회 시 만료 자동 필터링
SELECT value FROM cache
WHERE key = ? AND (expires_at IS NULL OR expires_at > unixepoch());
-- 별도 정리 쿼리 (호출 시점은 뒤에서 논의)
DELETE FROM cache WHERE expires_at <= unixepoch();구현 — 단계별로 실제 코드
기반 세팅: 데이터베이스 초기화
// cache-db.ts
import { Database } from "bun:sqlite";
function createCacheDB(path: string = "./cache.db") {
const db = new Database(path);
db.run("PRAGMA journal_mode = WAL");
db.run("PRAGMA synchronous = NORMAL");
db.run("PRAGMA busy_timeout = 5000");
db.run("PRAGMA cache_size = 10000");
db.run("PRAGMA temp_store = MEMORY");
db.run(`
CREATE TABLE IF NOT EXISTS cache (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
expires_at INTEGER,
created_at INTEGER NOT NULL DEFAULT (unixepoch())
)
`);
db.run(`
CREATE INDEX IF NOT EXISTS idx_cache_expires_at
ON cache(expires_at) WHERE expires_at IS NOT NULL
`);
return db;
}
export { createCacheDB };PRAGMA synchronous = NORMAL은 캐시 전용 트레이드오프입니다. OS 크래시나 전원 차단 시 마지막 트랜잭션이 유실될 수 있는데, 캐시라면 어차피 다시 채워지므로 허용할 만합니다. 하지만 이 설정을 원본 데이터가 저장되는 일반 DB에 그대로 복사하면 데이터가 실제로 사라질 수 있으니 조심해야 합니다.
busy_timeout = 5000은 SQLite가 lock을 잡지 못했을 때 즉시 실패하지 않고 최대 5초까지 재시도하도록 만듭니다. 단일 머신 멀티 프로세스나 짧은 쓰기 경합 상황에서 SQLITE_BUSY 예외를 크게 줄여줍니다.
Write/Read 커넥션 분리 및 캐시 클래스
Read 커넥션은 readonly: true로 여는데, 이때 파일과 스키마가 이미 존재해야 합니다. 그래서 생성자 안에서도 반드시 createCacheDB()(쓰기 커넥션)를 먼저 호출해 스키마를 만든 뒤에 Read 커넥션을 열어야 합니다.
// cache.ts
import { Database, Statement } from "bun:sqlite";
import { createCacheDB } from "./cache-db";
interface CacheOptions {
ttl?: number;
}
class SQLiteCache {
private writeDB: Database;
private readDB: Database;
private stmtGet: Statement;
private stmtSet: Statement;
private stmtDelete: Statement;
private stmtCleanup: Statement;
private stmtCheckpoint: Statement;
private writesSinceCleanup = 0;
private readonly cleanupEvery: number;
constructor(path: string = "./cache.db", cleanupEvery = 1000) {
this.writeDB = createCacheDB(path);
this.readDB = new Database(path, { readonly: true });
this.cleanupEvery = cleanupEvery;
this.stmtGet = this.readDB.prepare(`
SELECT value FROM cache
WHERE key = ? AND (expires_at IS NULL OR expires_at > unixepoch())
`);
this.stmtSet = this.writeDB.prepare(`
INSERT OR REPLACE INTO cache (key, value, expires_at)
VALUES (?, ?, ?)
`);
this.stmtDelete = this.writeDB.prepare(`
DELETE FROM cache WHERE key = ?
`);
this.stmtCleanup = this.writeDB.prepare(`
DELETE FROM cache
WHERE expires_at IS NOT NULL AND expires_at <= unixepoch()
`);
this.stmtCheckpoint = this.writeDB.prepare("PRAGMA wal_checkpoint(TRUNCATE)");
}
get<T = unknown>(key: string): T | null {
const row = this.stmtGet.get(key) as { value: string } | null;
if (!row) return null;
return JSON.parse(row.value) as T;
}
set(key: string, value: unknown, options: CacheOptions = {}): void {
const expiresAt = options.ttl
? Math.floor(Date.now() / 1000) + options.ttl
: null;
this.stmtSet.run(key, JSON.stringify(value), expiresAt);
this.writesSinceCleanup++;
if (this.writesSinceCleanup >= this.cleanupEvery) {
this.cleanup();
this.writesSinceCleanup = 0;
}
}
delete(key: string): void {
this.stmtDelete.run(key);
}
cleanup(): void {
this.stmtCleanup.run();
}
checkpoint(): void {
this.stmtCheckpoint.run();
}
close(): void {
this.writeDB.close();
this.readDB.close();
}
}
export { SQLiteCache };Prepared statement 필드 타입은 bun:sqlite가 export하는 Statement를 직접 씁니다. ReturnType<Database["prepare"]> 같은 우회 표현보다 API 구조가 한눈에 들어옵니다.
cleanup() 호출 위치도 초안에서 바꿨습니다. 원래 초안은 매 set() 뒤에 cleanup()을 붙였는데, 그러면 캐시 미스마다 DELETE FROM cache WHERE expires_at <= unixepoch()가 실행됩니다. 테이블이 커질수록 이 DELETE가 응답 지연에 그대로 나타납니다. 여기서는 N번 쓰기마다 한 번씩만 정리하도록 카운터를 두었고, 서비스 특성에 따라 아래처럼 시간 기반으로 완전히 분리해도 좋습니다.
// 별도 인터벌로 완전 분리하고 싶다면
setInterval(() => cache.cleanup(), 5 * 60 * 1000);get-or-set 패턴: API 응답 캐싱
실제 사용에서 가장 자주 쓰이는 패턴입니다. 캐시 히트면 즉시 반환, 미스면 실제 작업을 수행하고 결과를 저장합니다.
import { SQLiteCache } from "./cache";
const cache = new SQLiteCache();
async function getCachedUserProfile(userId: string) {
const cacheKey = `user:profile:${userId}`;
const cached = cache.get<UserProfile>(cacheKey);
if (cached) return cached;
const profile = await fetchUserFromDB(userId);
cache.set(cacheKey, profile, { ttl: 300 });
return profile;
}Hono 미들웨어로 HTTP 응답 캐싱
// middleware/cache.ts
import { Context, Next } from "hono";
import type { StatusCode } from "hono/utils/http-status";
import { SQLiteCache } from "../cache";
const cache = new SQLiteCache();
export function httpCache(ttlSeconds: number) {
return async (c: Context, next: Next) => {
if (c.req.method !== "GET") {
return next();
}
const cacheKey = `http:${c.req.method}:${c.req.url}`;
const cached = cache.get<{ body: string; contentType: string }>(cacheKey);
if (cached) {
return c.body(cached.body, 200, {
"Content-Type": cached.contentType,
"X-Cache": "HIT",
});
}
await next();
if (c.res.status >= 200 && c.res.status < 300) {
const body = await c.res.clone().text();
const contentType = c.res.headers.get("Content-Type") ?? "text/plain";
cache.set(cacheKey, { body, contentType }, { ttl: ttlSeconds });
return c.body(body, c.res.status as StatusCode, {
"Content-Type": contentType,
"X-Cache": "MISS",
});
}
};
}여기서 눈여겨볼 부분은 c.res.status as StatusCode입니다. 초안에는 as 200으로 강제 캐스팅되어 있었는데, 그러면 201·204·206 같은 실제 응답 상태 코드가 모두 200으로 왜곡됩니다. Hono의 StatusCode 타입을 쓰면 실제 상태 코드가 그대로 보존됩니다.
// app.ts
import { Hono } from "hono";
import { httpCache } from "./middleware/cache";
const app = new Hono();
app.get("/api/products", httpCache(60), async (c) => {
const products = await fetchProductsFromDB();
return c.json(products);
});요청 흐름 전체 시각화
WAL 체크포인트 관리
장시간 실행되는 서버에서는 WAL 파일이 계속 커질 수 있습니다. 클래스 내부에 이미 checkpoint() 공개 메서드를 뒀으니, 밖에서 호출하면 됩니다. 초안처럼 cache["writeDB"]로 private 필드에 브라켓 접근하는 방식은 캡슐화를 깨는 우회이니 피하는 게 좋습니다.
setInterval(() => {
try {
cache.checkpoint();
} catch (err) {
console.error("WAL 체크포인트 실패:", err);
}
}, 30 * 60 * 1000);트레이드오프 — 솔직한 정리
장단점 비교
| 항목 | SQLite 로컬 캐시 | Redis |
|---|---|---|
| 외부 인프라 | 불필요 | 별도 인스턴스 필요 |
| 네트워크 지연 | 없음 (로컬 파일) | 있음 (RTT) |
| 프로세스 재시작 후 유지 | 유지됨 | 설정에 따라 다름 |
| 다중 서버 공유 | 불가 | 가능 |
| 쓰기 동시성 | 단일 Writer만 | 높음 |
| 운영 부담 | 거의 없음 | 모니터링, failover 필요 |
| 트랜잭션 | ACID 트랜잭션 지원 | 제한적 |
실무에서 흔한 실수 두 가지
실수 1: Read/Write 커넥션을 같은 풀로 묶기
new Database(path)로 커넥션 여러 개를 만들어 풀로 관리하면, 그 커넥션들이 모두 쓰기를 시도할 때 SQLITE_BUSY가 터집니다. Write는 반드시 단일 커넥션으로 직렬화하고, Read 전용 커넥션을 별도로 두어야 합니다.
실수 2: 동기 API로 무거운 쿼리를 메인 스레드에서 실행
bun:sqlite는 동기 API입니다. 가볍고 빠른 캐시 조회에는 전혀 문제없지만, 복잡한 집계 쿼리나 대량 정리 작업을 자주 실행하면 이벤트 루프가 블록됩니다. 무거운 정리 작업은 요청 처리 흐름과 분리해서 실행하는 게 낫습니다.
이런 상황이라면 그냥 Redis를 쓰는 게 맞습니다
Redis로 전환해야 할 신호와 체크리스트
SQLite 캐시로 시작해도 언젠가는 Redis로 옮겨야 할 시점이 옵니다. 그 시점을 감으로 잡기보다 다음 신호를 기준으로 판단하는 게 좋습니다.
- 앱 인스턴스를 2대 이상 두어야 하고, 캐시 히트율이 인스턴스당 절반 이하로 떨어지기 시작할 때
SQLITE_BUSY재시도가busy_timeout안에서도 자주 실패하고, 쓰기 큐가 눈에 띄게 밀리기 시작할 때- 캐시 무효화를 여러 프로세스가 동시에 broadcast해야 할 때(pub/sub 필요)
- 캐시 데이터 크기가 로컬 디스크 용량이나 백업 정책에 부담을 주기 시작할 때
전환 자체는 인터페이스만 잘 짜두면 크게 어렵지 않습니다. get / set / delete 세 메서드를 갖는 인터페이스로 캐시를 감싸두면, 구현체만 SQLiteCache에서 RedisCache로 갈아끼우는 수준으로 끝납니다. 오히려 어려운 건 전환 시점에 데이터 일관성을 어떻게 유지할지(빈 캐시로 시작할지, 이관할지)의 결정이고, 이건 서비스 특성에 따라 다릅니다.
마무리
세 가지 결정 — WAL 모드, 커넥션 분리, 쿼리 기반 TTL — 은 각각 독립적으로 유용한 게 아니라 서로를 전제로 작동합니다. WAL 모드가 없으면 Read/Write 분리가 성능 이득으로 이어지지 못하고, TTL을 별도 워커에서 처리하면 다시 쓰기 경합이 생겨 커넥션 분리의 의미가 흐려집니다. 세 결정을 함께 잡을 때 비로소 외부 의존성 없이 프로덕션에 얹을 만한 캐시 레이어가 됩니다.
Redis는 필요할 때 언제든 다시 붙일 수 있습니다. 처음부터 붙일 필요는 없다는 것, 그리고 붙이지 않고도 꽤 오래 잘 굴러가는 구간이 실제로 존재한다는 것 — 이번에 직접 짜보면서 확인한 지점이었습니다.