Bun 1.2 내장 SQLite로 구성하는 Zero-Dependency 로컬 우선 백엔드
하나의 실험을 해봤습니다. Bun + Hono + Drizzle ORM 조합으로 작성한 REST API 서버를 도커 이미지로 패킹했더니 전체 이미지 크기가 68MB였습니다. Node.js + Express + Prisma + PostgreSQL 클라이언트 조합으로 같은 기능을 구현하면 이미지 크기만 400MB를 넘고, 여기에 실행 중인 PostgreSQL 컨테이너까지 별도로 있어야 합니다. 외부 DB가 없다는 건 단순한 아키텍처 취향의 문제가 아니라 배포 복잡도, 인프라 비용, 로컬 개발 환경 설정 시간 전체에 영향을 미칩니다.
Bun 1.2가 bun:sqlite를 런타임에 직접 내장하면서 이 접근이 실용적인 선택지가 됐습니다. Hono(14kB, 의존성 0개)와 Drizzle ORM(의존성 0개)을 더하면 외부 DB 드라이버를 별도 설치하지 않고도 타입 안전한 REST API 서버를 구성할 수 있습니다.
여기서 "zero-dependency"의 의미를 먼저 짚겠습니다. bun add hono drizzle-orm zod 같은 npm 패키지 설치는 여전히 합니다. 제거되는 건 SQLite 드라이버(better-sqlite3, sql.js)처럼 네이티브 바인딩 빌드가 필요한 패키지, 그리고 외부 DB 서버와의 연결 설정입니다. 이 글 전체에서 "zero-dependency"는 이 의미로 씁니다.
세 도구가 잘 맞는 이유
bun:sqlite — 런타임 내장이 실제로 의미하는 것
Node.js에서 better-sqlite3를 설치하면 npm install 시점에 node-gyp가 C++ 바인딩을 컴파일합니다. 그 바인딩이 SQLite 함수를 N-API를 통해 JavaScript에 노출하고, JS ↔ C++ 사이의 마샬링 오버헤드가 쿼리마다 발생합니다. CI 환경이나 Docker 빌드에서 gyp 빌드가 실패해서 고생한 경험이 있다면, 그 구조 때문입니다.
bun:sqlite는 이 중간 레이어가 없습니다. Bun 런타임 자체가 SQLite를 C 레벨로 직접 링크하고 있어서, 드라이버 설치도 네이티브 빌드도 필요 없습니다.
// bun:sqlite는 별도 설치 없이 바로 import됩니다
import { Database } from "bun:sqlite";
const db = new Database("app.db");
db.run("PRAGMA journal_mode=WAL");
db.run("PRAGMA busy_timeout = 5000");API 설계는 동기식(synchronous)입니다. 대부분의 SQLite 쿼리가 마이크로초 단위이고 파일 I/O 기반이라서, 동기 API가 이벤트 루프를 의미 있게 블로킹하는 경우는 드뭅니다. 오히려 async/await 체인 없이 쓸 수 있다는 실용적 장점이 있습니다.
트랜잭션은 콜백을 받아 BEGIN/COMMIT을 자동으로 감싸고, 예외 발생 시 ROLLBACK합니다:
const insertMany = db.transaction((users: Array<{ name: string; email: string }>) => {
const insert = db.prepare("INSERT INTO users (name, email) VALUES (?, ?)");
for (const user of users) {
insert.run(user.name, user.email);
}
});
insertMany([
{ name: "Alice", email: "alice@example.com" },
{ name: "Bob", email: "bob@example.com" },
]);이건 bun:sqlite의 원시(raw) 트랜잭션 API입니다. Drizzle ORM을 통해 쓸 때는 API가 달라집니다 — 아래 라우트 구현 섹션에서 다룹니다.
Drizzle ORM — SQL을 숨기지 않는 ORM
Drizzle의 설계 철학은 "SQL을 추상화하지 않고 타입 안전성만 추가한다"입니다. 쿼리 코드를 읽으면 어떤 SQL이 실행될지 바로 보입니다.
스키마 정의:
// src/schema.ts
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core";
export const users = sqliteTable("users", {
id: integer("id").primaryKey({ autoIncrement: true }),
name: text("name").notNull(),
email: text("email").notNull().unique(),
createdAt: integer("created_at", { mode: "timestamp" }).$defaultFn(() => new Date()),
});
export const posts = sqliteTable("posts", {
id: integer("id").primaryKey({ autoIncrement: true }),
title: text("title").notNull(),
content: text("content"),
authorId: integer("author_id").references(() => users.id),
});bun:sqlite에 연결:
// src/db.ts
import { Database } from "bun:sqlite";
import { drizzle } from "drizzle-orm/bun-sqlite";
import * as schema from "./schema";
const sqlite = new Database("app.db");
sqlite.run("PRAGMA journal_mode=WAL");
sqlite.run("PRAGMA busy_timeout = 5000");
export const db = drizzle(sqlite, { schema });쿼리는 두 가지 스타일로 씁니다:
import { eq, desc } from "drizzle-orm";
import { db } from "./db";
import { users, posts } from "./schema";
// SQL-like API — SELECT 절이 그대로 보입니다
const recentPosts = await db
.select()
.from(posts)
.orderBy(desc(posts.id))
.limit(10);
// Relational API — with 절로 관계 데이터를 가져옵니다
const userWithPosts = await db.query.users.findFirst({
where: eq(users.id, 1),
with: { posts: true },
});
// userWithPosts의 타입은 완전히 추론됩니다Hono — Web Standard API만 쓰는 라우터
Hono는 Node.js의 IncomingMessage나 ServerResponse 같은 플랫폼 전용 타입 없이, Request와 Response 같은 Web Standard API만 사용합니다. 그래서 같은 코드가 Bun, Deno, Cloudflare Workers에서 모두 실행됩니다. Bun과 조합하면 TypeScript를 별도 트랜스파일 없이 바로 실행할 수 있습니다.
// src/index.ts
import { Hono } from "hono";
import { logger } from "hono/logger";
import { HTTPException } from "hono/http-exception";
import { postsRoute } from "./routes/posts";
const app = new Hono();
app.use("*", logger());
app.route("/api/posts", postsRoute);
app.onError((err, c) => {
if (err instanceof HTTPException) {
return err.getResponse();
}
console.error(err);
return c.json({ error: "Internal server error" }, 500);
});
export default {
port: Number(process.env.PORT ?? 3000),
fetch: app.fetch,
};bun src/index.ts 한 줄로 서버가 뜹니다.
전체 아키텍처 흐름
요청이 들어와서 응답이 나가기까지의 경로입니다:
외부 네트워크 호출이 없습니다. 요청에서 응답까지 모든 I/O가 로컬 파일 시스템 안에서 완결됩니다.
실전 설정
프로젝트 초기화
bun init -y
bun add hono drizzle-orm @hono/zod-validator zod
bun add -d drizzle-kitbun:sqlite는 목록에 없습니다. 설치할 것이 없기 때문입니다.
drizzle.config.ts:
import type { Config } from "drizzle-kit";
export default {
schema: "./src/schema.ts",
out: "./drizzle",
dialect: "sqlite",
dbCredentials: {
url: "./app.db",
},
} satisfies Config;마이그레이션 워크플로
drizzle-kit push는 기존 테이블이 있으면 데이터를 날릴 수 있습니다. 프로덕션 배포 스크립트에는 반드시 generate + migrate 순서를 씁니다. 앱 시작 시 마이그레이션을 자동 적용하는 방법:
// src/db.ts에 추가
import { migrate } from "drizzle-orm/bun-sqlite/migrator";
migrate(db, { migrationsFolder: "./drizzle" });실제 라우트 구현
// src/routes/posts.ts
import { Hono } from "hono";
import { HTTPException } from "hono/http-exception";
import { zValidator } from "@hono/zod-validator";
import { z } from "zod";
import { db } from "../db";
import { posts, users } from "../schema";
import { eq, desc } from "drizzle-orm";
const postsRoute = new Hono();
const createPostSchema = z.object({
title: z.string().min(1).max(200),
content: z.string().optional(),
authorId: z.number().int().positive(),
});
postsRoute.get("/", async (c) => {
const page = Number(c.req.query("page") ?? "1");
const limit = 20;
const offset = (page - 1) * limit;
const result = await db
.select({
id: posts.id,
title: posts.title,
authorName: users.name,
})
.from(posts)
.leftJoin(users, eq(posts.authorId, users.id))
.orderBy(desc(posts.id))
.limit(limit)
.offset(offset);
return c.json(result);
});
postsRoute.post("/", zValidator("json", createPostSchema), async (c) => {
const body = c.req.valid("json");
const result = await db.transaction(async (tx) => {
const author = await tx
.select()
.from(users)
.where(eq(users.id, body.authorId))
.get();
if (!author) {
// 작성자가 없는 건 클라이언트 오류입니다 — 500이 아니라 404를 반환합니다
throw new HTTPException(404, { message: "Author not found" });
}
const [post] = await tx.insert(posts).values(body).returning();
return post;
});
return c.json(result, 201);
});
export { postsRoute };트랜잭션 안에서 db 대신 tx를 씁니다. tx는 현재 트랜잭션에 바인딩된 연결로, 이걸 써야 트랜잭션 격리가 보장됩니다. db를 직접 쓰면 동일 트랜잭션 범위 밖에서 쿼리가 실행됩니다.
배포 전략 선택
로컬 SQLite로 시작한다고 해서 여기서 멈출 필요는 없습니다. 요구사항이 커지면 데이터 레이어만 교체하면 됩니다:
Hono가 Web Standard API 기반이기 때문에, Bun에서 Cloudflare Workers로 이동할 때 라우트 코드 변경 없이 플랫폼만 바꿀 수 있습니다. DB 교체는 db.ts 한 파일의 드라이버 설정 변경으로 끝납니다.
Litestream으로 S3 백업 추가하기
Litestream은 SQLite 파일을 S3로 실시간 스트리밍 복제합니다. 별도 백업 스크립트 없이 WAL 변경분을 지속적으로 업로드합니다.
# litestream.yml
dbs:
- path: /app/app.db
replicas:
- type: s3
bucket: my-backup-bucket
path: db
region: ap-northeast-2# Dockerfile
FROM oven/bun:1.2-slim
WORKDIR /app
COPY package.json bun.lock ./
RUN bun install --frozen-lockfile
COPY . .
RUN bun run build # drizzle-kit generate가 포함된 경우
# Litestream 바이너리를 함께 복사
COPY --from=litestream/litestream:latest /usr/local/bin/litestream /usr/local/bin/
EXPOSE 3000
# -exec 플래그: Litestream이 앱 프로세스를 자식 프로세스로 관리합니다.
# 앱이 충돌하면 Litestream도 종료되고 컨테이너가 재시작됩니다.
CMD ["litestream", "replicate", "-exec", "bun src/index.ts", "litestream.yml"]-exec 플래그가 핵심입니다. Litestream이 앱을 자식 프로세스로 실행하면서 복제를 관리합니다. 앱이 비정상 종료되면 Litestream도 함께 종료되어 컨테이너 재시작 신호를 보냅니다. Litestream을 사이드카로 분리하거나 백그라운드 프로세스로 실행하면 앱 상태와 복제 프로세스의 생명주기가 따로 돌아 복구 시나리오가 복잡해집니다.
언제 쓰고 언제 피할지
| 적합한 경우 | 이유 |
|---|---|
| 내부 도구, 관리자 패널 | 동시 쓰기 부하가 낮고 단일 서버로 충분 |
| 소규모 SaaS MVP | 인프라 설정 없이 빠르게 프로덕션 도달 |
| 멀티 테넌트 서비스 | 테넌트당 SQLite 파일 하나 → 격리, 백업, 마이그레이션 독립 |
| CLI 도구 / 로컬 에이전트 | 임베디드 DB로 설치 편의성 극대화 |
| 피해야 할 경우 | 이유 | 대안 |
|---|---|---|
| 초당 수천 건 동시 쓰기 | WAL 모드에서도 쓰기는 직렬화됨 | PostgreSQL + 커넥션 풀 |
| 여러 서버가 같은 DB 파일 접근 | 네트워크 드라이브 위 SQLite는 락 이슈 발생 | Turso, LiteFS |
| 컬럼 타입 변경이 잦은 스키마 | SQLite는 ALTER TABLE로 컬럼 타입 변경 불가 | 초기 설계 신중히 |
SQLite의 단일 라이터 제약은 제한이기도 하지만 특성이기도 합니다. 쓰기 직렬화 덕분에 낙관적 락이나 MVCC 없이도 데이터 일관성이 자동 보장됩니다. 쓰기 처리량이 낮은 서비스에서는 이 단순함이 장점으로 작용합니다.
시작 방법 요약
# 1. 프로젝트 생성
bun init -y
bun add hono drizzle-orm @hono/zod-validator zod
bun add -d drizzle-kit
# 2. 스키마 작성 후 개발 DB 초기화
bunx drizzle-kit push
# 3. 서버 실행
bun src/index.ts프로덕션 배포는 마이그레이션 파일을 생성하고 커밋하는 흐름으로 전환합니다:
bunx drizzle-kit generate # 마이그레이션 파일 생성
git add drizzle/ # 버전 관리에 포함
# 배포 시 앱 시작 전 migrate() 함수가 자동 적용