`pg`와 `aws-sdk`를 지우고 나서 — Bun.sql·Bun.s3로 외부 드라이버 없이 PostgreSQL과 오브젝트 스토리지 연결하기
Node.js 프로젝트를 오래 운영하다 보면 package.json이 조용히 두꺼워집니다. PostgreSQL을 쓰면 pg가 들어오고, 커넥션 풀을 관리하려면 pg-pool이 따라오고, 타입 안전성을 위해 @types/pg까지 추가됩니다. S3에 파일 하나 올리려고 시작한 작업이 @aws-sdk/client-s3, @aws-sdk/lib-storage를 거쳐 끝나는 경험도 흔하죠. 의존성 하나하나는 선택이었는데, 어느 순간 그게 족쇄처럼 느껴지기 시작합니다.
2025년 1월 Bun v1.2 릴리스에서 눈길을 끈 지점이 여기였습니다. npm install 없이 PostgreSQL에 쿼리를 날리고, S3에 파일을 올리는 내장 클라이언트를 런타임이 직접 제공하기 시작한 것입니다. Bun.sql과 Bun.s3 — 두 API를 처음 봤을 때는 "그래서 실제로 쓸 만한가?"라는 의문이 들었는데, 최근 사이드 프로젝트를 통째로 Bun으로 옮기면서 답이 꽤 명확해졌습니다.
이 글은 그 이전 경험을 순서대로 되짚어 봅니다. 환경변수를 설정하고 첫 쿼리를 날리는 순간부터, 트랜잭션 걸다가 만난 API 이름 이슈, S3 업로드 엔드포인트를 만드는 과정, R2/MinIO 연결에서 삽질한 지점, 그리고 마지막으로 Drizzle 얹기까지. 각 단계에서 마주친 함정을 그 자리에서 이야기하는 게 이 글의 목적입니다.
시작 전에 그림 한 장
의존성 다이어그램을 놓고 보면 Bun이 흡수한 게 무엇인지 명확해집니다.
외부 패키지가 하던 역할을 런타임이 흡수하는 구조입니다. import { sql } from 'bun' 한 줄로 커넥션 풀 포함 PostgreSQL 클라이언트가 준비됩니다. 실제 코드로 넘어가겠습니다.
1단계 — 환경변수 하나로 첫 쿼리를 날려보기
DATABASE_URL이 설정되어 있으면 별도 커넥션 설정 없이 바로 쿼리가 나갑니다.
import { sql } from "bun";
// SELECT
const users = await sql`SELECT * FROM users WHERE active = ${true} LIMIT ${10}`;
// INSERT
const [newUser] = await sql`
INSERT INTO users (name, email)
VALUES (${name}, ${email})
RETURNING *
`;
// UPDATE
await sql`UPDATE posts SET views = views + 1 WHERE id = ${postId}`;Tagged Template Literal 문법에서 ${} 안에 들어간 값은 자동으로 파라미터 바인딩으로 처리됩니다. SQL 인젝션을 구조적으로 방지하는 방식인데, 이스케이프를 "잊어버릴 방법이 없다"는 점이 핵심입니다. 처음엔 그냥 문자열 보간처럼 보여서 의심했는데, 실제로는 드라이버 레벨에서 완전히 분리됩니다.
명시적 클라이언트 인스턴스가 필요할 때는 SQL 클래스에 커넥션 문자열을 넘기면 됩니다. 이 방식은 뒤에서 Drizzle 연동할 때 다시 씁니다.
import { SQL } from "bun";
const client = new SQL(process.env.DATABASE_URL!);
const posts = await client`SELECT * FROM posts WHERE author_id = ${userId}`;풀 크기, 타임아웃 같은 세부 옵션은 버전에 따라 키 이름이 달라질 수 있어서 Bun SQL 공식 문서에서 확인하는 게 안전합니다.
2단계 — 트랜잭션에서 만난 API 이름 이슈
여기서 첫 번째 삽질이 있었습니다. sql.transaction()으로 감싸려다가 메서드가 없다는 에러가 났습니다. Bun.sql은 postgres.js의 API 설계를 따르는데, 트랜잭션 진입점 이름이 begin입니다.
await sql.begin(async (tx) => {
await tx`UPDATE accounts SET balance = balance - ${amount} WHERE id = ${fromId}`;
await tx`UPDATE accounts SET balance = balance + ${amount} WHERE id = ${toId}`;
});콜백 안에서 받은 tx는 같은 커넥션·트랜잭션 컨텍스트를 공유합니다. 예외가 발생하면 자동으로 롤백됩니다. 세이브포인트가 필요하다면 tx.savepoint()도 있는데, 정확한 시그니처는 Bun SQL 문서의 Transactions 섹션을 확인해 주세요.
함정 요약: postgres.js/Bun.sql은 .begin(), pg는 client.query('BEGIN') 스타일. 헷갈리면 문서를 열어놓고 시작하는 게 빠릅니다.
3단계 — Bun.s3로 첫 파일 올리기
Bun.s3도 환경변수를 자동으로 읽습니다. AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, S3_BUCKET 정도만 있어도 기본 인스턴스가 만들어집니다.
// 환경변수 자동 인식
const file = Bun.s3.file("images/photo.jpg");
const buffer = await file.arrayBuffer();명시적 인스턴스가 필요할 때는 Bun.S3Client를 직접 생성합니다.
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: "ap-northeast-2",
});
// 업로드
await s3.write("uploads/avatar.png", imageBuffer);
// 다운로드
const data = await s3.file("uploads/avatar.png").arrayBuffer();S3File 객체는 Web API의 Blob처럼 동작하기 때문에 Response나 Request에 직접 전달하는 것도 가능합니다. 스트리밍 응답 처리가 자연스러워지는 지점입니다.
Presigned URL
클라이언트 직접 업로드용 PUT과 다운로드용 GET, 두 방향 모두 만들 수 있습니다. 콘텐츠 타입 지정을 포함한 옵션 키 이름은 버전에 따라 다를 수 있어 S3Client 레퍼런스의 presign 항목을 함께 확인하는 걸 권합니다.
// 다운로드
const downloadUrl = s3.presign("uploads/doc.pdf", {
method: "GET",
expiresIn: 600,
});
// 업로드 (콘텐츠 타입 등 세부 옵션 키는 레퍼런스 참조)
const uploadUrl = s3.presign("uploads/doc.pdf", {
method: "PUT",
expiresIn: 3600,
});4단계 — HTTP 서버에서 업로드 엔드포인트 조립하기
Bun.serve와 Bun.s3를 합치면 외부 미들웨어 없이 멀티파트 업로드 엔드포인트가 완성됩니다.
const s3 = new Bun.S3Client({
bucket: process.env.S3_BUCKET,
region: process.env.AWS_REGION,
});
Bun.serve({
port: 3000,
async fetch(req) {
const url = new URL(req.url);
if (req.method === "POST" && url.pathname === "/upload") {
const formData = await req.formData();
const file = formData.get("file") as File;
if (!file) {
return Response.json({ error: "파일이 없습니다" }, { status: 400 });
}
const key = `uploads/${crypto.randomUUID()}-${file.name}`;
await s3.write(key, file);
const downloadUrl = s3.presign(key, { method: "GET", expiresIn: 3600 });
return Response.json({ key, url: downloadUrl });
}
return new Response("Not Found", { status: 404 });
},
});multer 같은 미들웨어 없이 req.formData()가 그대로 동작하는 게 체감상 가장 편했습니다.
5단계 — SQL + S3를 엮을 때 고아 레코드를 다루는 방법
실무에서 자주 마주치는 패턴이 있습니다. "S3에 파일을 올리고, DB에 그 파일의 메타데이터를 저장한다." 순진하게 짜면 이렇게 됩니다.
async function uploadFileNaive(file: File, userId: number) {
const key = `files/${userId}/${crypto.randomUUID()}-${file.name}`;
await s3.write(key, file);
const [record] = await sql`
INSERT INTO file_uploads (key, name, size, user_id, uploaded_at)
VALUES (${key}, ${file.name}, ${file.size}, ${userId}, NOW())
RETURNING id
`;
return { key, id: record.id };
}문제는 S3 업로드는 성공했는데 DB INSERT가 실패하는 경우입니다. S3에 orphan 객체가 남습니다. sql.begin() 안에 S3 업로드를 넣으면 트랜잭션 내부 네트워크 I/O 때문에 커넥션이 오래 점유되고, 그렇다고 방치하면 스토리지 청구서가 조용히 늘어납니다.
제가 실제로 쓰는 패턴은 DB에 pending 상태 레코드를 먼저 남기고, 업로드 성공 시 committed로 승격시키는 방식입니다.
async function uploadFileWithPending(file: File, userId: number) {
const key = `files/${userId}/${crypto.randomUUID()}-${file.name}`;
const [record] = await sql`
INSERT INTO file_uploads (key, name, size, user_id, status, created_at)
VALUES (${key}, ${file.name}, ${file.size}, ${userId}, 'pending', NOW())
RETURNING id
`;
try {
await s3.write(key, file);
await sql`UPDATE file_uploads SET status = 'committed', uploaded_at = NOW() WHERE id = ${record.id}`;
return { key, id: record.id };
} catch (err) {
await sql`UPDATE file_uploads SET status = 'failed' WHERE id = ${record.id}`;
throw err;
}
}이 구조라면 청소 잡은 두 방향으로 돕니다.
status = 'pending'이 일정 시간 이상 지난 레코드 → 앱 크래시로 상태 전이가 끊긴 경우. 대응 S3 객체를 지우거나, 실제 존재하면 committed로 복구.status = 'failed'레코드 → S3 객체 삭제 후 레코드 아카이빙.
주기적 배치가 부담스러우면 S3 라이프사이클 규칙으로 특정 prefix를 자동 만료시키는 방법도 조합할 수 있습니다.
6단계 — R2와 MinIO에서 삽질한 지점
Bun.s3는 S3 호환 API를 따르는 스토리지라면 endpoint만 바꿔서 연결할 수 있습니다. 여기서 저는 URL 스타일 이슈로 시간을 좀 썼습니다.
- MinIO 로컬 서버: 기본적으로 path-style URL만 받습니다(호스트 헤더에 버킷을 붙이지 않음). 가상 호스트 방식으로 요청이 나가면 "SignatureDoesNotMatch" 같은 에러로 끝납니다.
- Cloudflare R2: 가상 호스트 방식과 path-style 둘 다 지원하지만, 커스텀 도메인이나 지역 이슈에 따라 path-style이 더 잘 맞는 경우가 있습니다. "필수"는 아니지만 "일단 켜두면 안전한 스위치"에 가깝습니다.
Bun.S3Client에서 이 동작을 제어하는 옵션 키 이름은 S3Client 레퍼런스에서 확인해 주세요. AWS SDK의 forcePathStyle과 이름이 다를 수 있으므로, 코드 복붙하기 전에 한 번 열어보는 걸 권합니다.
연결 코드 자체는 이 정도 골격입니다(옵션 키는 위 링크에서 확인).
// Cloudflare R2 (개념적 예시)
const r2 = new Bun.S3Client({
endpoint: `https://${process.env.CF_ACCOUNT_ID}.r2.cloudflarestorage.com`,
accessKeyId: process.env.R2_ACCESS_KEY,
secretAccessKey: process.env.R2_SECRET_KEY,
bucket: process.env.R2_BUCKET,
});
// MinIO 로컬 (path-style 필요)
const minio = new Bun.S3Client({
endpoint: "http://localhost:9000",
accessKeyId: "minioadmin",
secretAccessKey: "minioadmin",
bucket: "dev-bucket",
});Backblaze B2, DigitalOcean Spaces 같은 다른 S3 호환 스토리지도 동일한 구조로 연결됩니다.
7단계 — Drizzle ORM 얹기
타입 안전 스키마와 마이그레이션이 필요하다면 Drizzle을 Bun.sql 위에 얹을 수 있습니다. Drizzle 공식 문서에 Bun SQL 연결 가이드가 있고, 이 페이지에 필요한 어댑터 버전과 임포트 경로가 정리되어 있으니 시작할 때 함께 열어두면 됩니다.
bun add drizzle-orm
bun add -d drizzle-kit// schema.ts
import { pgTable, serial, text, boolean, timestamp } from "drizzle-orm/pg-core";
export const users = pgTable("users", {
id: serial("id").primaryKey(),
name: text("name").notNull(),
email: text("email").notNull().unique(),
active: boolean("active").default(true),
createdAt: timestamp("created_at").defaultNow(),
});// db.ts
import { SQL } from "bun";
import { drizzle } from "drizzle-orm/bun-sql";
import * as schema from "./schema";
const client = new SQL(process.env.DATABASE_URL!);
export const db = drizzle({ client, schema });// 사용 예
import { db } from "./db";
import { users } from "./schema";
import { eq } from "drizzle-orm";
const activeUsers = await db.select().from(users).where(eq(users.active, true));new SQL() 인스턴스를 Drizzle에 직접 넘기는 방식이라 pg 어댑터 없이 동작합니다. Prisma는 Bun 지원이 로드맵상 진행 중이고 커뮤니티 어댑터도 있지만, 특정 패키지를 지목했다가 관리 중단되는 경우가 있어서 Prisma의 공식 Bun 지원 상태를 그때그때 확인하는 게 안전합니다. 2026년 7월 기준으로는 Drizzle이 실사용 관점에서 가장 안정적입니다.
다시 짚어보는 트레이드오프
각 단계에서 마주친 이야기를 정리하면, Bun.sql·Bun.s3의 장단점은 이렇게 요약됩니다.
얻는 것
- 제로 의존성:
pg,postgres.js,aws-sdk없이 동작합니다.package.json이 가벼워지고, 공급망 공격 면이 줄어듭니다. - 단일 API: PostgreSQL·MySQL·SQLite를 같은 Tagged Template 문법으로 처리합니다. MySQL/MariaDB 통합은 Bun v1.2.21 릴리스 노트에서 도입 시점을 확인할 수 있습니다.
- SQL 인젝션 구조적 방지: 파라미터가 드라이버 레벨에서 분리되어 이스케이프 실수 자체가 불가능한 구조.
- 성능: Bun v1.2 릴리스 노트에 벤치마크가 공개되어 있습니다. 다만 비교 대상이 중요합니다. 공식 수치는 대부분 Node.js +
pg대비입니다. 이미 postgres.js를 Bun 위에서 쓰고 있다면 벤치마크상 격차는 훨씬 작아지고, 실제 네트워크 지연이 지배적인 환경에서는 체감이 미미할 수 있습니다. 두 베이스라인을 구분하지 않으면 기대가 어긋납니다. - 단일 바이너리 배포:
bun build --compile로 드라이버 포함 실행 파일을 만들 수 있습니다.
감수해야 하는 것
- Bun 런타임 전제: Node.js에서 실행 불가. 팀 전체가 런타임을 바꾸게 됩니다.
- Node.js 호환성의 마지막 몇 %: 대부분은 잘 돌지만,
node-gyp기반 네이티브 애드온이 있는 패키지에서는 실제 blocker가 되기도 합니다. - 관찰가능성 생태계: APM/트레이싱 도구의 Bun 지원이 Node.js만큼 넓지 않습니다. 프로덕션 도입 전 사용 중인 도구의 Bun 지원 상태를 확인해야 합니다.
- ORM 선택지 제한: Drizzle이 가장 안정적, Prisma는 진행 중, TypeORM은 완전 호환은 아님.
- MySQL 성숙도: PostgreSQL 대비 통합 시점이 늦고 프로덕션 검증 사례도 상대적으로 적습니다.
- 일부 최신 API 커버리지: S3 관련 신규 API가 Bun.s3에 즉시 반영되지 않을 수 있습니다. 필요 API의 지원 여부는 oven-sh/bun GitHub 이슈에서 검색해 확인하는 게 확실합니다.
전환 판단에 필요한 분기는 이렇게 정리됩니다.
그래서, 지금 무엇을 확인해야 할까
"새 프로젝트부터 점진적으로 시작하세요" 같은 안전한 결론 대신, 한 가지 구체적인 질문을 남기고 마무리하겠습니다.
당신의 프로덕션 배포 파이프라인에 node-gyp 빌드 스텝이 있습니까?
bcrypt, sharp, sqlite3(그러니까 better-sqlite3 말고 예전 바인딩), 사내에서 만든 네이티브 애드온 — 이 중 하나라도 걸리면 Bun 전환의 첫 관문이 애드온 대체입니다. 걸리는 게 없다면, DATABASE_URL 하나만 설정해서 로컬에서 30분 안에 첫 쿼리를 날려볼 수 있습니다. 그 30분이 이 글이 실제로 유용한지 판단할 가장 빠른 방법입니다.
package.json에서 pg와 @aws-sdk/*가 사라진 diff를 보는 감각은, 그 실험을 해볼 만한 이유는 됩니다.