`better-sqlite3`를 `package.json`에서 지운 날 — Bun 내장 `bun:sqlite`로 WAL·트랜잭션·마이그레이션 옮기기
ARM 빌드 서버에서 better-sqlite3 컴파일이 터졌을 때 처음 진지하게 대안을 찾아봤습니다. 에러 메시지는 낯설고, CI 로그는 길었고, 결국 node-pre-gyp를 붙잡고 씨름하다가 한 시간을 날렸죠. 그때는 "이건 SQLite인데 왜 이렇게 복잡하지?"라는 생각이 계속 들었습니다.
이 글의 대상 독자는 이미 Bun을 런타임으로 쓰고 있고, better-sqlite3 의존성 때문에 네이티브 빌드/CI 문제를 겪고 있는 서버 개발자입니다. bun:sqlite는 Bun 0.6.x(2022년 말)부터 이미 내장돼 있었고, 이후 릴리스마다 안정성·API가 개선돼 왔습니다. 2026년 8월 기준으로는 프로덕션 서버에서 better-sqlite3를 완전히 대체하기에 충분히 성숙한 상태라고 판단하고 이 글을 씁니다. 다만 1:1 호환은 아니고 주의할 지점이 분명히 있어서, 실제로 어떻게 전환하는지·WAL 모드와 트랜잭션·마이그레이션은 어떻게 옮기는지·어디서 막히는지를 같이 살펴보겠습니다.
왜 지금 이 선택지가 유효한가
런타임 차원의 SQLite 1급 시민화
bun:sqlite가 새로 생긴 건 아니지만, 최근 몇 년 사이 JavaScript 런타임이 SQLite를 외부 네이티브 애드온이 아닌 표준 내장 기능으로 다루려는 방향이 뚜렷해졌습니다. Node.js도 실험적 내장 SQLite(node:sqlite)를 도입했고, Bun은 이미 안정 API로 제공합니다.
bun:sqlite는 N-API를 거치지 않고 JavaScriptCore에 직접 통합돼 있어서, 네이티브 애드온과 JavaScript 런타임 사이를 오가는 마샬링 오버헤드가 없는 구조입니다.
better-sqlite3와 API 비교
두 라이브러리의 실질적 차이를 정리하면 이렇습니다. SQLite 자체의 문법(예: PRAGMA journal_mode=WAL)은 두 드라이버가 동일하게 실행하므로 비교에서 뺐고, 드라이버 API 자체의 차이만 표로 정리했습니다.
| 항목 | better-sqlite3 (Node.js) |
bun:sqlite (Bun 내장) |
|---|---|---|
| 설치 | npm install better-sqlite3 + 네이티브 빌드 |
별도 설치 없음 |
| 바인딩 방식 | N-API (네이티브 애드온) | JavaScriptCore 직접 통합 |
| 실행 모델 | 동기(synchronous) | 동기(synchronous) |
| 명시적 Prepared Statement | db.prepare(sql) |
db.prepare(sql) |
| 자동 캐싱 Prepared Statement | 별도 API 없음 (직접 캐싱) | db.query(sql) (내부 캐싱) |
| 트랜잭션 래핑 | db.transaction(fn) |
db.transaction(fn) |
| TypeScript 타입 | @types/better-sqlite3 별도 설치 |
Bun에 내장 |
핵심 차이는 db.query()입니다. better-sqlite3에서 반복 사용되는 쿼리를 최적화하려면 prepare() 결과를 직접 변수에 담아 캐싱해야 했는데, bun:sqlite는 db.query()가 동일한 SQL에 대해 prepared statement를 내부적으로 캐싱합니다. db.prepare()만 계속 쓰면 이 이점을 놓치게 되므로, 코드에서 자주 재실행하는 쿼리는 db.query()로 옮기는 걸 권합니다.
기본 전환: import 한 줄 바꾸기
가장 단순한 케이스부터 보겠습니다.
// before: Node.js + better-sqlite3
import Database from "better-sqlite3";
const db = new Database("app.db");
const row = db.prepare("SELECT * FROM users WHERE id = ?").get(1);// after: Bun 내장 bun:sqlite
import { Database } from "bun:sqlite";
const db = new Database("app.db");
const row = db.prepare("SELECT * FROM users WHERE id = ?").get(1);import 경로가 바뀌고 named export로 가져오는 것 외에는 달라진 게 없습니다. .get(), .all(), .run() 메서드 이름도 동일합니다. 반복 호출되는 쿼리라면 아래처럼 db.query()로 옮겨 캐싱 이점을 챙길 수 있습니다.
import { Database } from "bun:sqlite";
const db = new Database("app.db");
const getUserById = db.query("SELECT * FROM users WHERE id = ?");
const row = getUserById.get(1);WAL 모드와 성능 PRAGMA 설정
WAL(Write-Ahead Logging) 모드는 쓰기를 별도의 -wal 파일에 먼저 기록해서 읽기와 쓰기를 병렬로 처리하는 SQLite 저널링 전략입니다. 멀티 리더 + 단일 라이터 환경, 즉 백엔드 API 서버 대부분의 상황에서 기본 모드(DELETE) 대비 처리량이 향상됩니다.
WAL 모드에서 읽기 트랜잭션은 체크포인트가 실행되기 전까지 DB 파일과 WAL 파일을 함께 참조합니다. 아래 다이어그램은 흐름을 단순화한 것이며, 실제로는 읽기 요청도 WAL 파일의 최신 커밋을 함께 스캔한다는 점을 유의해 주세요.
DB 연결 직후 PRAGMA 몇 줄을 일괄 적용해 두면 이후 운영이 편합니다.
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 cache_size = -64000;");
db.run("PRAGMA temp_store = MEMORY;");synchronous = NORMAL은 기본값(FULL)보다 fsync 빈도를 낮춰 처리량을 올리는 대신, 전원 차단 등 극단 상황에서 마지막 몇 트랜잭션의 손실 가능성이 있습니다. 일반 API 서버 워크로드에서는 충분하지만, 금융 트랜잭션처럼 강한 내구성이 필요하면 그대로 두는 게 안전합니다.
성능에 관해서는 커뮤니티 측정치가 여럿 공유돼 있는데, 상황에 따라 편차가 큽니다. better-sqlite3 GitHub Discussions #1057에서 Bun 팀 구성원이 공유한 수치가 대표적입니다. 다만 이는 순수 드라이버 오버헤드 차이에 가깝고, 복잡한 SQL 연산 비중이 큰 워크로드에서는 결과가 다르게 나올 수 있어 실제 서비스 트래픽으로 검증해 보는 편이 안전합니다.
트랜잭션 래핑
db.transaction(fn) API는 이름과 시그니처가 동일합니다. 함수 안에서 예외가 발생하면 자동으로 롤백됩니다.
import { Database } from "bun:sqlite";
interface Item {
name: string;
value: number;
}
const db = new Database("app.db");
const insertMany = db.transaction((items: Item[]) => {
const stmt = db.prepare(
"INSERT INTO items (name, value) VALUES ($name, $value)"
);
for (const item of items) {
stmt.run({ $name: item.name, $value: item.value });
}
});
insertMany(largeDataset);처음 이 API를 쓸 때 흔히 헷갈리는 부분은 db.transaction(fn)의 반환값이 즉시 실행되는 결과가 아니라 함수라는 점입니다. insertMany(largeDataset)처럼 반환된 함수를 다시 호출해야 실제로 트랜잭션이 열립니다. better-sqlite3와 동일한 패턴입니다.
마이그레이션 방식 결정
두 가지 방식이 있고, 상황에 따라 적합한 쪽이 다릅니다. 코드부터 보기 전에 어느 쪽을 볼지 정하고 넘어가는 게 시간을 아낍니다.
커스텀 _migrations 테이블
외부 도구 없이 _migrations 테이블로 마이그레이션 상태를 직접 관리하는 방식입니다. 의존성을 최소화하고 싶을 때 유용합니다.
import { Database } from "bun:sqlite";
const db = new Database("app.db");
db.run(`
CREATE TABLE IF NOT EXISTS _migrations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT UNIQUE NOT NULL,
applied_at TEXT DEFAULT (datetime('now'))
)
`);
function applyMigration(name: string, sql: string) {
const already = db
.prepare("SELECT 1 FROM _migrations WHERE name = ?")
.get(name);
if (already) return;
db.transaction(() => {
db.run(sql);
db.prepare("INSERT INTO _migrations (name) VALUES (?)").run(name);
})();
}
applyMigration(
"001_create_users",
`CREATE TABLE users (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
created_at TEXT DEFAULT (datetime('now'))
)`
);
applyMigration(
"002_add_email_to_users",
`ALTER TABLE users ADD COLUMN email TEXT`
);이 구현이 실용적인 이유를 짚어 두면, 우선 name에 UNIQUE 제약을 걸어 두어 동일 이름의 마이그레이션이 두 번 적용될 수 없습니다. 그리고 스키마 변경과 기록 삽입을 db.transaction()으로 묶었기 때문에, db.run(sql)이 실패하면 _migrations 테이블에 기록도 남지 않아 재실행 시 다시 시도됩니다. 반대로 스키마 변경은 성공했는데 기록만 실패하는 상황도 원자성 덕분에 생기지 않습니다.
Drizzle ORM 연동
Drizzle ORM은 bun:sqlite를 공식 지원합니다. drizzle-orm/bun-sqlite 어댑터를 사용합니다.
import { drizzle } from "drizzle-orm/bun-sqlite";
import { Database } from "bun:sqlite";
import { migrate } from "drizzle-orm/bun-sqlite/migrator";
import { sqliteTable, integer, text } from "drizzle-orm/sqlite-core";
export const users = sqliteTable("users", {
id: integer("id").primaryKey({ autoIncrement: true }),
name: text("name").notNull(),
email: text("email"),
});
const sqlite = new Database("app.db");
const db = drizzle(sqlite);
await migrate(db, { migrationsFolder: "./drizzle" });
const allUsers = db.select().from(users).all();drizzle-kit generate로 스키마 변경을 SQL 파일로 뽑고, migrate()로 앱 시작 시 자동 적용하는 패턴입니다.
주의할 점이 하나 있습니다. drizzle-kit CLI를 Bun에서 실행할 때 일부 버전에서 CLI 내부가 Node 환경을 가정해 better-sqlite3를 요구하는 경우가 보고돼 왔습니다(설치되지 않으면 CLI 실행 자체가 실패). 이 경우 마이그레이션 파일 생성은 CLI(필요하면 Node 환경)로, 실제 적용은 앱 코드의 migrate()로 분리하면 런타임 코드에서는 better-sqlite3 의존성을 두지 않아도 됩니다. 사용 중인 drizzle-kit 버전의 릴리스 노트에서 현재 상태를 먼저 확인해 주세요.
트레이드오프
얻는 것
| 항목 | 내용 |
|---|---|
| 의존성 제거 | better-sqlite3 삭제 → node_modules 경량화, ARM·musl 크로스컴파일 오류 해소 |
| 설치·시작 속도 | bun install 후 즉시 사용. CI/CD에서 네이티브 모듈 재컴파일 단계 제거 |
| TypeScript 내장 | @types/ 패키지 별도 설치 불필요 |
| 친숙한 API | .prepare(), .get(), .all(), .run(), .transaction() 이름 동일 |
| 자동 캐싱 쿼리 | db.query()로 반복 쿼리 최적화가 코드 한 줄로 가능 |
감수해야 할 것
| 항목 | 내용 |
|---|---|
| 100% 호환 아님 | 일부 라이브러리는 better-sqlite3 인스턴스를 어댑터로 직접 주입받도록 설계돼 있어 별도 확인 필요 |
| ORM 툴체인 제약 | drizzle-kit 일부 버전이 CLI 내부에서 better-sqlite3를 요구할 수 있음 |
| 런타임 잠금 | bun:sqlite는 Bun 전용. Node.js와 Bun 양쪽 런타임을 동시에 지원해야 한다면 별도 추상 레이어 필요 |
| 병행 사용의 복잡도 | Bun에서 better-sqlite3를 그대로 쓰려고 하면 재컴파일 관련 이슈 #16050이 있어, 어설픈 병행 사용은 오히려 복잡도를 높임 |
| 성능은 워크로드 의존 | 쿼리 성격에 따라 편차가 큼. 자체 벤치 없이 절대적 우열을 단정하지 말 것 |
가장 신경 쓰이는 부분은 API 호환성입니다. 단순한 CRUD 앱이라면 import 한 줄 바꾸면 끝나지만, better-sqlite3 인스턴스를 특정 어댑터로 주입받는 서드파티 라이브러리를 쓰고 있다면 먼저 해당 라이브러리의 bun:sqlite 지원 여부를 확인해야 합니다. better-sqlite3 API를 흉내내는 shim 패키지가 있다는 이야기를 종종 접하는데, 실제로 npm 레지스트리와 최근 릴리스·이슈 활동을 직접 확인한 뒤 도입하는 편이 안전합니다.
지운 것과 남은 것
전환 전후 package.json을 비교해 보면 변화가 뚜렷합니다.
{
"dependencies": {
- "better-sqlite3": "^11.x.x"
- },
- "devDependencies": {
- "@types/better-sqlite3": "^7.x.x"
}
}CI 파이프라인에서도 node-gyp 캐시 워밍업, 플랫폼별 prebuilt 바이너리 폴백, ARM 러너 전용 빌드 스텝 같은 항목이 함께 사라집니다. 처음에 한 시간을 날렸던 그 node-pre-gyp 로그도 다시 볼 일이 없어졌습니다. CI 로그가 조용해지는 것은 눈에 잘 띄지 않지만, 배포 실패 알림이 오지 않는 밤이 이어지는 걸로 체감됩니다.
전환 자체는 파일 몇 개의 import 경로 수정과 마이그레이션 방식 결정, PRAGMA 4~5줄 추가로 끝납니다. 다만 drizzle-kit 같은 툴체인이나 서드파티 어댑터에 걸린 의존성이 있다면 그 지점만 별도로 확인해 두는 걸 권합니다. 준비물이 많지 않은 대신, 지워지는 항목이 CI 로그 절반이라는 게 이 전환의 실질적 이득입니다.