Bun.sql로 PostgreSQL 트랜잭션·prepared statement 다루기: 내장 풀과 2PC가 실제로 하는 일
Node.js 백엔드를 운영하다 보면 한 번쯤 "이 pg + pg-pool 조합, 좀 더 단순하게 할 수 없나?" 하는 시점이 옵니다. 의존성은 의존성대로 쌓이고, 외부 풀 설정은 따로 관리해야 하고, prepared statement가 내부에서 어떻게 동작하는지도 솔직히 잘 모른 채 쓰게 되죠.
Bun 1.2에서 등장한 Bun.sql은 그 지점을 정면으로 겨냥합니다. 외부 npm 패키지 없이 PostgreSQL을 쓸 수 있고, 트랜잭션과 prepared statement가 드라이버 내장 동작으로 처리됩니다. 여기서 오해하기 쉬운 게 있는데, Bun.sql이 없애는 건 외부 풀 패키지(pg-pool 같은)이지 커넥션 풀 개념 자체가 아닙니다. 내장 커넥션 풀은 여전히 존재하고, 뒤에서 볼 것처럼 이 내장 풀의 초기화 전략이 실무에서 이슈가 되기도 합니다.
이 글에서는 Bun.sql이 트랜잭션과 prepared statement를 내부적으로 어떻게 처리하는지 살펴보고, savepoint·분산 트랜잭션·커넥션 예약 시나리오를 코드로 짚어봅니다. 그리고 PgBouncer 호환성 문제나 알려진 production 버그처럼 실제로 마주칠 수 있는 함정도 함께 다룹니다.
왜 지금 Bun.sql인가
기존 스택의 레이어 구성
Node.js에서 PostgreSQL을 쓰는 경로는 크게 두 갈래입니다. pg는 libpq(C 라이브러리)에 바인딩하는 방식이고, postgres.js는 순수 JS로 wire protocol을 구현한 방식입니다. 두 접근은 아키텍처가 근본적으로 다르므로 나눠서 보는 편이 정확합니다.
각 레이어는 각자의 버전 관리, 설정, 트러블슈팅 포인트를 가집니다. pg-pool은 외부 커넥션 풀만 담당하고, prepared statement 캐싱은 라이브러리별로 다르게 동작하며, 트랜잭션은 client.query('BEGIN') / COMMIT / ROLLBACK을 수동으로 챙기거나 라이브러리별 헬퍼를 익혀야 했죠.
Bun.sql이 이 레이어를 줄이는 방식
Bun.sql은 PostgreSQL wire protocol을 Zig으로 직접 구현했습니다. libpq에 의존하지 않고, Bun의 자체 이벤트 루프 위에서 동작합니다. 별도 C 바인딩 오버헤드가 없고, pg나 postgres.js 같은 npm 패키지도 필요 없습니다. 커넥션 풀 관리도 드라이버 안에 내장돼 있어 외부 풀 패키지를 붙일 필요가 없습니다.
2026년 8월 기준, Bun v1.2.21에서 MySQL/MariaDB Zig 드라이버도 추가되어 단일 zero-dependency SQL 클라이언트로 확장되고 있습니다.
Prepared Statement가 기본값이라는 것
Bun.sql은 prepare: true가 기본값입니다. 같은 구조의 쿼리를 반복 실행하면 PostgreSQL 서버 측에 named prepared statement로 캐싱해두고, 이후 요청에서는 파싱·플랜 생성 비용을 건너뜁니다. 여기에 binary wire protocol과 파이프라이닝으로 Bind/Execute 메시지를 묶어 보내 왕복 횟수를 줄입니다.
성능 이득은 워크로드에 크게 의존합니다. 정량 수치를 인용하려면 자기 환경에서 직접 재봐야 하고, DB I/O가 지배적인 CRUD에서는 드라이버 간 차이가 대체로 작아지는 경향이 있다는 정도로 정리하는 게 안전합니다. Bun.sql을 도입하는 실무적 근거는 성능보다는 의존성 감소와 SQL 인젝션을 API 수준에서 차단하는 구조에 있습니다.
시나리오별 코드
기본 연결과 SQL 인젝션 방지
태그드 템플릿 리터럴 방식을 강제하는 게 핵심입니다. 값을 보간하면 자동으로 파라미터화된 쿼리($1, $2)로 변환되어 raw string 삽입 자체가 구조적으로 불가능합니다.
const sql = new Bun.SQL("postgres://user:pass@localhost/mydb");
const userId = req.params.id;
const users = await sql`SELECT * FROM users WHERE id = ${userId}`;문자열 연결("SELECT ... WHERE id = " + userId)로는 쿼리를 만들 수 없습니다. API 설계 자체가 인젝션을 막는 구조입니다.
트랜잭션: 자동 COMMIT/ROLLBACK
sql.begin()에 콜백을 넘기면 단일 커넥션을 예약해 트랜잭션 안에서만 사용합니다. 콜백이 정상 return되면 자동 COMMIT, 예외가 throw되면 자동 ROLLBACK됩니다.
const sql = new Bun.SQL("postgres://localhost/mydb");
await sql.begin(async (tx) => {
const [user] = await tx`
INSERT INTO users (name, email) VALUES (${"Alice"}, ${"alice@example.com"})
RETURNING id
`;
await tx`
INSERT INTO audit_log (user_id, action) VALUES (${user.id}, ${"created"})
`;
});BEGIN / COMMIT / ROLLBACK을 직접 쓸 필요가 없고, 커넥션 반환도 자동입니다.
Savepoint: 트랜잭션 일부만 롤백하기
주문 처리처럼 "재고 업데이트는 실패해도 주문 자체는 남겨야 한다"는 시나리오에서 savepoint가 유용합니다.
await sql.begin(async (tx) => {
await tx`INSERT INTO orders (user_id) VALUES (${userId})`;
try {
await tx.savepoint(async (sp) => {
await sp`UPDATE inventory SET stock = stock - 1 WHERE product_id = ${productId}`;
if (outOfStock) {
throw new Error("재고 부족");
}
});
} catch (err) {
// savepoint 내부 예외는 여기서 잡아 외부 트랜잭션을 유지
}
await tx`INSERT INTO order_log (action) VALUES ('order_placed')`;
});savepoint 콜백에서 예외가 발생하면 해당 savepoint까지만 되돌아가고, 예외를 다시 던지지 않으면 외부 트랜잭션은 계속됩니다. 반대로 전체 트랜잭션을 실패시키고 싶다면 예외를 그대로 상위로 흘려보내면 됩니다.
분산 트랜잭션 (Two-Phase Commit)
여러 서비스가 동일한 트랜잭션에 참여해야 하는 상황에서 sql.beginDistributed()를 씁니다. 아래 API 이름은 PR #16381과 TransactionSQL 레퍼런스 기준이며, 현재 문서에서 정확한 시그니처를 확인하고 쓰는 걸 권장합니다.
2PC 흐름을 정확히 짚으면 다음과 같습니다.
- Phase 1 (Prepare): 콜백 안에서 DML을 실행한 뒤, 정상 종료 시
PREPARE TRANSACTION이 호출되어 트랜잭션이 "준비" 상태로 서버에 보존됩니다. - Phase 2 (Commit/Rollback): 이후 어느 세션에서나 글로벌 트랜잭션 ID로 최종 커밋 또는 롤백을 결정합니다.
// Phase 1: DML 실행 + PREPARE TRANSACTION
await sql.beginDistributed("txn-xyz-001", async (tx) => {
await tx`UPDATE accounts SET balance = balance - ${amount} WHERE id = ${fromId}`;
});
// Phase 2: 코디네이터의 결정에 따라 commit 또는 rollback
try {
await sql.commitDistributed("txn-xyz-001");
} catch (err) {
await sql.rollbackDistributed("txn-xyz-001");
}주의할 점은 2PC는 운영 부담이 큰 기능이라는 것입니다. 코디네이터가 Phase 2 결정을 내리기 전에 죽으면 서버에는 "prepared transaction 고아"가 남고, 이 상태는 자동 청소되지 않은 채 pg_prepared_xacts에 계속 표시되며 vacuum도 방해합니다. PostgreSQL의 max_prepared_transactions 설정을 미리 열어둬야 하고, 장애 시 수동 정리 절차와 모니터링을 반드시 준비해야 합니다. 대부분의 마이크로서비스 시나리오는 outbox 패턴이나 saga 같은 eventually consistent 접근으로 충분히 처리 가능하므로, 2PC는 정말 원자성이 필요한 경계에서만 도입하는 게 안전합니다.
단일 커넥션 점유 (Reserve)
PostgreSQL advisory lock처럼 "이 커넥션에서만 연속으로 실행해야 한다"는 경우 sql.reserve()로 커넥션 하나를 명시적으로 꺼내 씁니다.
const reserved = await sql.reserve();
try {
// reserve()는 트랜잭션이 아니므로 SET LOCAL 대신 SET을 사용
await reserved`SET statement_timeout = '5s'`;
const result = await reserved`SELECT * FROM large_table`;
return result;
} finally {
reserved.release();
}SET LOCAL은 트랜잭션 블록 안에서만 유효하고 트랜잭션 종료 시 원복됩니다. reserve()가 반환한 커넥션은 트랜잭션 없이 열려 있으므로 SET을 써야 의도대로 세션 파라미터가 적용됩니다. 그리고 finally에서 release()를 빠뜨리면 해당 커넥션이 풀로 돌아오지 않으니 주의가 필요합니다.
실무에서 마주치는 함정
PgBouncer transaction mode에서는 prepare: false
prepare: true(기본값)는 PostgreSQL 서버에 named prepared statement를 세션 단위로 캐싱합니다. 그런데 PgBouncer의 transaction pooling 모드는 쿼리마다 다른 백엔드 커넥션으로 라우팅하기 때문에, 앞서 준비된 statement가 다음 커넥션에는 존재하지 않는 상태가 됩니다.
const sql = new Bun.SQL({
url: "postgres://localhost/mydb",
prepare: false, // unnamed prepared statement 사용
});배경은 Crunchy Data 문서에 상세히 설명돼 있습니다. PgBouncer session mode에서는 prepare: true를 그대로 써도 됩니다.
Constraint violation 후 hang 버그
Issue #22395로 보고된 이슈입니다. 특정 constraint violation 이후 뒤따르는 쿼리가 무한 대기 상태에 빠지는 현상으로, 커넥션 풀 전체가 멈출 위험이 있습니다. 재현 조건을 확인할 때는 일반적으로 자주 발생하는 23505(unique violation)나 23503(foreign key violation) 같은 SQLSTATE로 검증해보는 게 실질적입니다. 2026년 8월 현재 production 적용을 고려한다면 픽스 버전을 확인한 뒤 도입하는 걸 권장합니다.
내장 풀의 eager 초기화
Bun.sql의 내장 풀은 Issue #30631 기준으로 최대 커넥션 수까지 즉시 생성하는 eager 전략이며, lazy 초기화 옵션은 아직 없습니다. 여러 인스턴스를 띄우는 환경이거나 개발 중 잦은 재시작이 있는 워크플로에서는 DB 측 커넥션 수가 예상보다 빠르게 채워질 수 있습니다.
성능 이야기
drift-prone한 벤치마크 수치를 그대로 옮기는 대신, Bun.sql이 주는 실무 이득을 성능 축이 아닌 구조 축으로 보는 게 정확합니다. binary wire protocol과 파이프라이닝 덕에 raw 벤치마크에서 유리한 지점이 있는 건 사실이지만, 실제 서비스에서는 DB I/O·인덱스·플랜이 지배적인 요소이므로 드라이버 교체만으로 극적인 변화를 기대하기는 어렵습니다. pg-pool을 걷어내고 의존성 트리를 줄이는 것, 태그드 템플릿으로 인젝션 위험을 API 레벨에서 차단하는 것, 트랜잭션·savepoint를 콜백 패턴으로 통일하는 것 — 이 세 가지가 실무에서 체감되는 이득에 가깝습니다.
Drizzle ORM과 함께
type-safe 쿼리 빌더가 필요하다면 Drizzle이 bun-sql 어댑터를 공식 지원하므로 조합이 가능합니다. 2026년 기준 신규 Bun 기반 PostgreSQL 프로젝트에서 하나의 선택지로 자리잡고 있습니다.
정리하며
여기까지의 내용을 종합하면 판단은 비교적 분명합니다. 신규 Bun 기반 서비스이고, PgBouncer를 쓴다면 session mode(또는 prepare: false)로 운영 가능하며, 트랜잭션 경계 안에서 대부분의 원자성을 해결하는 팀이라면 Bun.sql 단독으로 가는 선택은 합리적입니다. 외부 SQL 패키지를 걷어내고 태그드 템플릿·sql.begin() 패턴 하나로 통일된 코드베이스를 얻는 이득이 명확합니다.
반대로 다음 조건 중 하나에 해당한다면 도입을 미루거나 부분 적용부터 시작하는 편이 안전합니다.
- PgBouncer transaction mode를 이미 광범위하게 사용 중이고
prepare: false로 전환할 여지가 없을 때 — 서버 측 statement 캐싱 이점이 사라지고 라우팅 부작용이 남습니다. - 2PC 기반 분산 트랜잭션을 상시 사용하는 경우 — API는 준비돼 있지만 운영 부담이 크므로, saga·outbox 등 다른 패턴을 먼저 검토하는 게 낫습니다.
- Node.js와 런타임을 병행해야 하는 상황 —
Bun.sql은 Bun 전용 API이므로 클라이언트 코드가 런타임에 묶입니다. - production 안정성이 최우선이고 #22395의 픽스 확인이 어려운 경우 — hang 이슈가 풀 전체에 영향을 줄 수 있습니다.
기술 선택은 결국 팀의 운영 환경과 맞물리는 문제입니다. Bun.sql은 "얇게 만든 래퍼"가 아니라 프로토콜부터 다시 짠 드라이버라는 점에서, 앞으로 몇 분기 동안 성숙도를 지켜볼 가치가 충분한 카드입니다.
참고 자료
- Bun 공식 SQL 문서
- Bun TransactionSQL TypeScript 인터페이스
- Bun SavepointSQL TypeScript 인터페이스
- Bun ReservedSQL 레퍼런스
- SQL and Database APIs - DeepWiki (oven-sh/bun)
- Bun v1.2.21 릴리즈 노트
- PR #16381: feat(sql) transactions, savepoints, connection pooling and reserve
- Issue #22395: Postgres driver hangs after constraint violation
- Issue #30631: Customizable Connection Pool Strategy
- Drizzle ORM - Bun SQL 연동 문서
- Crunchy Data: PgBouncer transaction mode와 prepared statements