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

`pg`, `ioredis`, `@aws-sdk/client-s3`를 지워도 되는 이유 — Bun 네이티브 클라이언트로 스토리지 드라이버 의존성 줄이기

새 프로젝트를 시작할 때마다 npm install pg ioredis @aws-sdk/client-s3를 치고, @types/* 패키지를 맞추고, 버전 충돌을 잡다가 정작 코드는 한 줄도 못 쓰고 오후가 지나가는 경험 — 낯설지 않으실 겁니다. Bun 1.2가 SQL·S3 클라이언트를, Bun 1.3이 Redis 클라이언트를 런타임에 내장하면서 질문 하나가 생겼습니다. 데이터·스토리지 드라이버를 직접 설치할 이유가 아직 있는가?

이 글은 Node.js 백엔드를 Bun으로 옮기는 걸 검토 중인 분들을 위해 씁니다. Bun.sql, Bun.redis, Bun.s3 — 이 세 클라이언트가 실제로 어떻게 작동하는지, 어디서 쓸 만하고 어디서 발목을 잡히는지 솔직하게 다룰 예정입니다.

미리 말씀드리면, 런타임 성능보다는 드라이버 의존성 제거·콜드 스타트 단축·CI 파이프라인 단순화 쪽에서 체감 이득이 큽니다. 이 세 가지가 팀의 페인포인트라면 읽어보실 만합니다.


핵심 개념

세 클라이언트의 등장 배경

pg, ioredis, @aws-sdk/client-s3는 각각 순수 JavaScript로 동작하지만, 설치할 때 수십 개의 전이 의존성을 딸려옵니다. 버전 충돌, @types/* 패키지 정합성, 패키지별 설정 파일 — 서비스 하나 띄우기 전에 복잡도가 이미 쌓입니다. 네이티브 바이너리 빌드 문제가 심각한 건 sharp, bcrypt 류의 N-API 애드온이고, 이 부분은 아래 단점 섹션에서 따로 다룹니다.

Bun은 세 드라이버를 런타임 레벨에서 Zig으로 구현해 내장했습니다. 웹 프레임워크(Hono 등)나 ORM(Drizzle 등)은 여전히 npm으로 설치하지만, 데이터·스토리지 드라이버만큼은 npm 설치 없이 사용할 수 있게 됐습니다.

Bun.sql — 하나의 API로 PostgreSQL·MySQL·SQLite

Bun.sql은 태그드 템플릿 리터럴 문법으로 SQL을 실행하는 클라이언트입니다. Bun 1.2에서 PostgreSQL 지원으로 시작해, v1.2.21에서 MySQL/MariaDB가 추가되면서 Bun.SQL 통합 API로 진화했습니다. bun:sqlite는 그보다 앞서 존재했으니, 세 종류의 SQL DB를 동일한 문법으로 다룰 수 있게 된 셈입니다.

API는 두 가지 방식으로 씁니다.

  • import { sql } from "bun" — DATABASE_URL 환경 변수에서 연결 설정을 읽는 모듈 싱글턴
  • import { SQL } from "bun" — 연결 옵션을 직접 넘겨 인스턴스를 만드는 클래스

대부분의 경우 sql(소문자)을 쓰고, 테스트에서 :memory: SQLite를 프로덕션 DB와 분리해 제어하고 싶을 때처럼 연결을 명시적으로 관리할 때 SQL(대문자 클래스)을 씁니다.

typescript
import { sql } from "bun";
 
// 인터폴레이션은 자동으로 파라미터 바인딩됩니다 — SQL Injection 걱정 없음
const users = await sql`SELECT * FROM users WHERE id = ${userId}`;
 
// 트랜잭션 — sql.begin()으로 콜백 안에서 원자적 실행
await sql.begin(async (tx) => {
  await tx`INSERT INTO orders ${sql(order)}`;
  await tx`UPDATE inventory SET stock = stock - 1 WHERE id = ${itemId}`;
});

내부적으로 자동 prepared statement, 쿼리 파이프라이닝, 커넥션 풀링, 바이너리 wire 프로토콜을 처리합니다. 바이너리 프로토콜은 텍스트 기반 프로토콜과 달리 직렬화·역직렬화 오버헤드가 작아, 고빈도 쿼리에서 성능 차이가 납니다.

태그드 템플릿이 처음엔 낯설지만, ORM보다 실제 실행 쿼리가 투명하게 보이고 동적 조건 조합 유틸리티도 내장되어 있어 금방 적응됩니다. Drizzle ORM도 Bun.sql 위에서 동작하니, 타입 안전성이 중요하다면 함께 사용하면 됩니다.

다이어그램 1

DB 종류가 달라도 코드 분기 없이 같은 문법을 씁니다. 단, FOR UPDATE 같은 행 잠금 문법은 PostgreSQL·MySQL에서만 유효하고 SQLite에서는 동작하지 않으므로, 환경 전환 전에 사용 중인 문법을 확인해두세요.

Bun.redis — 자동 파이프라이닝이 기본값

Bun.redis는 Bun 1.3에서 정식 도입된 Redis 7.2+ 및 Valkey 호환 클라이언트입니다. 개발자가 별도 설정 없이 Promise.all로 묶으면 Bun이 알아서 커맨드를 배치로 전송하는 자동 파이프라이닝이 인상적입니다.

typescript
import { RedisClient } from "bun";
 
const redis = new RedisClient("redis://localhost:6379");
// REDIS_URL 또는 VALKEY_URL 환경 변수가 있으면 인자 없이도 됩니다
 
// TTL(초) 설정
await redis.set("session:abc", JSON.stringify(data), { ex: 3600 });
const cached = await redis.get("session:abc");
 
// 자동 파이프라이닝 — Promise.all이면 커맨드가 한 번에 전송됩니다
const [a, b, c] = await Promise.all([
  redis.get("key1"),
  redis.get("key2"),
  redis.get("key3"),
]);
 
// Pub/Sub — v1.2.23부터 실험적 지원 (아래 단점 섹션 참고)
await redis.subscribe("events", (message, channel) => {
  console.log(`[${channel}]`, message);
});

VALKEY_URL 환경 변수를 설정하면 Valkey에 바로 연결됩니다. Redis가 BSL 라이선스로 전환한 이후 Valkey로 이동한 팀이 많은데, 클라이언트 코드 변경 없이 URL 하나로 전환할 수 있습니다.

Bun.s3 — presigned URL이 동기 계산으로

Bun.s3는 Bun 1.2에서 나온 S3 호환 스토리지 클라이언트입니다. new Bun.S3Client()의 단축 형태로, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_REGION, S3_BUCKET 환경 변수에서 자격증명을 자동으로 읽습니다.

typescript
const file = Bun.s3.file("uploads/avatar.png");
await file.write(imageBuffer, { type: "image/png" });
 
// presigned URL — 로컬 HMAC 계산만으로 동기 반환, 네트워크 요청 없음
const url = file.presign({ expiresIn: 3600 });
 
// Cloudflare R2 같은 S3 호환 스토리지도 동일한 API
const r2 = new Bun.S3Client({
  endpoint: "https://<account>.r2.cloudflarestorage.com",
  bucket: "my-bucket",
  accessKeyId: process.env.R2_ACCESS_KEY_ID,
  secretAccessKey: process.env.R2_SECRET_ACCESS_KEY,
});
 
const data = await r2.file("report.pdf").arrayBuffer();
 
// Web 표준 Blob API 호환
const blob = await Bun.s3.file("data.json").blob();

presigned URL 생성이 동기적인 이유는 서명 계산을 로컬 HMAC으로만 처리하기 때문입니다. @aws-sdk/client-s3는 시간 동기화를 위한 네트워크 요청이 필요한 경우가 있어, presigned URL을 고빈도로 발급하는 서비스에서 차이가 납니다.


실전 적용

시나리오 1: 세션 캐시 + DB 조회 통합 API

Hono + Bun.redis(세션 캐시) + Bun.sql(유저 데이터) 조합은 현재 Bun 생태계에서 가장 자주 보이는 패턴입니다.

typescript
import { Hono } from "hono";
import { sql } from "bun";
import { RedisClient } from "bun";
 
const app = new Hono();
const redis = new RedisClient(); // REDIS_URL 환경 변수 자동 참조
 
app.get("/user/:id", async (c) => {
  const userId = c.req.param("id");
  const cacheKey = `user:${userId}`;
 
  const cached = await redis.get(cacheKey);
  if (cached) {
    return c.json(JSON.parse(cached));
  }
 
  const [user] = await sql`
    SELECT id, name, email FROM users WHERE id = ${userId} LIMIT 1
  `;
 
  if (!user) return c.json({ error: "Not found" }, 404);
 
  // 5분 캐싱
  await redis.set(cacheKey, JSON.stringify(user), { ex: 300 });
  return c.json(user);
});
 
export default app;

다이어그램 2

시나리오 2: 파일 업로드 API (S3 + presigned URL)

Bun.s3의 기본 자격증명은 S3_BUCKET, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_REGION 환경 변수에서 읽습니다. 이 변수들이 없으면 실행 시 에러가 납니다.

typescript
import { Hono } from "hono";
 
const app = new Hono();
 
// 서버에서 직접 업로드 받아 S3에 저장
app.post("/upload", async (c) => {
  const formData = await c.req.formData();
  const file = formData.get("file");
 
  // string으로 넘어오는 경우를 방어
  if (!(file instanceof File)) {
    return c.json({ error: "파일이 없거나 형식이 잘못되었습니다" }, 400);
  }
 
  const key = `uploads/${Date.now()}-${file.name}`;
  const s3File = Bun.s3.file(key);
 
  await s3File.write(await file.arrayBuffer(), { type: file.type });
 
  // 동기 계산 — 네트워크 요청 없음
  const downloadUrl = s3File.presign({ expiresIn: 86400 });
 
  return c.json({ key, downloadUrl });
});
 
// 클라이언트가 직접 S3에 업로드할 수 있도록 presigned PUT URL 발급
app.get("/upload-url", async (c) => {
  const filename = c.req.query("filename") ?? "unknown";
  const key = `uploads/${Date.now()}-${filename}`;
 
  const uploadUrl = Bun.s3.file(key).presign({
    method: "PUT",
    expiresIn: 300,
    type: "application/octet-stream",
  });
 
  return c.json({ key, uploadUrl });
});
 
export default app;

시나리오 3: 트랜잭션이 필요한 주문 처리

sql.begin()으로 재고 감소와 주문 생성을 원자적으로 처리합니다. FOR UPDATE는 PostgreSQL·MySQL에서 지원하는 행 잠금 문법으로, SQLite에서는 동작하지 않습니다. 이 예시는 PostgreSQL 기준입니다.

typescript
import { sql } from "bun";
import { RedisClient } from "bun";
 
const redis = new RedisClient();
 
async function placeOrder(userId: number, itemId: number, quantity: number) {
  const order = await sql.begin(async (tx) => {
    const [item] = await tx`
      SELECT id, stock, price FROM items WHERE id = ${itemId} FOR UPDATE
    `;
 
    if (!item || item.stock < quantity) {
      throw new Error("재고 부족");
    }
 
    const [newOrder] = await tx`
      INSERT INTO orders (user_id, item_id, quantity, total_price)
      VALUES (${userId}, ${itemId}, ${quantity}, ${item.price * quantity})
      RETURNING *
    `;
 
    await tx`
      UPDATE items SET stock = stock - ${quantity} WHERE id = ${itemId}
    `;
 
    return newOrder;
  });
 
  // DB 트랜잭션 이후 Redis 연산 — 실패해도 주문 데이터에는 영향 없음 (best-effort)
  await redis.incr(`order_count:${userId}`);
  await redis.expire(`order_count:${userId}`, 3600);
  await redis.del(`user_orders:${userId}`);
 
  return order;
}

시나리오 4: 환경별 코드 분기 없는 테스트 구성

DATABASE_URL 하나로 연결 대상을 결정할 수 있어, 표준 CRUD 쿼리를 쓰는 한 환경별 코드 분기가 필요 없습니다.

typescript
// db.ts
import { SQL } from "bun";
 
// "postgres://..." → PostgreSQL, "mysql://..." → MySQL, ":memory:" → SQLite
export const db = new SQL(process.env.DATABASE_URL ?? ":memory:");
bash
# 로컬 개발
DATABASE_URL=:memory: bun run dev
 
# 테스트 (인메모리 SQLite)
DATABASE_URL=:memory: bun test
 
# 프로덕션
DATABASE_URL=postgres://user:pass@host/db bun run start

FOR UPDATE처럼 DB 종류에 따라 다르게 동작하는 문법은 환경 전환 시 별도 확인이 필요합니다. "코드 무변경 전환"은 표준 SELECT·INSERT·UPDATE·DELETE 범위 안에서 유효한 이야기입니다.


장단점 분석

실제로 얻는 것들

항목 내용
드라이버 의존성 제거 데이터·스토리지 드라이버 레이어에서 12개 안팎의 패키지가 제거됩니다
SQL 성능 pg/postgres.js 대비 약 50% 빠른 쿼리 처리¹
Redis 성능 ioredis 대비 약 7.9배 빠른 처리량¹
S3 성능 @aws-sdk/client-s3 대비 약 5배 빠른 처리량¹
콜드 스타트 10ms 미만 (Node.js 약 200ms 대비)
통합 SQL API PostgreSQL·MySQL·SQLite를 동일한 문법으로
CI 단순화 드라이버 패키지 설치 단계가 줄어 Docker 이미지와 CI 파이프라인이 가벼워집니다
Valkey 지원 Redis BSL 라이선스 이슈로부터 자유롭습니다

¹ Bun 공식 벤치마크 기준, I/O 드라이버 레이어 단독 측정입니다. DB 바운드 CRUD 앱에서 end-to-end 요청 처리량 차이는 ~3% 수준에 그치는 경우도 있습니다. 성능보다 의존성 제거와 콜드 스타트 단축 쪽이 실무 체감 이득이 큰 경우가 많습니다.

단점 및 제약 사항

항목 상세 내용
N-API 네이티브 애드온 Bun 버전에 따라 지원 범위가 다릅니다. sharp, bcrypt 네이티브 버전 등은 현 시점 Bun 버전에서 호환 여부를 먼저 확인하세요
Redis Pub/Sub 실험 단계 v1.2.23 이후 실험적 상태 유지 중 — 프로덕션 투입은 안정화 후 권장
Redis Cluster 미지원 GitHub 이슈로 요청됐으나 아직 구현되지 않았습니다
S3 멀티파트 업로드 대용량 파일 업로드는 별도 구현이 필요합니다
APM·컴플라이언스 공백 일부 엔터프라이즈 APM 도구가 Bun을 아직 정식 지원하지 않습니다

실무에서 자주 빠지는 함정들

1. N-API 애드온 의존 서비스를 사전 확인 없이 이전하는 경우

sharp, bcrypt 같은 네이티브 애드온을 쓰고 있다면 마이그레이션 전에 현재 Bun 버전에서의 호환 여부를 반드시 확인하세요. Bun의 N-API 지원 범위는 버전마다 달라졌고 계속 개선 중이므로, 공식 호환성 문서와 릴리스 노트를 먼저 보는 게 맞습니다. 호환이 안 된다면 대안 패키지를 찾거나 해당 처리를 별도 서비스로 분리하는 방법을 검토해야 합니다.

2. Redis Cluster를 쓰고 있는데 그냥 넘어오려는 경우

Bun.redis는 현재 Redis Cluster를 지원하지 않습니다. Sentinel은 일부 동작하는 것으로 보고되지만, 클러스터 모드로 운영 중이라면 GitHub 이슈 진행 상황을 모니터링하면서 판단하세요.

3. Pub/Sub을 프로덕션에 즉시 투입하는 경우

Redis Pub/Sub이 v1.2.23부터 실험적으로 들어왔습니다. 아직 팀에서 피드백을 수집 중인 상태라, 장애 시 복구가 필요한 실시간 시스템에서는 안정화를 기다리거나 기존 ioredis와 병행 운영하는 편이 안전합니다.

4. 드라이버 벤치마크 수치를 서비스 전체 성능 개선으로 기대하는 경우

Redis 7.9배, SQL 50% 빠르다는 수치는 드라이버 레이어 단독 측정입니다. CRUD 중심 애플리케이션의 병목은 대부분 DB나 외부 API에 있으므로 end-to-end 요청 처리량 차이는 훨씬 좁아집니다. 마이그레이션의 주된 이유를 의존성 제거·콜드 스타트 단축·CI 단순화에 두는 게 현실적입니다.

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

다이어그램 3

Node.js 패키지 대체 매핑

Node.js 패키지 Bun 네이티브 대체
pg, postgres.js Bun.sql (PostgreSQL)
mysql2 Bun.sql (MySQL)
better-sqlite3 bun:sqlite
ioredis, node-redis Bun.redis (RedisClient)
@aws-sdk/client-s3 Bun.s3, Bun.S3Client
dotenv 내장 (Bun이 .env 자동 로드)
ts-node, nodemon 내장 (Bun이 TS 직접 실행, --watch 기본 제공)

마치며

Bun.sql, Bun.redis, Bun.s3는 각각 pg/mysql2, ioredis, @aws-sdk/client-s3를 대체하는 런타임 내장 드라이버로, 드라이버 의존성 제거·콜드 스타트 단축·CI 단순화 측면에서 명확한 이득이 있습니다. 다만 N-API 네이티브 애드온 호환성, Redis Cluster 미지원, Pub/Sub 실험 단계, 대용량 멀티파트 업로드는 현 시점에서 확인이 필요한 부분입니다.

지금 바로 시작해볼 수 있는 3단계입니다.

  1. N-API 의존성 인벤토리 먼저. package.json에서 sharp, bcrypt, canvas 같은 네이티브 애드온을 찾아보고 현 Bun 버전 호환성을 확인합니다. 이게 없거나 호환된다면 마이그레이션 블로커가 없다는 의미입니다.

  2. 신규 프로젝트 하나로 먼저 검증. 기존 서비스를 통째로 이전하기보다, 새로 만드는 API 서버 하나를 Bun + Hono + Bun.sql/Bun.redis로 구성해보세요. 패키지 설치 없이 DB와 캐시가 연결되는 경험에서 감이 잡힙니다.

  3. bun run --watch와 bun test를 일단 써보기. dotenv도 ts-node도 nodemon도 필요 없어지는 순간, "배터리 포함 런타임"이 무엇인지 체감됩니다.


참고 자료

  • SQL - Bun 공식 문서
  • Redis - Bun 공식 문서
  • S3 - Bun 공식 문서
  • Bun 1.3 공식 블로그
  • Bun 1.2 Improves Node Compatibility and Adds Postgres Client — InfoQ
  • Bun adds Bun.SQL — a zero-dependency unified SQL client — Progosling
  • Bun.sql vs postgres.js vs Drizzle: Postgres in 2026 — PkgPulse Guides
  • Bun 1.2 Deep Dive: Built-in SQLite, S3, and Why It Might Actually Replace Node.js — DEV Community
  • Bun's Built-in Redis Client: Fast, Simple, Production-Ready — bunjs.run
  • Bun v1.2.23 릴리스 노트
  • Bun vs Node.js in 2026: Benchmarks & Migration Guide — Strapi
  • The Case for Bun in 2026: Where It Works and Where It Doesn't — Medium
  • Redis/Valkey Cluster Support Issue — GitHub oven-sh/bun
#Bun#PostgreSQL#Redis#S3#TypeScript#Backend
공유하기

목차

핵심 개념세 클라이언트의 등장 배경Bun.sql — 하나의 API로 PostgreSQL·MySQL·SQLiteBun.redis — 자동 파이프라이닝이 기본값Bun.s3 — presigned URL이 동기 계산으로실전 적용시나리오 1: 세션 캐시 + DB 조회 통합 API시나리오 2: 파일 업로드 API (S3 + presigned URL)시나리오 3: 트랜잭션이 필요한 주문 처리시나리오 4: 환경별 코드 분기 없는 테스트 구성장단점 분석실제로 얻는 것들단점 및 제약 사항실무에서 자주 빠지는 함정들마이그레이션 의사결정 흐름Node.js 패키지 대체 매핑마치며참고 자료

추천 포스트

무중단 PostgreSQL 스키마 마이그레이션 실전 가이드 — Expand-Contract 패턴으로 컬럼 리네임부터 타입 변경까지 롤백 없이 배포하기
Backend

무중단 PostgreSQL 스키마 마이그레이션 실전 가이드 — Expand-Contract 패턴으로 컬럼 리네임부터 타입 변경까지 롤백 없이 배포하기

NOT VALID + VALIDATE CONSTRAINT 조합은 PostgreSQL 12+에서 특히 강력합니다. VALIDATE CONSTRAINT 는 SHARE UPDATE EXCLUSIVE 락만 잡기 때문에 SELECT는 물론이고 대부분의 DML도 막지 않습니다.…

2026년 07월 19일읽는 데 11분
공개키만 남기고 비밀번호를 걷어내기: Node.js와 SimpleWebAuthn으로 패스키 등록·인증을 구현하고 기존 로그인과 단계적으로 통합하기
Backend

공개키만 남기고 비밀번호를 걷어내기: Node.js와 SimpleWebAuthn으로 패스키 등록·인증을 구현하고 기존 로그인과 단계적으로 통합하기

인증 시스템을 처음부터 다시 짜야 한다는 얘기를 들으면 솔직히 긴장됩니다. 비밀번호 해싱, 세션 관리, 2FA 연동까지 이미 쌓아온 코드가 있는데, 거기에 WebAuthn이라는 새로운 개념을 얹어야 한다니. 저도 처음에 W3C 명세를 펼쳤다가 CBOR, COSE, at…

2026년 07월 19일읽는 데 27분
Node.js 22 Permission Model 실전 가이드 — `--allow-fs-read`로 프로세스 권한을 최소화하고 공급망 공격 표면을 줄이는 법
Backend

Node.js 22 Permission Model 실전 가이드 — `--allow-fs-read`로 프로세스 권한을 최소화하고 공급망 공격 표면을 줄이는 법

npm 생태계에서 공급망 공격은 꾸준히 늘고 있다. 패키지 하나가 침해되는 순간, npm install 로 당겨온 수백 개의 의존성 중 어느 것도 온전히 신뢰하기 어려워진다. 락파일 관리, 의존성 감사, npm audit 은 여전히 유효하지만, 침해가 발생한 이후를 위…

2026년 07월 20일읽는 데 14분
Fastify v5 + TypeBox로 TypeScript REST API 구축하기
Backend

Fastify v5 + TypeBox로 TypeScript REST API 구축하기

Express로 API를 작성할 때 생기는 구조적 문제를 코드로 먼저 보겠습니다. TypeScript 타입, 런타임 검증 규칙, API 문서가 각각 다른 곳에 있습니다. 셋 중 하나가 바뀌어도 나머지가 자동으로 따라오지 않습니다. Fastify v5 + TypeBox는…

2026년 07월 19일읽는 데 15분
Node.js 비동기 작업을 안정적으로 처리하는 BullMQ 5 잡 큐 — 재시도 전략·Dead Letter Queue·Sandboxed Processor·Prometheus 메트릭 연동까지
Backend

Node.js 비동기 작업을 안정적으로 처리하는 BullMQ 5 잡 큐 — 재시도 전략·Dead Letter Queue·Sandboxed Processor·Prometheus 메트릭 연동까지

회원 가입 API를 만들다 보면 어느 순간 이런 생각이 듭니다. "환영 이메일을 발송하는 동안 HTTP 응답이 블로킹되는 게 맞는 걸까?" 저도 처음엔 그냥 await sendEmail() 을 응답 전에 넣었는데, SendGrid 타임아웃이 터지는 순간 모든 가입 요청…

2026년 07월 19일읽는 데 23분
Node.js Permission Model 실전 가이드 — 파일 시스템 최소 권한 원칙으로 서드파티 패키지의 무단 접근을 차단하는 법
Backend

Node.js Permission Model 실전 가이드 — 파일 시스템 최소 권한 원칙으로 서드파티 패키지의 무단 접근을 차단하는 법

2018년 event stream 사건은 NPM 공급망 공격의 전환점이었습니다. 유지보수자 계정이 탈취된 뒤 악성 패키지가 월 수천만 건 다운로드되는 의존성 체인에 조용히 삽입됐고, 특정 Bitcoin 지갑 앱을 겨냥한 코드가 몇 주 동안 눈에 띄지 않고 실행됐습니다.…

2026년 07월 19일읽는 데 17분