Bun 1.2 내장 SQL 드라이버로 PostgreSQL 다루기 — 준비 구문, 트랜잭션, 그리고 스트리밍의 현재 위치
Node.js 백엔드를 Bun으로 옮기기로 마음먹었을 때 제일 먼저 걸리는 지점 중 하나가 DB 드라이버입니다. node-postgres나 postgres.js는 Node.js 생태계에서 오래 검증된 라이브러리라 "Bun에서도 그냥 그거 쓰면 되지 않나?" 싶어지죠. 실제로 그렇게 해도 동작합니다. 그런데 Bun 1.2부터는 런타임 자체에 PostgreSQL 클라이언트가 번들로 들어오면서 선택지가 하나 더 생겼습니다.
Bun.sql은 외부 패키지 설치 없이 쓸 수 있는 내장 SQL 클라이언트입니다. PostgreSQL을 기본으로 다루고, Bun 1.2 후기 패치부터는 MySQL/MariaDB, 그리고 SQLite까지 동일한 API로 다룰 수 있습니다(정확한 지원 시점은 Bun 릴리스 노트를 확인하는 편이 안전합니다). 드라이버 자체는 Zig로 구현되어 있고요.
이 글에서는 준비 구문 자동 캐싱, 트랜잭션과 세이브포인트, 스트리밍 쿼리의 현재 상태를 코드 중심으로 살펴봅니다. 아직 안정화가 덜 된 부분도 짚어보니, 지금 전환을 검토 중이라면 판단 근거가 되길 바랍니다.
이후 나오는 예시들은 첫 예시에서 만든
sql인스턴스를 계속 재사용한다고 가정합니다. 매 예시마다new SQL(...)을 다시 쓰지는 않습니다.
왜 내장 드라이버인가 — 외부 패키지를 걷어낸다는 것의 의미
의존성 하나가 줄어드는 게 왜 중요한가
npm install postgres 한 줄이 대단한 부담은 아닙니다. 다만 CI 파이프라인 관점에서 보면 이야기가 조금 달라집니다. 외부 드라이버 중 일부는 네이티브 빌드가 필요하고, 버전 충돌로 빌드가 깨지는 경험을 다들 한 번쯤은 해봤을 겁니다. 공급망 보안 측면에서도 별도 의존성이 줄면 감사(audit) 대상이 하나 줄어드는 셈입니다.
물론 이 논리를 뒤집으면, Bun 런타임 자체가 훨씬 큰 신뢰 범위를 요구한다는 점도 인정해야 합니다. postgres.js 같은 얇은 라이브러리 대신 런타임 통째로 신뢰해야 하는 셈이니, "의존성이 줄었다"는 표현은 어디까지나 애플리케이션 package.json 관점에서 그렇다는 뜻입니다.
Bun.sql의 실용적인 가치는 드라이버가 런타임에 이미 들어 있어서 별도 설치·빌드가 필요 없다는 점입니다.
import { SQL } from "bun";
const sql = new SQL("postgres://user:pass@localhost:5432/mydb");
const users = await sql`SELECT * FROM users WHERE active = ${true}`;태그드 템플릿 리터럴로 파라미터를 넘기면 내부적으로 바인딩 처리가 되기 때문에 SQL 인젝션을 API 레벨에서 막아줍니다. 문자열 접합으로 쿼리를 만드는 실수 자체가 구조적으로 어려워지는 구조입니다.
쿼리 실행 흐름 한눈에 보기
클라이언트(Bun)는 prepared statement 이름을 관리하고, 서버(PostgreSQL)는 그 이름에 대응하는 파싱 결과를 세션 안에 보관합니다. 두 계층이 분리되어 있다는 점을 기억해두면 뒤에 나오는 서버 로그 이야기가 자연스럽게 이어집니다.
준비 구문 — 별도 설정 없이 캐싱이 된다
서버 사이드 prepared statement 자동 캐싱
pg를 쓸 때 반복 쿼리 성능을 높이려면 client.query({ name: 'fetch-user', text: '...' })처럼 이름을 직접 지정해야 했습니다. Bun.sql은 이 과정이 기본값으로 자동화되어 있습니다.
const ids = [1, 2, 3, 4, 5];
for (const id of ids) {
const [row] = await sql`SELECT * FROM orders WHERE id = ${id}`;
console.log(row);
}루프를 돌아도 SELECT * FROM orders WHERE id = $1 쿼리는 처음 한 번만 파싱됩니다. 이후 반복에서는 파라미터만 바꿔 재실행됩니다. 대량 단건 조회가 많은 서비스라면 체감 차이가 납니다.
PostgreSQL 서버 로그를 열어놓고 확인해보면, 두 번째 실행부터는 서버 쪽 Parse 단계가 빠지고 Bind → Execute만 찍히는 걸 눈으로 확인할 수 있습니다. 클라이언트가 statement 이름을 재사용하고, 서버가 그 이름에 붙은 파싱 결과를 그대로 쓰기 때문입니다.
트랜잭션 — 콜백 안에서 예외 나면 알아서 롤백
기본 트랜잭션
const [user, account] = await sql.begin(async (tx) => {
const [u] = await tx`
INSERT INTO users (name) VALUES ('Alice') RETURNING *
`;
const [a] = await tx`
INSERT INTO accounts (user_id) VALUES (${u.id}) RETURNING *
`;
return [u, a];
});sql.begin() 호출 시 내부적으로 전용 연결이 예약되어 콜백 실행 동안 유지됩니다. 콜백 내부에서 예외가 발생하면 자동으로 ROLLBACK, 정상 완료되면 COMMIT입니다. try/catch로 롤백을 직접 호출할 필요가 없습니다.
콜백의 반환값은 sql.begin()의 결과로 이어집니다(공식 문서와 예제에서 확인 가능). 첫 번째 인자로 isolation level 문자열("serializable", "repeatable read" 등)을 넘길 수도 있는데, PostgreSQL access mode(READ WRITE 등)와는 개념이 다릅니다. 필요한 경우 문서에서 지원 문자열을 먼저 확인하는 편이 안전합니다.
세이브포인트로 부분 롤백
트랜잭션 전체가 아니라 특정 블록만 되돌리고 싶을 때 세이브포인트가 유용합니다. 다만 여기서 한 가지 유의할 점이 있는데, 세이브포인트 블록 안의 예외를 바깥으로 그대로 던지면 외부 트랜잭션도 abort됩니다. "세이브포인트만 롤백된다"는 결과를 원한다면 예외를 try/catch로 흡수해야 합니다.
await sql.begin(async (tx) => {
await tx`INSERT INTO users (name) VALUES ('Bob')`;
try {
await tx.savepoint(async (sp) => {
await sp`UPDATE users SET role = 'admin' WHERE name = 'Bob'`;
throw new Error('권한 정책 위반');
});
} catch (e) {
console.warn('role 변경 실패, 계속 진행', e);
}
await tx`INSERT INTO audit_log (action) VALUES ('user_created')`;
});이렇게 작성하면 세이브포인트 안에서 발생한 예외를 바깥에서 잡아 처리하고, Bob 사용자 생성과 감사 로그는 그대로 유지되며 role 변경만 되돌아가는 흐름을 만들 수 있습니다. try/catch를 빼면 전체 트랜잭션이 롤백되니 주의하세요.
트랜잭션 실행 흐름
연결 풀 예약·반환은 내부 구현이라 API 계약 수준에서 신경 쓸 필요는 없습니다. 앱 개발자가 봐야 할 흐름은 위와 같이 BEGIN → 쿼리 → COMMIT 혹은 ROLLBACK 두 갈래입니다.
연결 예약 — 세션 범위 임시 테이블이 필요할 때
임시 테이블은 연결 단위로 존재합니다. 연결 풀에서 매번 다른 연결을 가져오면 방금 만든 임시 테이블이 사라지겠죠. 이럴 때 sql.reserve()로 전용 연결을 빼내서 씁니다.
아래 예시의
using키워드는 TypeScript 5.2 이상,tsconfig.json의target/lib가ES2022이상(혹은esnext)일 때 컴파일됩니다. 그렇지 않은 환경에서는try/finally로reserved.release()형태로 명시적 반환을 써야 합니다.
{
using reserved = await sql.reserve();
await reserved`CREATE TEMP TABLE staging (id INT, val TEXT)`;
await reserved`INSERT INTO staging VALUES (1, 'test')`;
const rows = await reserved`SELECT * FROM staging`;
console.log(rows);
} // 스코프 종료 시 자동으로 풀에 반환using은 ECMAScript Explicit Resource Management 스펙이고, Bun이 이를 지원합니다. 스코프 밖으로 나가면 연결이 자동 반환되어 누수 걱정이 없어요.
스트리밍 쿼리 — 아직은 관망 구간
대용량 결과셋을 한 번에 메모리에 올리지 않고 행 단위로 처리하고 싶을 때 스트리밍이 필요합니다. Bun.sql에서도 이 방향으로의 API가 논의되고 있지만, 2026년 8월 기준으로 안정화 단계는 아닙니다.
GitHub Issue #25307에서 네이티브 이터레이터/스트림 SELECT 기능 요청이 추적되고 있으며, 제안된 형태는 대략 이런 모습입니다.
// GitHub Issue #25307에서 제안된 개념적 형태 — 안정 API가 아님
const stream = sql`SELECT * FROM large_table`.stream();
for await (const row of stream) {
process(row);
}지금 당장 수백만 행짜리 결과셋을 처리해야 한다면, 오프셋 페이지네이션이나 커서 기반 페이지네이션으로 나눠 처리하는 편이 현실적입니다. 스트리밍 API가 안정화되기 전까지는요.
트레이드오프 — 어디까지 쓸 수 있고, 어디서 막히나
| 항목 | 평가 |
|---|---|
| 외부 의존성 제거 | 애플리케이션 package.json 관점의 단순화(런타임 신뢰 범위는 커짐) |
| SQL 인젝션 방지 | 태그드 템플릿 리터럴로 API 레벨에서 차단 |
| 준비 구문 캐싱 | 기본 활성화, 별도 설정 불필요 |
| PostgreSQL/MySQL/SQLite 통합 API | 테스트(SQLite)와 프로덕션(PostgreSQL) 전환 용이 |
| Bun 런타임 종속 | Node.js·Deno·엣지 런타임에서는 동일 코드 재사용 불가 |
| 스트리밍 API | 안정화 전, 대용량 결과셋 처리에 제약 |
| ORM 지원 | Drizzle ORM은 공식 어댑터 있음, Prisma는 미지원 |
| 프로덕션 검증 기간 | pg·postgres.js 대비 장기 운영 사례 적음 |
실무에서 자주 나오는 실수
트랜잭션 안에서 sql을 직접 쓰기. 트랜잭션 콜백 안에서는 반드시 콜백 파라미터로 받은 tx를 써야 합니다. 바깥의 sql을 그대로 쓰면 다른 연결에서 실행되어 트랜잭션 범위 밖으로 벗어납니다.
// 잘못된 예
await sql.begin(async (tx) => {
await sql`INSERT INTO users (name) VALUES ('Wrong')`; // tx가 아님!
});
// 올바른 예
await sql.begin(async (tx) => {
await tx`INSERT INTO users (name) VALUES ('Correct')`;
});런타임 변경만으로 성능 문제가 풀릴 거라는 기대. 애플리케이션 프로파일이 CPU-bound가 아니라 PostgreSQL I/O에 종속되어 있다면, 런타임을 바꾼다고 병목이 사라지지는 않습니다. 실측 없이 "Bun으로 옮기면 빨라진다"는 전제로 로드맵을 짜는 건 위험합니다. 반드시 자신의 워크로드에서 A/B 측정을 해보길 권합니다.
ORM 지원 상태
| 도구 | Bun.sql 지원 방식 | 상태 |
|---|---|---|
| Drizzle ORM | drizzle-orm/bun-sql 어댑터 |
공식 지원 |
| Kysely | kysely-bun-sql |
커뮤니티 어댑터 |
| Prisma | 없음 | 미지원(2026년 8월 기준) |
Raw Bun.sql |
태그드 템플릿 리터럴 | 런타임 내장 |
Drizzle을 이미 쓰고 있다면 어댑터 교체만으로 자연스럽게 이어집니다. Prisma 의존도가 높은 코드베이스라면, Bun.sql 자체보다는 Prisma의 Bun 지원 로드맵을 먼저 확인하는 편이 순서상 맞습니다.
그래서 언제 쓰고, 언제 미룰까
정리하자면 결정 지점은 크게 두 축입니다. 런타임을 이미 Bun으로 넘겼는가, 그리고 의존하는 ORM/드라이버가 Bun.sql과 궁합이 맞는가.
신규 프로젝트를 Bun 위에서 시작하고 Prisma 종속이 없다면 Bun.sql을 기본으로 두는 게 자연스럽습니다. 준비 구문 캐싱이 기본값이라 반복 쿼리 성능을 별도 튜닝 없이 챙길 수 있고, 콜백 기반 트랜잭션 API 덕분에 롤백 처리를 빠뜨리는 실수도 구조적으로 줄어듭니다.
반대로 레거시 Node.js 서비스를 유지보수하면서 일부 서비스만 Bun으로 이관하는 상황이라면, 서비스 간 코드 재사용성을 고려해 postgres.js를 계속 쓰는 편이 조직 차원의 유지비용이 더 낮을 수 있습니다. "지금 당장 최적"보다 "6개월 뒤에도 손댈 만한 구조"를 기준으로 판단해도 늦지 않습니다.
참고 자료
공식 문서 및 API
- Bun 공식 SQL 문서
- Bun SQL API Reference — TransactionSQL
- Bun SQL API Reference — SQL.reserve()
- Bun SQL API Reference — SQL.transaction()
GitHub 이슈(진행 중 논의)
기사 및 어댑터