ORM 없이도 타입이 살아있다: Bun 1.2 내장 SQLite로 엣지 쿼리 레이어 직접 짜기
경량 서버리스 백엔드를 구성하다 보면 한 번쯤 이런 고민에 빠집니다. "Prisma는 너무 무겁고, 직접 SQL 짜면 타입이 죽고, 그렇다고 ORM 없이 도메인 객체 매핑까지 하기는 너무 번거롭다." 저도 한동안 이 삼각 딜레마에서 better-sqlite3 + 수동 인터페이스 선언이라는 타협점을 쓰고 있었습니다.
그런데 Bun 1.2가 2025년 1월 나오면서 이 구도가 좀 달라졌습니다. bun:sqlite는 외부 패키지 없이 런타임에 번들된 SQLite 드라이버이고, db.query<ResultType, [ParamType]>(sql) 형태의 제네릭 API를 제공합니다. 코드 생성 도구 없이도 기본적인 타입 안전성을 잡을 수 있습니다.
이 글에서는 bun:sqlite 내장 드라이버로 타입 안전한 쿼리 레이어를 직접 설계하는 패턴, 그리고 이게 엣지 환경에서 어떤 의미를 가지는지 코드와 함께 풀어봅니다. ORM을 아예 버리라는 이야기가 아니라, ORM이 짐이 될 때 어떤 대안이 있는지를 솔직하게 따져보는 글입니다.
bun:sqlite가 지금 다시 주목받는 이유
SQLite의 재발견: 파일 DB가 엣지 DB가 되다
SQLite를 "프로토타입용 DB"로만 생각했다면, 2024–2025년 사이에 벌어진 일들을 짚어볼 필요가 있습니다. Cloudflare D1, Turso, Fly.io LiteFS가 프로덕션 안정성에 도달하면서 분산 SQLite가 엣지 데이터베이스의 현실적인 선택지로 올라섰습니다. SQLite의 인프로세스 특성은 네트워크 왕복 없이 매우 낮은 읽기 지연을 가능하게 합니다. 글로벌 리전에 파일을 복제하는 레이어와 결합하면, PostgreSQL을 쓸 때보다 훨씬 낮은 운영 복잡도로 엣지 배포가 됩니다.
bun:sqlite의 포지션
bun:sqlite는 better-sqlite3에서 영감을 받은 동기식 API를 제공하며, 네이티브 바인딩 덕분에 Bun 공식 벤치마크에서 better-sqlite3와 deno.land/x/sqlite 대비 유의미한 우위를 보고합니다. 벤치마크는 대체로 읽기 중심 워크로드에서 강한 이점을 나타내는데, 실제 프로젝트 워크로드에 따라 결과는 달라질 수 있으니 자체 측정도 함께 해보시길 권합니다.
번들 관점에서는 별도 npm 의존성이 없다는 점이 더 실질적입니다. 엣지 배포 번들에 네이티브 애드온을 밀어 넣을 필요가 없고, 런타임에 이미 링크돼 있으니 설치 실패 이슈가 사라집니다.
솔직히 처음 이 수치를 봤을 때 "설마" 싶었는데, 직접 돌려보면 콜드 스타트 차이는 체감이 확실히 납니다. 다만 이 콜드 스타트 이점은 Bun 런타임 자체의 시작 시간에 기인하는 부분이 크다는 점을 뒤에서 따로 짚겠습니다.
언제 bun:sqlite를 쓰고 언제 안 쓰는지
모든 상황에 쓸 수 있는 도구가 아닙니다. 아래 흐름이 판단에 도움이 될 것 같습니다.
Cloudflare Workers에서는 bun:sqlite 네이티브 바이너리가 실행되지 않습니다. 2026년 8월 기준으로도 해결된 제약이 아니라서, Workers를 타깃으로 한다면 D1 API를 써야 합니다.
타입 안전한 쿼리 레이어를 직접 짜는 방법
기본 제네릭 API — 생각보다 간단합니다
bun:sqlite의 핵심은 db.query<ResultType, ParamType>(sql) 형태입니다. 결과 행의 타입과 바인딩 파라미터의 타입을 제네릭으로 명시하면, 그 이후엔 TypeScript가 지켜줍니다.
import { Database } from "bun:sqlite";
interface User {
id: number;
name: string;
email: string;
}
const db = new Database("app.db");
const getUser = db.query<User, [number]>(
"SELECT id, name, email FROM users WHERE id = ?"
);
const insertUser = db.query<User, [string, string]>(
"INSERT INTO users (name, email) VALUES (?, ?) RETURNING id, name, email"
);
const user = getUser.get(42);
const created = insertUser.get("Alice", "alice@example.com");이것만으로도 쿼리 결과를 any로 받아서 직접 캐스팅하던 패턴에서 벗어날 수 있습니다. 다만 타입 시스템이 잡아주는 범위는 정확히 이해하고 써야 합니다. 인자 개수가 부족하거나 타입이 아예 다르면 잡아주지만, [string, string]처럼 동형 파라미터의 순서 뒤집힘은 잡지 못합니다. name과 email을 바꿔 넘겨도 컴파일은 통과합니다. SQL injection과 자료형 오류는 막아주지만, 의미론적 파라미터 오배치까지 기대하면 안 됩니다.
.run() 반환값과 void 제네릭의 관계
bun:sqlite의 .run()은 { lastInsertRowid: number | bigint, changes: number }를 반환합니다. 첫 번째 제네릭을 void로 선언하면 이 반환값 타입은 그대로 살아있지만, 결과 행 타입이 필요 없다는 의도를 명시하는 관용적 표기로 쓰입니다. 삽입 후 새 ID가 필요하다면 .run()의 반환값을 그대로 쓰거나, 위 예시처럼 RETURNING 절과 .get()을 함께 쓰는 편이 더 명시적입니다.
const insert = db.query<User, [string, string]>(
"INSERT INTO users (name, email) VALUES (?, ?)"
);
const result = insert.run("Bob", "bob@example.com");
console.log(result.lastInsertRowid, result.changes);query.as(Class) — ORM 없이 도메인 객체 매핑
Bun 1.2에서 추가된 query.as(Class) 기능은 쿼리 결과를 특정 클래스 인스턴스로 매핑해줍니다. 그 클래스에 메서드나 게터를 붙여 도메인 로직을 담을 수 있습니다.
class User {
id!: number;
name!: string;
email!: string;
get displayName() {
return `@${this.name}`;
}
isValidEmail() {
return this.email.includes("@");
}
}
const users = db
.query("SELECT id, name, email FROM users")
.as(User)
.all();
users.forEach((u) => {
console.log(u.displayName);
});여기서 중요한 함정 하나. SELECT *와 .as(Class)를 조합하면 타입 안전성이 끊깁니다. 테이블에 컬럼이 추가·삭제돼도 클래스 프로퍼티와의 불일치를 컴파일 타임에 잡을 방법이 없습니다. 반환 컬럼은 항상 명시적으로 나열하는 편이 매핑의 의도를 지키는 방법입니다.
트랜잭션 래퍼 패턴 — statement 재사용이 핵심
db.transaction()은 함수를 받아서 트랜잭션으로 감싸고, 예외 발생 시 자동으로 롤백합니다. 동기식이라 async/await 없이 깔끔합니다. 다만 여기서 자주 저지르는 실수가 있는데, 트랜잭션 함수 내부에서 매번 db.query(...)를 호출하면 실행할 때마다 SQL을 재컴파일합니다. prepared statement의 이점을 통째로 잃어버리는 패턴이라 반드시 바깥에서 미리 준비해두세요.
const debit = db.query<void, [number, number]>(
"UPDATE accounts SET credits = credits - ? WHERE id = ?"
);
const credit = db.query<void, [number, number]>(
"UPDATE accounts SET credits = credits + ? WHERE id = ?"
);
const transferCredits = db.transaction(
(from: number, to: number, amount: number) => {
debit.run(amount, from);
credit.run(amount, to);
}
);
transferCredits(1, 2, 100);비동기 ORM에서 try/catch + 수동 롤백을 관리하던 것과 비교하면 코드가 훨씬 단순해지고, statement 재사용까지 챙기면 성능 특성도 예측 가능해집니다.
쿼리 레이어 아키텍처 — 파일 구조로 타입 경계 만들기
제네릭 API를 여기저기 흩뿌리면 나중에 관리가 어렵습니다. 저는 아래처럼 쿼리 레이어를 별도 모듈로 분리하는 방식을 선호합니다.
// db/users.ts
import { Database } from "bun:sqlite";
interface UserRow {
id: number;
name: string;
email: string;
created_at: string;
}
interface CreateUserParams {
name: string;
email: string;
}
export function createUserRepository(db: Database) {
const findById = db.query<UserRow, [number]>(
"SELECT id, name, email, created_at FROM users WHERE id = ?"
);
const findAll = db.query<UserRow, never[]>(
"SELECT id, name, email, created_at FROM users"
);
const insert = db.query<UserRow, [string, string]>(
"INSERT INTO users (name, email) VALUES (?, ?) RETURNING id, name, email, created_at"
);
return {
findById: (id: number) => findById.get(id),
findAll: () => findAll.all(),
create: ({ name, email }: CreateUserParams) => insert.get(name, email),
};
}이렇게 하면 db 인스턴스를 직접 노출하지 않고, 각 모듈이 자신에게 필요한 쿼리만 갖게 됩니다. 파라미터 없는 쿼리는 never[]를 두 번째 제네릭에 넘겨 "바인딩할 인자가 없음"을 표현하는 편이 빈 튜플 []보다 의도를 분명히 드러냅니다(생략도 가능합니다).
코드젠으로 스키마-타입 갭 메우기 (개념적 예시)
제네릭 API는 인터페이스와 SQL이 동기화됐는지를 런타임 이전에 검증해주지는 않습니다. users 테이블 컬럼이 바뀌어도 TypeScript 인터페이스를 수동으로 맞춰줘야 합니다. 이 간격을 채우는 게 코드젠 도구의 역할입니다.
# 개념적 예시: 마이그레이션에서 타입을 뽑아내는 도구
# 실제 도구 선택은 프로젝트 상황에 맞게
your-sql-codegen --migrations ./migrations --queries ./src/queries접근 방식은 도구마다 다릅니다. 마이그레이션 SQL을 파싱해 스키마를 추론하는 방식, 실제 SQLite 인스턴스에 스키마를 적용하고 PRAGMA table_info로 컬럼 타입을 읽어오는 방식, 쿼리를 실행 계획 분석기에 태워 결과 컬럼 타입을 얻는 방식 등이 있습니다. 어떤 방식이든 목표는 같습니다. 스키마가 변경되면 타입이 함께 갱신되어 user.nonExistingField 같은 접근을 컴파일 단계에서 잡아내는 것.
2026년 8월 기준으로 bun:sqlite를 직접 겨냥한 코드젠 생태계는 아직 얇은 편이므로, 도구 도입 전에는 해당 프로젝트의 활성도와 지원 문법 범위를 반드시 확인하는 편이 안전합니다.
트레이드오프 — 솔직하게 따져보기
드라이버와 런타임 특성 비교
| 항목 | bun:sqlite (내장) |
better-sqlite3 (Node.js) |
|---|---|---|
| 읽기 성능 | Bun 벤치마크 기준 우위 | 기준 |
| 추가 의존성 | 없음 (런타임 번들) | npm 패키지 + 네이티브 빌드 |
| API 스타일 | 동기식 (better-sqlite3 계열) | 동기식 |
| 타입 안전성 | 제네릭 + 코드젠 조합 | 수동 인터페이스 |
| 관계형 매핑 | 수동 구현 필요 | 수동 구현 필요 |
| 마이그레이션 | 별도 도구 필요 | 별도 도구 필요 |
콜드 스타트 이야기를 표에 끼워 넣기 쉬운데, 이건 드라이버 간 차이가 아니라 Bun 런타임 vs Node.js 런타임 전체의 시작 시간 차이입니다. bun:sqlite를 골랐다고 드라이버 자체가 콜드 스타트를 줄이는 게 아니라, Bun 런타임 위에서 돌기 때문에 얻는 부수 효과라는 점을 구분해 두는 게 오해를 막습니다. Prisma 같은 ORM과의 비교도 성능·크기 축에서 방향성은 분명하지만, 정량 비교표에 끼워 넣으려면 각각의 최신 공식 벤치마크로 근거를 대는 편이 낫습니다.
실무에서 흔히 만나는 함정들
Cloudflare Workers에서 쓰려다 막히는 경우: bun:sqlite의 네이티브 바이너리는 Workers 환경에서 실행되지 않습니다. Workers를 타깃으로 한다면 D1 API를 써야 합니다. 처음부터 이 제약을 알고 설계해야 합니다.
동시 쓰기 병목: SQLite는 쓰기 잠금 구조 때문에 고동시성 쓰기 워크로드에 부적합합니다. WAL(Write-Ahead Logging) 모드를 활성화하면 읽기와 쓰기를 어느 정도 분리할 수 있지만, 쓰기 요청이 초당 수백 건 이상이라면 PostgreSQL이나 MySQL을 고려하는 편이 낫습니다.
스키마 변경 추적: 내장 마이그레이션 시스템이 없습니다. SQL 파일을 직접 관리하거나 Drizzle의 마이그레이션 기능, bun-migrate 같은 외부 도구를 별도로 도입해야 합니다. ORM 없이 쓰면서도 마이그레이션만은 Drizzle을 쓰는 조합도 현실적인 선택입니다.
다중 인스턴스 배포: 파일 기반 DB이므로 인스턴스가 여러 개면 파일 공유 문제가 생깁니다. Turso나 Fly.io LiteFS를 레이어로 추가해 해결합니다.
ORM과 공존하는 방법
ORM을 완전히 버리지 않아도 됩니다. Drizzle ORM은 bun:sqlite를 공식 어댑터로 지원하며, 엣지 환경에 초점을 둔 경량 설계라 번들 부담이 적은 편입니다. TypeORM·MikroORM·Sequelize 같은 다른 ORM들도 시점에 따라 지원 상태가 달라질 수 있으니, 채택 전에는 최신 공식 문서를 확인하시길 권합니다. 복잡한 관계형 쿼리는 Drizzle로, 성능 크리티컬한 단순 읽기 경로는 bun:sqlite 직접 호출로 섞는 조합도 현실적입니다.
프로젝트 상황별로 어느 층까지 쓰면 좋을지
세 가지 층(제네릭 API / .as(Class) / 코드젠)을 순서대로 밟아야 하는 건 아닙니다. 프로젝트가 놓인 상황에 따라 어느 층에서 멈추는 게 더 합리적인지 다릅니다.
기준을 짧게 정리하면 이렇습니다. 스키마가 자주 흔들리고 쿼리 수가 많다면 코드젠의 초기 투자 대비 회수가 빠릅니다. 반대로 쿼리가 수십 개 미만이고 스키마가 안정적이라면 제네릭 API만으로도 충분하고, 오히려 도구 하나 덜 두는 편이 유지보수 부담이 작습니다. .as(Class)는 그 사이에서 "엔티티에 붙이고 싶은 로직이 있는가"로 판단하면 자연스럽습니다.
마무리
bun:sqlite가 가져다주는 핵심 가치는 ORM 없이도 타입이 살아있는 쿼리 레이어를 낮은 의존성으로 구성할 수 있다는 겁니다. 엣지 환경에서 번들과 콜드 스타트를 아끼고 싶은 상황, ORM의 추상화 비용을 감당하기엔 도메인이 작은 상황에서 특히 설득력이 있습니다.
동시에 Cloudflare Workers 비호환, 동시 쓰기 한계, 마이그레이션 도구 미비 같은 제약은 미리 알고 시작해야 나중에 낭패를 피할 수 있습니다. 도구 선택은 항상 프로젝트 컨텍스트의 문제라, 이 글이 결정을 대신해주기보다 "우리 상황엔 어디까지 쓰면 되겠다"를 가늠하는 재료가 되면 좋겠습니다.