Bun.sql과 pg, 벤치마크 숫자가 감추는 진짜 차이 — 네이티브 바인딩이 쿼리 레이턴시와 커넥션 풀에 남기는 흔적
Bun이 처음 등장했을 때 "Node.js 대비 4배 빠름"이라는 수식어가 커뮤니티를 달궜습니다. 솔직히 저도 처음엔 그 숫자에 혹했는데, 알고 보니 그건 HTTP 처리 계층에 한정된 이야기였습니다. 실제로 PostgreSQL 쿼리가 포함된 벤치마크를 돌려보면 약 3% 차이로 수렴한다는 독립 분석도 나옵니다. 그렇다면 Bun 1.2에서 새롭게 들어온 Bun.sql은 그냥 마케팅용 숫자일까요?
저도 처음엔 그렇게 의심했습니다. 그런데 벤치마크 숫자 뒤에 있는 구조를 파고들수록, 이 이야기가 단순히 "얼마나 빠른가"가 아니라 **"어떤 워크로드에서 어떤 방식으로 다르게 동작하는가"**의 문제라는 게 보이더군요. 일반적인 CRUD API에서는 드라이버 선택보다 인덱스 설계가 훨씬 중요하지만, 소규모 쿼리가 폭발적으로 몰리는 내부 서비스에서는 query pipelining 덕분에 큰 처리량 차이가 보고된 사용자 사례도 있습니다(단, 재현 가능한 조건 명세가 없는 단일 보고이므로 절대 수치는 그대로 신뢰하기 어렵습니다).
이 글은 그 차이가 구체적으로 어디서 오는지, 그리고 여러분의 서비스에 실제로 의미 있는 규모인지를 함께 짚어봅니다. pg 드라이버로 운영 중인 Node.js 서비스를 두고 Bun 전환을 검토 중이거나, 이미 Bun을 쓰면서 Bun.sql 도입 타이밍을 재고 있는 분들이 대상입니다.
구현 방식의 차이가 만드는 성능 격차
Bun.sql은 무엇이 다른가
Bun.sql은 외부 npm 패키지가 아닙니다. Bun 1.2(2025년 1월 출시)부터 런타임에 내장된 SQL 클라이언트로, import { sql } from "bun" 한 줄로 PostgreSQL에 바로 붙을 수 있습니다. 내부적으로는 Zig 언어로 PostgreSQL wire protocol을 직접 구현했고, Bun의 이벤트 루프와 uSockets 네트워킹 레이어에 깊이 통합되어 있습니다. 핸드셰이크, SSL/TLS 업그레이드, MD5·SCRAM-SHA-256 인증까지 Zig로 처리합니다.
pg는 반대로 순수 JavaScript로 wire protocol을 구현합니다. libpq 같은 C 라이브러리를 쓰지는 않고, 모든 파싱과 직렬화가 V8 엔진 안에서 돌아갑니다. 저는 이 차이를 세 가지 축으로 이해하고 있습니다.
첫째, 바이너리 vs 텍스트 wire protocol. Bun.sql은 PostgreSQL의 바이너리 포맷을 지원합니다. 숫자·타임스탬프 등을 텍스트로 직렬화했다가 다시 파싱하는 과정이 줄어들어, 대량 숫자·시간 데이터를 다룰 때 파싱 오버헤드가 감소합니다. 다만 바이너리 포맷은 tcpdump/Wireshark 캡처에서 육안 디버깅이 어렵고, PgBouncer 트랜잭션 모드나 일부 프록시·쿼리 로거에서 예상 밖 동작이 발생할 수 있으므로 무조건 이점이라고 보기는 어렵습니다.
둘째, 자동 Prepared Statement 캐싱. Bun.sql은 같은 쿼리 문자열을 반복 실행할 때 자동으로 prepared statement 형태로 재사용합니다. pg에서 이 효과를 얻으려면 쿼리 객체에 name 파라미터를 직접 지정해야 합니다.
셋째, Query Pipelining. 가장 눈에 띄는 차이입니다. Bun.sql은 응답을 기다리지 않고 여러 쿼리를 연속으로 전송할 수 있습니다. pg는 각 쿼리의 응답을 받은 뒤에야 다음 쿼리를 보냅니다. 네트워크 왕복 횟수가 줄어드는 만큼, 소규모 쿼리가 대량으로 몰리는 상황에서 처리량 차이가 벌어집니다.
실제 코드로 비교해보면
연결 설정
// Bun.sql - 개념적 예시 (실제 옵션명은 Bun 사용 버전 공식 문서 확인 권장)
import { SQL } from "bun";
// DATABASE_URL 환경변수가 있으면 자동 참조
// (postgresql://user:pass@localhost:5432/mydb 형태)
const sql = new SQL({
url: process.env.DATABASE_URL,
max: 10,
});
const result = await sql`SELECT 1`;// node-postgres
import { Pool } from "pg";
const pool = new Pool({
host: "localhost",
port: 5432,
database: "mydb",
user: "user",
password: "pass",
max: 10,
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 2000,
});pg는 공식 Pool 문서에 명시된 max, idleTimeoutMillis, connectionTimeoutMillis로 커넥션 수명을 제어합니다(문서상 acquireTimeoutMillis나 min 같은 옵션은 공식 API 표면에 없으니, 다른 커뮤니티 예제에서 봤더라도 직접 지정해 쓰기 전에 문서를 확인하는 편이 안전합니다). Bun.sql은 아직 이 정도로 정교한 옵션이 정리되어 있지 않고, 세밀한 풀 제어 관련 기능 요청이 GitHub 이슈에서 논의 중입니다.
쿼리 실행
// Bun.sql - tagged template literal
const userId = 42;
const result = await sql`
SELECT id, name, email
FROM users
WHERE id = ${userId}
`;
console.log(result[0].name);// node-postgres
const userId = 42;
// 기본 방식 (prepared statement 캐싱 없음)
const { rows } = await pool.query(
"SELECT id, name, email FROM users WHERE id = $1",
[userId]
);
// prepared statement 캐싱을 원하면 name 파라미터 필요
const { rows: cachedRows } = await pool.query({
name: "get-user-by-id",
text: "SELECT id, name, email FROM users WHERE id = $1",
values: [userId],
});Bun.sql의 tagged template literal API는 문자열 보간이 자동으로 파라미터 바인딩으로 처리되어 SQL injection을 실수로 만들기 어렵게 되어 있습니다. pg도 $1 자리표시자를 쓰면 동일하게 안전하지만, 두 스타일 중 실수 확률이 낮은 쪽은 tagged template 쪽이라고 봅니다.
Drizzle ORM과 함께 사용
Bun 단일 런타임 프로젝트에서 Bun.sql + Drizzle 조합을 채택하는 팀이 늘고 있고, Drizzle 공식 문서에도 drizzle-orm/bun-sql 어댑터가 정식으로 문서화되어 있습니다.
import { drizzle } from 'drizzle-orm/bun-sql';
import { sql } from 'bun';
import { eq } from 'drizzle-orm';
import { usersTable } from './schema';
const db = drizzle(sql);
const users = await db
.select()
.from(usersTable)
.where(eq(usersTable.id, 42));여기서 짚어둘 점은, ORM 레이어를 얹으면 드라이버 사이의 성능 격차가 상당 부분 희석된다는 것입니다. Drizzle이 쿼리 빌드·타입 캐스팅·결과 매핑에 쓰는 JS 사이클이 추가되기 때문에, Bun.sql의 pipelining이나 바이너리 파싱 이점이 ORM 오버헤드 뒤로 숨는 워크로드도 많습니다. 드라이버 벤치마크 결과를 그대로 ORM 위에서 재현되기를 기대하면 실망하기 쉽습니다. 자체 서비스의 대표 쿼리로 재측정해보는 편이 좋습니다.
커넥션 풀 동작의 결정적 차이
성능보다 운영 안정성 관점에서 더 신경 쓰이는 대목이 여기 있습니다.
초기화 전략 차이의 결과: 시작 부하 스파이크
Bun.sql의 커넥션 풀 초기화 전략은 아직 공식 문서에 상세히 정리되어 있지 않지만, 사용자 관측과 기능 요청 이슈(#30631)에서 반복적으로 지적되는 패턴은 "풀이 요청 부하에 따라 눈에 띄게 크게, 빠르게 커진다"는 것입니다. pg는 요청이 들어올 때마다 필요한 만큼 점진적으로 커넥션을 생성하고, 유휴 커넥션은 idleTimeoutMillis로 정리합니다.
이게 왜 운영에서 문제냐면, max=200 이상으로 잡고 여러 인스턴스를 동시에 재시작할 때 TCP/TLS 핸드셰이크가 짧은 시간에 몰려 PostgreSQL 서버에 접속 스파이크가 발생하기 쉽기 때문입니다. --sql-preconnect 플래그(Bun v1.2.21에서 도입)로 애플리케이션이 트래픽을 받기 전에 미리 커넥션을 확보할 수 있지만, 이건 시작 시 커넥션이 몰리는 상황 자체를 해결하는 게 아니라 그 시점을 앞당겨 명시적으로 관리하는 도구에 가깝습니다. 스파이크 자체를 낮추려면 결국 max 값을 보수적으로 잡거나, 인스턴스 롤아웃 순서를 배포 파이프라인에서 조절해야 합니다.
pg는 max, idleTimeoutMillis, connectionTimeoutMillis 조합으로 이 동작을 제어하고, PgBouncer 같은 외부 풀러와도 오래 검증된 조합으로 운영됩니다.
숫자로 보는 현실
인용되는 벤치마크 대부분은 재현 조건이 충분히 공개되지 않았습니다. 대표적으로 참조되는 자료들을 그대로 신뢰 수준까지 표시해 정리하면 이렇습니다.
- 순수 API 서버 처리량 비교: Evert Heylen의 독립 분석에서 Bun과 Node 사이 처리량 차이가 약 3% 수준으로 수렴한다고 보고됨. 환경·측정 방법이 문서에 공개되어 있어 상대적으로 참고 가치가 높음.
- 고동시 소규모 쿼리에서의 pipelining 이점: Bun 이슈 #20294의 단일 사용자 보고. 하드웨어·네트워크·pipelining 폭 등 조건이 상세히 공개되지 않았으므로, 관찰된 방향성(pipelining이 있을 때 유리)만 취하고 절대 배수는 참고 수준으로만 봐야 함.
- Row 대량 읽기 개선: InfoQ 기사에서 인용된 단일 마이그레이션 사례. 쿼리 유형·데이터 크기 등이 제한적으로만 공개됨.
- node-postgres 이슈 #3391의 밀리초 단위 수치들: 사용자 제출 벤치마크로, 하드웨어·네트워크 조건이 명시되지 않아 절대치를 인용하기에는 부적합.
따라서 이 글에서는 "몇 배 빠르다" 같은 절대 수치 대신 방향성 중심으로 요약합니다.
| 워크로드 유형 | 관찰된 방향성 | 주된 원인 |
|---|---|---|
| 일반 CRUD API (네트워크 레이턴시 지배) | 사실상 무차이 | 드라이버 파싱 비용이 네트워크 왕복에 묻힘 |
| 소규모 쿼리 대량 반복 (내부 서비스, 배치) | Bun.sql이 유의미하게 유리한 경향 | Query Pipelining |
| Row 대량 읽기 (집계, 보고서) | Bun.sql이 다소 유리한 경향 | 바이너리 파싱 + Prepared Statement 자동 캐싱 |
벤치마크 숫자를 볼 때 항상 자문해볼 것: "이 테스트의 네트워크 조건이 우리 서비스와 같은가?" DB가 같은 VPC 안에 있으면 네트워크 레이턴시가 낮아서 드라이버 파싱 비용이 상대적으로 커집니다. 퍼블릭 인터넷 너머에 DB가 있으면 드라이버 차이는 대체로 잡음 수준입니다.
트레이드오프 정리
| 항목 | Bun.sql | pg (node-postgres) |
|---|---|---|
| 구현 언어 | Zig 네이티브 | 순수 JavaScript |
| Prepared Statement | 자동 캐싱 (기본값) | name 파라미터 수동 지정 필요 |
| Query Pipelining | 기본 활성화 | 없음 |
| Wire Protocol | 바이너리 지원 (디버깅·프록시 호환성 주의) | 텍스트 기반 (도구 호환성 넓음) |
| 풀 초기화·확장 성향 | 공격적 (시작 스파이크 관리 필요) | 점진적 (idleTimeoutMillis 등으로 제어) |
| 풀 세밀 제어 옵션 | 아직 제한적 | max, idleTimeoutMillis, connectionTimeoutMillis 등 |
| PgBouncer 트랜잭션 모드 | 호환성 검증 불충분 | 오래 검증된 조합 |
| 런타임 지원 | Bun 전용 | Node.js, Bun에서 동작 |
| 생태계 성숙도 | 신생 (2025년~) | 10년 이상 프로덕션 사용 |
| 외부 의존성 | 없음 (런타임 내장) | npm 별도 설치 |
실수하기 쉬운 지점들
Bun.sql 초기 버전에서 concurrent statement 실행이나 Date 인스턴스 처리와 관련한 이슈 보고가 있었습니다. 도입 전에 사용 중인 Bun 버전의 변경 이력을 확인하고, 타임스탬프를 많이 다루는 서비스라면 대표 쿼리로 반드시 검증해보길 권합니다.
트랜잭션 모드 PgBouncer를 사용 중이라면 Bun.sql과의 호환성이 아직 충분히 검증되지 않았습니다. 이 경우는 pg나 postgres.js를 유지하는 쪽이 안전합니다.
크로스 런타임 코드베이스를 유지해야 하는 팀에게는 postgres.js + Drizzle 조합도 현실적인 대안입니다. Bun.sql의 API 원형이기도 해서 tagged template literal 문법이 거의 동일하고, Node.js와 Bun 양쪽에서 동일 코드로 동작합니다.
어떤 상황에서 무엇을 선택할까
마무리하며
이 글에서 반복적으로 강조하고 싶은 결론은 세 가지입니다.
첫째, Bun.sql이 pg보다 체감 가능한 차이를 만드는 구간은 좁습니다. 소규모 쿼리를 대량으로, 낮은 네트워크 레이턴시 위에서 돌리는 내부 서비스·배치 워크로드가 유일하게 pipelining 이점을 뚜렷이 느낄 수 있는 지점입니다. 그 외 대부분의 API 서버에서는 드라이버보다 EXPLAIN ANALYZE와 인덱스 설계가 훨씬 큰 영향을 미칩니다.
둘째, 커넥션 풀 초기화·확장 성향의 차이는 성능보다 운영 측면에서 더 중요합니다. max를 크게 잡고 여러 인스턴스를 동시 재시작하는 배포 파이프라인이라면, Bun.sql 도입 전에 시작 시 접속 스파이크를 감당할 수 있는지 반드시 확인해야 합니다. --sql-preconnect는 이 스파이크를 없애주는 도구가 아니라, 그 시점을 앞당겨 명시적으로 관리하는 도구입니다.
셋째, PgBouncer 트랜잭션 모드를 쓰고 있다면 지금은 pg나 postgres.js를 유지하는 편이 안전합니다. 반대로 Bun 단일 런타임을 이미 확정했고 위 두 조건에 해당하지 않는다면, 제로 의존성·tagged template literal·자동 prepared statement 캐싱 같은 API 측면의 장점만으로도 Bun.sql을 선택할 이유는 충분합니다.
숫자로만 판단하지 말고, 자신의 서비스가 어느 축에 서 있는지를 먼저 확인해보세요. "우리 워크로드에서 재측정한 결과"만이 결국 믿을 수 있는 근거입니다.
참고 자료
- Bun 1.2 공식 릴리즈 노트
- Bun SQL 공식 문서
- Bun v1.2.21 릴리즈 노트 (--sql-preconnect 등)
- Bun 1.2 Improves Node Compatibility and Adds Postgres Client — InfoQ
- Performance: pg VS postgres.js VS Bun.SQL — GitHub Issue #3391
- Huge performance gap between bun sql and pg npm test? — Bun GitHub Issue #20294
- Feature Request: Customizable Connection Pool Strategy — Bun GitHub Issue #30631
- Node vs Bun: no backend performance difference — Evert Heylen
- Drizzle ORM - PostgreSQL with Bun.sql 공식 문서
- node-postgres Pool API 공식 문서