개인정보처리방침© 2026 DEV BAK - 기술블로그. All rights reserved.
DEV BAK - 기술블로그
포스트 검색
Backend

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"로만 보면 핵심을 놓칩니다. 실제 차별점은 무엇을 런타임 안에 내장했느냐입니다.

다이어그램 1

Node.js에서 외부 패키지로 해결해야 했던 기능들이 Bun 자체에 들어와 있습니다. 단순한 편의성 문제가 아니라, 의존성 그래프가 줄어들고 번들 크기가 작아지며, 빌드 파이프라인이 단순해진다는 의미입니다.


bun:sqlite — 동기식이라 오히려 직관적인 SQLite

Bun의 SQLite 드라이버는 better-sqlite3에서 영감을 받아 완전 동기식으로 설계됐습니다. JavaScript의 비동기 우선 관행과 다르게 느껴질 수 있는데, SQLite처럼 로컬 파일 기반 DB에서는 async/await로 래핑된 동기 작업보다 직접적인 동기 호출이 실제로 더 직관적입니다.

typescript
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 호환 스토리지에 모두 연결됩니다.

typescript
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를 자동으로 번들링해줍니다.

typescript
// 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 });
    },
  },
});
html
<!-- 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 설정 파일이 필요 없습니다.

다이어그램 2


Bun.sql — DB 종류에 상관없이 같은 문법

Bun 1.2에서 PostgreSQL 클라이언트로 시작해 1.3에서 MySQL·MariaDB까지 확장된 통합 SQL API입니다. 환경변수 DATABASE_URL만 설정하면 어떤 DB든 같은 문법으로 접근됩니다.

typescript
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 조합이 빛을 발합니다.

아래는 최소 동작 예시입니다. 실제 운영에서는 입력 유효성 검사와 예외 처리를 추가하세요.

typescript
// 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 업로드가 포함된 풀스택 서버

아래는 최소 동작 예시입니다. 실제 운영에서는 파일 타입·크기 검증과 예외 처리를 추가하세요.

typescript
// 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으로 전환하는 하이브리드 전략입니다.

