Bun 2.0 완전 가이드 — 내장 SQLite·S3 클라이언트·HTML 번들러로 외부 패키지 없이 프로덕션 풀스택 서버 구축하기
Node.js 프로젝트에서 package.json을 열어보면 항상 비슷한 패턴이 보입니다. better-sqlite3, @aws-sdk/client-s3, webpack 혹은 vite, jest 또는 vitest... 이런 의존성 하나하나가 쌓일수록 node_modules는 기하급수적으로 커지고, CI 빌드 시간도 늘어나며, 공급망 공격(supply chain attack) 노출 위험도 함께 올라갑니다. 간단한 내부 어드민 툴 하나 만들려다 의존성 설정에만 몇 시간을 쓴 경험이 있는 분이라면 이 문제가 남의 이야기로 느껴지지 않을 겁니다.
Bun은 이 문제에 근본적인 방식으로 접근합니다. SQLite, S3, PostgreSQL/MySQL 클라이언트, HTTP 서버, TypeScript 런타임, 번들러, 테스트 러너를 단일 실행 파일 하나에 통합했습니다. SQLite·S3 클라이언트는 2025년 1월 1.2에서, HTML 번들러와 MySQL/MariaDB 지원은 같은 해 10월 1.3에서 각각 추가됐고, 이 기능들이 안정화·통합된 현재 버전이 Bun 2.0입니다. HTML 파일을 서버 진입점으로 삼아 React 앱과 API 서버를 단일 프로세스로 돌리는 것, npm 패키지 없이 S3에 파일을 올리고 SQLite 데이터를 조회하는 것이 이제 현실적인 프로덕션 선택지가 됐습니다.
이 글에서는 Bun의 내장 API들이 실제로 어떻게 작동하는지, 어떤 상황에서 쓸 만하고 어디서 조심해야 하는지를 코드와 실제 사례를 통해 살펴봅니다. Node.js나 Deno를 쓰고 있고 성능·개발 경험 개선에 관심 있는 분이라면 유용한 참고가 될 겁니다.
핵심 개념
Bun은 런타임이지만, 그 이상입니다
Bun은 Zig로 작성된 JavaScript/TypeScript 런타임입니다. 실행 엔진은 V8이 아닌 WebKit의 JavaScriptCore(JSC)를 사용하는데, 이게 콜드 스타트에 직접적인 영향을 줍니다. Node.js의 V8 초기화가 수백 ms 수준인 것에 비해 Bun은 10ms 미만으로 보고되고 있습니다(로컬 환경·단순 스크립트 기준이며 실행 환경에 따라 차이가 있습니다). 서버리스나 엣지 환경에서 이 차이가 크게 체감됩니다.
그런데 Bun을 단순히 "빠른 Node.js"로만 보면 핵심을 놓칩니다. 실제 차별점은 무엇을 런타임 안에 내장했느냐입니다.
Node.js에서 외부 패키지로 해결해야 했던 기능들이 Bun 자체에 들어와 있습니다. 단순한 편의성 문제가 아니라, 의존성 그래프가 줄어들고 번들 크기가 작아지며, 빌드 파이프라인이 단순해진다는 의미입니다.
bun:sqlite — 동기식이라 오히려 직관적인 SQLite
Bun의 SQLite 드라이버는 better-sqlite3에서 영감을 받아 완전 동기식으로 설계됐습니다. JavaScript의 비동기 우선 관행과 다르게 느껴질 수 있는데, SQLite처럼 로컬 파일 기반 DB에서는 async/await로 래핑된 동기 작업보다 직접적인 동기 호출이 실제로 더 직관적입니다.
import { Database } from "bun:sqlite";
const db = new Database("app.db");
db.run(`
CREATE TABLE IF NOT EXISTS posts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
body TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
// 단건 조회는 get()
const post = db.query("SELECT * FROM posts WHERE id = ?").get(1);
// 다건 조회는 all()
const posts = db.query("SELECT * FROM posts ORDER BY created_at DESC").all();
// prepared statement는 라우터 밖에서 한 번만 생성해두는 것이 좋습니다
const insertPost = db.prepare("INSERT INTO posts (title, body) VALUES (?, ?)");
insertPost.run("첫 번째 글", "내용입니다");공식적으로 better-sqlite3 대비 36배, deno.land/x/sqlite 대비 89배 빠른 읽기 성능을 내세웁니다. 소규모 서비스나 내부 도구에서 PostgreSQL 같은 별도 DB 서버 없이 SQLite로 충분한 경우가 생각보다 많은데, 외부 패키지 없이 쓸 수 있다는 게 이 상황에서 큰 장점입니다.
동기 API와 동시 요청 처리
"bun:sqlite가 동기 API라면 동시에 요청이 들어오면 블로킹되는 거 아닌가?" — 자연스러운 질문입니다. Bun은 Node.js와 마찬가지로 단일 스레드 이벤트 루프 위에서 실행됩니다. 동기 SQLite 호출이 진행되는 동안 다른 네트워크 I/O 이벤트 처리는 잠시 대기하게 됩니다. 대부분의 SQLite 쿼리는 수 마이크로초 단위로 완료되기 때문에 트래픽이 적당한 서버에서는 실질적인 문제가 되지 않지만, 대용량 집계 쿼리가 많은 경우에는 이 점을 염두에 둬야 합니다. SQLite는 기본적으로 단일 쓰기 잠금 구조이므로, 쓰기 트래픽이 높아지면 PostgreSQL로 이전하는 것이 자연스러운 경로입니다.
Bun.S3Client — aws-sdk 없이 S3 연동
S3 연동은 Node.js 생태계에서 @aws-sdk/client-s3가 거의 표준이었는데, 이 패키지 하나가 무겁습니다. Bun 1.2부터 내장된 Bun.S3Client는 AWS S3는 물론 Cloudflare R2, MinIO, DigitalOcean Spaces, Backblaze B2 등 S3 호환 스토리지에 모두 연결됩니다.
const s3 = new Bun.S3Client({
accessKeyId: process.env.AWS_ACCESS_KEY_ID,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
bucket: process.env.S3_BUCKET,
region: process.env.AWS_REGION ?? "us-east-1",
// Cloudflare R2라면 endpoint를 추가로 지정합니다
// endpoint: "https://<account-id>.r2.cloudflarestorage.com",
});
// 업로드 — JSON, 텍스트, Buffer 모두 가능
await s3.write("uploads/report.json", JSON.stringify({ generated: true }));
// 다운로드
const file = s3.file("uploads/report.json");
const content = await file.json(); // .text(), .arrayBuffer()도 동작
// 프리사인 URL은 네트워크 요청 없이 즉시 생성
const url = s3.presign("uploads/report.json", { expiresIn: 3600 });공식 발표 기준 @aws-sdk/client-s3 대비 업로드 속도 5배 향상이 언급됩니다. 이미지 처리 파이프라인처럼 S3 업로드가 병목인 환경에서 실질적인 개선이 보고되고 있습니다. 실제 개선폭은 네트워크 환경, 파일 크기, 업로드 패턴에 따라 달라집니다.
HTML 번들러와 Bun.serve() — 프론트·백 단일 프로세스
Bun 1.3의 핵심 기능입니다. HTML 파일을 import해서 라우터에 그냥 넘기면 Bun이 그 안의 React, TypeScript, CSS를 자동으로 번들링해줍니다.
// index.ts
import dashboard from "./dashboard.html"; // React + TS + CSS 자동 번들링
Bun.serve({
development: process.env.NODE_ENV !== "production", // HMR 자동 활성화
routes: {
"/": dashboard, // 프론트엔드 라우트
"GET /api/users": async () => {
const users = db.query("SELECT * FROM users").all();
return Response.json(users);
},
"POST /api/users": async (req) => {
const { name } = await req.json();
db.run("INSERT INTO users (name) VALUES (?)", [name]);
return new Response("Created", { status: 201 });
},
},
});<!-- dashboard.html -->
<!DOCTYPE html>
<html>
<head><title>Dashboard</title></head>
<body>
<div id="root"></div>
<script type="module" src="./dashboard.tsx"></script>
</body>
</html>bun run index.ts 하나로 React 앱 서빙과 API 서버가 같이 뜹니다. development: true이면 React Fast Refresh 기반 HMR도 자동으로 활성화되고, 브라우저 콘솔 로그가 터미널에도 포워딩됩니다. Webpack·Vite 설정 파일이 필요 없습니다.
Bun.sql — DB 종류에 상관없이 같은 문법
Bun 1.2에서 PostgreSQL 클라이언트로 시작해 1.3에서 MySQL·MariaDB까지 확장된 통합 SQL API입니다. 환경변수 DATABASE_URL만 설정하면 어떤 DB든 같은 문법으로 접근됩니다.
const sql = Bun.sql; // DATABASE_URL 환경변수로 자동 연결
// tagged template literal이 파라미터를 별도 바인딩으로 처리하므로
// SQL 인젝션 방지가 자동으로 이루어집니다
const users = await sql`SELECT * FROM users WHERE active = ${true}`;
const user = await sql`SELECT * FROM users WHERE id = ${userId} LIMIT 1`;
await sql`INSERT INTO users (name, email) VALUES (${name}, ${email})`;${} 안의 값은 SQL 문자열에 직접 삽입되지 않고, DB 드라이버 수준의 파라미터 바인딩으로 전달됩니다. userId에 '; DROP TABLE users; -- 같은 값이 들어와도 쿼리 구조 자체가 변형되지 않는 이유입니다.
bun:sqlite는 동기 API이고 Bun.sql은 비동기 API입니다. 두 가지를 함께 쓸 때 이 차이를 헷갈리지 않도록 주의가 필요합니다.
실전 적용
시나리오 1: 의존성 제로 REST API 서버
내부 어드민 도구나 프로토타입처럼 PostgreSQL 서버를 별도로 띄울 필요 없는 상황에서 SQLite + Bun.serve 조합이 빛을 발합니다.
아래는 최소 동작 예시입니다. 실제 운영에서는 입력 유효성 검사와 예외 처리를 추가하세요.
// server.ts — npm 패키지 0개
import { Database } from "bun:sqlite";
const db = new Database("app.db");
db.run(`
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
email TEXT UNIQUE
)
`);
// prepared statement는 라우터 밖에서 한 번만 생성합니다
const getUsers = db.prepare("SELECT * FROM users");
const getUserById = db.prepare("SELECT * FROM users WHERE id = ?");
const insertUser = db.prepare("INSERT INTO users (name, email) VALUES (?, ?)");
Bun.serve({
port: 3000,
routes: {
"GET /users": () => {
return Response.json(getUsers.all());
},
"GET /users/:id": (req) => {
const user = getUserById.get(req.params.id);
if (!user) return new Response("Not Found", { status: 404 });
return Response.json(user);
},
"POST /users": async (req) => {
const { name, email } = await req.json();
insertUser.run(name, email);
return new Response("Created", { status: 201 });
},
},
});prepared statement를 요청마다 새로 생성하면 SQLite 성능 이점을 상당 부분 날립니다. db.query()를 라우터 핸들러 안에 넣는 패턴은 고트래픽 환경에서 차이가 확연히 드러납니다.
시나리오 2: S3 업로드가 포함된 풀스택 서버
아래는 최소 동작 예시입니다. 실제 운영에서는 파일 타입·크기 검증과 예외 처리를 추가하세요.
// fullstack-server.ts
import { Database } from "bun:sqlite";
import dashboard from "./dashboard.html";
const db = new Database("app.db");
db.run(`
CREATE TABLE IF NOT EXISTS files (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT,
s3_key TEXT,
uploaded_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
const s3 = new Bun.S3Client({
accessKeyId: process.env.AWS_ACCESS_KEY_ID,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
bucket: process.env.S3_BUCKET,
region: process.env.AWS_REGION ?? "ap-northeast-2",
});
const insertFile = db.prepare("INSERT INTO files (name, s3_key) VALUES (?, ?)");
const getFiles = db.prepare("SELECT * FROM files ORDER BY uploaded_at DESC");
Bun.serve({
development: process.env.NODE_ENV !== "production",
routes: {
"/": dashboard,
"POST /api/upload": async (req) => {
const form = await req.formData();
const file = form.get("file");
if (!(file instanceof File)) {
return new Response("file 필드가 없습니다", { status: 400 });
}
const key = `uploads/${Date.now()}-${file.name}`;
await s3.write(key, await file.arrayBuffer());
insertFile.run(file.name, key);
// 프리사인 URL은 네트워크 요청 없이 즉시 생성됩니다
const url = s3.presign(key, { expiresIn: 86400 });
return Response.json({ url, key });
},
"GET /api/files": () => Response.json(getFiles.all()),
},
});시나리오 3: 기존 Node.js 서비스 하이브리드 전환
기존 서비스 전체를 Bun으로 바꾸는 건 리스크가 클 수 있습니다. 현실적인 접근은 빌드 도구만 Bun으로 전환하는 하이브리드 전략입니다.
{
"scripts": {
"dev": "bun run src/index.ts",
"build": "bun build src/index.ts --outdir dist --target node",
"start": "node dist/index.js",
"test": "bun test"
}
}이 방식으로 bun install의 빠른 설치 속도(npm 대비 20~30배 수준), bun test의 빠른 테스트 실행을 가져가면서도 프로덕션 실행은 검증된 Node.js로 유지할 수 있습니다.
중요한 제약이 있습니다. bun:sqlite나 Bun.S3Client 같은 Bun 내장 API를 사용한 코드는 --target node로 빌드해도 Node.js에서 실행되지 않습니다. 이 하이브리드 전략은 Bun 내장 API를 쓰지 않는 기존 코드베이스에 적합합니다. 내장 API를 적극 활용하고 싶다면 Bun을 프로덕션 런타임으로 직접 채택해야 합니다.
장단점 분석
런타임 비교
| 항목 | Bun 2.0 | Node.js 22 | Deno 2.x |
|---|---|---|---|
| HTTP 처리량 | ~180,000 req/s | ~65,000 req/s | ~75,000 req/s |
| 콜드 스타트 | < 10ms | ~200ms | ~100ms |
| TypeScript 실행 | 네이티브 | --experimental-strip-types |
네이티브 |
| 패키지 설치 속도 | npm 대비 20~30배 빠름 | 기준 | 환경·버전 의존 |
| 내장 SQLite | O bun:sqlite |
X | X |
| 내장 S3 클라이언트 | O Bun.S3Client |
X | X |
| npm 생태계 호환성 | ~92% | 100% | ~95% |
네이티브 애드온 .node |
X | O | 제한적 |
HTTP 처리량은 단순 엔드포인트 기준이며 하드웨어·concurrency 설정에 따라 달라집니다. npm 호환성 수치는 상위 패키지 기준 추정치입니다.
솔직하게 보는 단점
| 제약 | 구체적 상황 | 영향도 |
|---|---|---|
| 네이티브 애드온 미지원 | sharp, bcrypt 네이티브 빌드, node-gyp 의존 패키지 실행 불가 |
높음 |
| 메모리 사용량 | 일부 실운영 환경에서 Node.js 대비 50~75% 높은 RAM 소비 보고 | 중간 |
| APM·PM2 호환성 | 일부 APM 에이전트와 PM2가 process API 차이로 오작동 가능 |
중간 |
| npm 호환성 | 약 8% 패키지는 작동하지 않을 수 있음 | 프로젝트 의존적 |
| 공식 Lambda 런타임 없음 | AWS 커스텀 런타임으로 구성 필요 | 낮음~중간 |
| I/O 바운드 실무 격차 | DB 쿼리가 지배적인 API 서버에서 런타임 차이가 미미할 수 있음 | 벤치마크 과신 주의 |
벤치마크 숫자가 매력적으로 보이지만, DB 쿼리가 대부분의 시간을 차지하는 전형적인 REST API 서버에서는 런타임 차이가 미미할 수 있습니다. 병목이 어디에 있는지 먼저 확인하는 게 좋습니다.
마이그레이션 의사결정 흐름
실무에서 흔한 실수
bun:sqlite를 처음 쓸 때 async/await를 습관적으로 붙이는 경우가 있습니다. 동기 API라 await가 필요 없고, 붙여도 동작은 하지만 의도가 불명확해집니다.
// 불필요한 패턴 (동작하지만 혼란스러움)
const users = await db.query("SELECT * FROM users").all();
// 올바른 패턴 — bun:sqlite는 동기
const users = db.query("SELECT * FROM users").all();
// Bun.sql은 반대로 비동기입니다
const rows = await sql`SELECT * FROM users`;마치며
Bun 2.0이 만들어놓은 그림을 정리하면 이렇습니다. SQLite는 bun:sqlite로, S3는 Bun.S3Client로, PostgreSQL·MySQL은 Bun.sql로, 프론트·백 서버는 Bun.serve()로 — 이 조합이면 많은 경우에 외부 npm 패키지 없이 프로덕션 수준 서버를 구성할 수 있습니다. 콜드 스타트는 10ms 미만, 패키지 설치는 npm 대비 20~30배 빠릅니다.
다만 네이티브 애드온, PM2, 일부 APM 도구 의존성이 있거나 메모리 제약이 심한 환경이라면 신중한 검토가 먼저입니다. DB 쿼리가 병목인 API 서버에서 벤치마크 숫자를 그대로 기대하면 실망할 수 있습니다. 런타임을 바꾼다고 병목이 사라지지는 않으니까요.
지금 시작해보시려면 이 순서가 현실적입니다.
- 신규 사이드 프로젝트나 내부 도구는
bun init으로 시작하세요. 리스크 없이 내장 SQLite와Bun.serve를 직접 체험할 수 있습니다. - 기존 프로젝트에서는
bun install과bun test만 먼저 전환해볼 수도 있습니다. 런타임 교체 없이 빌드 속도 개선 효과를 즉시 가져올 수 있는 가장 낮은 리스크의 시작점입니다. - S3 연동이 있는 서비스라면
@aws-sdk/client-s3를Bun.S3Client로 교체해보는 것도 좋은 실험입니다. 패키지 하나를 제거하면서 성능 변화를 직접 측정해볼 수 있습니다.