json
{
  "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 서버에서는 런타임 차이가 미미할 수 있습니다. 병목이 어디에 있는지 먼저 확인하는 게 좋습니다.

마이그레이션 의사결정 흐름

다이어그램 3

실무에서 흔한 실수

bun:sqlite를 처음 쓸 때 async/await를 습관적으로 붙이는 경우가 있습니다. 동기 API라 await가 필요 없고, 붙여도 동작은 하지만 의도가 불명확해집니다.

typescript
// 불필요한 패턴 (동작하지만 혼란스러움)
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 서버에서 벤치마크 숫자를 그대로 기대하면 실망할 수 있습니다. 런타임을 바꾼다고 병목이 사라지지는 않으니까요.

지금 시작해보시려면 이 순서가 현실적입니다.

  1. 신규 사이드 프로젝트나 내부 도구는 bun init으로 시작하세요. 리스크 없이 내장 SQLite와 Bun.serve를 직접 체험할 수 있습니다.
  2. 기존 프로젝트에서는 bun install과 bun test만 먼저 전환해볼 수도 있습니다. 런타임 교체 없이 빌드 속도 개선 효과를 즉시 가져올 수 있는 가장 낮은 리스크의 시작점입니다.
  3. S3 연동이 있는 서비스라면 @aws-sdk/client-s3를 Bun.S3Client로 교체해보는 것도 좋은 실험입니다. 패키지 하나를 제거하면서 성능 변화를 직접 측정해볼 수 있습니다.

참고 자료

  • Bun 공식 블로그
  • Bun 공식 SQLite 문서
  • Bun 공식 S3 문서
  • Bun 풀스택 개발 서버 문서
  • Bun 1.2 Deep Dive: SQLite, S3 — DEV Community
  • The Register: Bun 1.3 기능 리뷰
  • Bun vs Node.js 2026 마이그레이션 — Strapi 블로그
  • Bun vs Deno vs Node.js 2026 벤치마크 — DEV Community
  • Bun 프로덕션 실전기 — akousa.net
  • Bun 1.2 프로덕션 마이그레이션 사례 — DEV Community
#Bun#SQLite#S3#TypeScript#JavaScript런타임#Node.js
공유하기

목차

핵심 개념Bun은 런타임이지만, 그 이상입니다bun:sqliteBun.S3ClientHTML 번들러와Bun.sql실전 적용시나리오 1: 의존성 제로 REST API 서버시나리오 2: S3 업로드가 포함된 풀스택 서버시나리오 3: 기존 Node.js 서비스 하이브리드 전환장단점 분석런타임 비교솔직하게 보는 단점마이그레이션 의사결정 흐름실무에서 흔한 실수마치며참고 자료

추천 포스트

Cloudflare Durable Objects + Hono로 실시간 협업 기능 구현하기
Backend

Cloudflare Durable Objects + Hono로 실시간 협업 기능 구현하기

WebSocket은 연결이 유지되어야 한다. Worker는 요청이 끝나면 메모리가 사라진다. 이 두 조건이 충돌하는 지점에서 Durable Objects가 등장한다. 각 인스턴스가 전 세계에서 단 하나만 실행되며, 지속적인 인메모리 상태와 내장 SQLite 스토리지를…

2026년 07월 14일읽는 데 17분
tRPC v11이 Next.js App Router에서 REST를 대체하는 방식 — 코드 생성 없는 end-to-end 타입 안전성과 서버 스트리밍
Backend

tRPC v11이 Next.js App Router에서 REST를 대체하는 방식 — 코드 생성 없는 end-to-end 타입 안전성과 서버 스트리밍

REST API 엔드포인트를 하나 추가할 때마다 반복하는 작업이 있습니다. 서버에 라우트 파일을 만들고, 요청·응답 타입을 별도로 정의하고, 클라이언트 fetch 래퍼를 작성하고, 두 파일 사이에서 타입이 어긋나지 않았는지 직접 확인하는 과정입니다. 엔드포인트가 늘어날…

2026년 07월 13일읽는 데 22분
Deno 2 실전 가이드 — Node.js 호환 모드·내장 KV·Queues로 외부 인프라 없이 서버리스 백엔드 구축하기
Backend

Deno 2 실전 가이드 — Node.js 호환 모드·내장 KV·Queues로 외부 인프라 없이 서버리스 백엔드 구축하기

Deno 2가 2024년 10월에 공개됐을 때 백엔드 개발자들이 가장 먼저 던진 질문은 하나였습니다. 기존 npm 생태계와 얼마나 자연스럽게 공존하는가. 이전 버전의 Deno는 "npm이 안 된다"는 이유 하나로 실무 후보에서 곧바로 제외됐으니까요. 이 글은 공식 문서…

2026년 07월 13일읽는 데 23분
Temporal 클러스터 없이 엣지에서 내결함성 워크플로를 — Cloudflare Workflows의 재시도·인간 승인·타임아웃 처리 방식
Backend

Temporal 클러스터 없이 엣지에서 내결함성 워크플로를 — Cloudflare Workflows의 재시도·인간 승인·타임아웃 처리 방식

서버리스 환경에서 장기 실행 작업을 다루다 보면 공통된 문제를 마주칩니다. 요청이 실패했을 때 처음부터 다시 시작해야 할까, 아니면 중단된 지점부터 재개할 수 있을까. 내구성 있는 실행(Durable Execution) 이라는 개념이 이 고민의 답인데, 전통적으로는 T…

2026년 07월 12일읽는 데 21분
Node.js 네이티브 TypeScript 실행 완전 가이드
Backend

Node.js 네이티브 TypeScript 실행 완전 가이드

기준 버전: Node.js 24 LTS. Node.js 26에서 달라지는 동작(enum 처리 변화 등)은 본문 중 별도 구획에서 다룹니다. node app.ts 가 그냥 된다는 게 어느 시점부터 사실이 되었습니다. 빌드 없이, 추가 도구 없이, 플래그 없이. Node.…

2026년 07월 12일읽는 데 19분
BullMQ로 구축하는 프로덕션 작업 큐 — 우선순위, 재시도 백오프, Dead Letter Queue를 TypeScript로 구현하기
Backend

BullMQ로 구축하는 프로덕션 작업 큐 — 우선순위, 재시도 백오프, Dead Letter Queue를 TypeScript로 구현하기

API 핸들러가 이메일 발송이나 결제 요청을 직접 처리하면, 외부 서비스 응답 지연이 곧 HTTP 타임아웃으로 이어집니다. 비동기 작업 큐는 이 결합을 끊습니다. API는 잡을 큐에 넣는 즉시 응답하고, 실제 처리는 백그라운드 워커가 맡습니다. 그런데 단순히 "큐에 넣…

2026년 07월 12일읽는 데 18분