`pg` 없이 PostgreSQL 다루기 — Bun.sql로 준비된 구문·트랜잭션·타입 안전 쿼리 레이어 구성하기
Node.js 백엔드를 오래 운영하다 보면 package.json이 조금씩 무거워지는 걸 느끼게 됩니다. PostgreSQL 하나 붙이려고 pg, pg-pool, @types/pg를 깔고, 타입 안전성이 갖고 싶어서 ORM 하나 더 얹다 보면 의존성 트리가 어느새 수백 개 패키지로 불어나 있죠. 저도 그 상황에서 "이걸 언제쯤 줄일 수 있을까" 생각을 자주 했었는데, Bun 1.2가 나오면서 그 고민이 꽤 달라졌습니다.
Bun 1.2는 Bun.sql이라는 네이티브 PostgreSQL 클라이언트를 런타임에 직접 번들링했습니다. npm install pg가 필요 없고, 커넥션 풀 라이브러리도 따로 설정하지 않아도 됩니다. 더 흥미로운 건 자동 prepared statement, 트랜잭션 콜백 API, TypeScript 제네릭 반환 타입 지원이 모두 이 안에 들어있다는 점입니다. 이 글에서는 Bun.sql이 실제로 어떻게 동작하는지, 준비된 구문과 트랜잭션을 어떻게 다루는지, 그리고 타입 안전한 쿼리 레이어를 얹는 패턴까지 살펴봅니다.
pg 없이 PostgreSQL을 쓴다는 게 어떤 의미인가
와이어 프로토콜을 Zig으로 직접 구현했다는 것
Bun.sql은 단순히 pg 위에 래퍼를 씌운 게 아닙니다. PostgreSQL Wire Protocol을 Zig 언어로 새로 구현해서 Bun 런타임 자체에 내장했습니다. 여기서 "의존성 제로"라고 하면 오해가 생길 수 있어 정확히 짚자면, npm으로 설치해야 하는 외부 드라이버 패키지가 없다는 뜻입니다. Bun 런타임 자체는 당연히 필요합니다.
API 설계는 postgres.js를 참조했기 때문에 이미 postgres.js를 쓰고 있다면 import 경로만 바꿔도 대부분의 코드가 그대로 동작합니다.
// 기존 postgres.js
import postgres from "postgres";
const sql = postgres(process.env.DATABASE_URL);
// Bun.sql로 전환
import { sql } from "bun";
// 환경변수 DATABASE_URL 또는 POSTGRES_URL을 자동으로 읽습니다Tagged Template Literal이 SQL 인젝션을 막는 실제 이유
sql`SELECT * FROM users WHERE id = ${userId}` 형태를 보면 값이 그대로 문자열에 박히는 것처럼 보이지만, 실제로는 그렇지 않습니다. 초안에서는 "TemplateStringsArray가 상수 배열로 고정되기 때문에 안전하다"고 설명했는데, 이는 인과관계가 어긋난 서술이었습니다. 배열이 고정된다는 성질 자체가 인젝션을 막는 게 아니라, Bun.sql 구현이 이 구조를 활용해서 SQL 텍스트와 파라미터 값을 PostgreSQL Extended Query Protocol의 별도 채널로 나눠 전송하기 때문에 안전합니다.
즉, 서버는 "쿼리 텍스트"와 "바인딩 파라미터"를 각각 다른 메시지로 받고, 파라미터 값은 절대 파서를 거치지 않습니다. 사용자 입력이 문자열 조합으로 파서에 들어갈 경로가 애초에 없어야 인젝션이 막히는 것이지, tagged template 문법 그 자체가 마법을 부리는 건 아닙니다. 이 점을 오해해서 sql(`SELECT * FROM users WHERE id = ${userId}`) 처럼 일반 문자열 연결을 넣어 호출하면 방어가 무너집니다.
import { sql } from "bun";
// userId가 어떤 문자열이든 파라미터 채널로만 전달됨
// '1; DROP TABLE users' 같은 값도 파서에 닿지 않습니다
const users = await sql<{ id: number; name: string }>`
SELECT id, name FROM users WHERE id = ${userId}
`;자동 Prepared Statement — 설정 없이 얻는 성능 이득
솔직히 처음 문서를 읽었을 때 "자동으로 prepared statement가 된다"는 게 반신반의했습니다. 그냥 마케팅 문구 아닐까 싶었는데, 동작 원리를 파고드니 구체적인 메커니즘이 있었습니다.
어떻게 동작하는가
쿼리를 처음 실행할 때 Bun은 PostgreSQL 서버에 Parse 메시지를 전송합니다. 서버는 이 시점에 파싱과 플래닝을 수행하고 실행 계획을 캐싱합니다. 이후 같은 쿼리 구조가 다른 파라미터로 호출되면 저장된 statement에 Bind + Execute만 전송되므로 파싱·플래닝 단계를 반복하지 않습니다.
import { sql } from "bun";
async function getUserByEmail(email: string) {
// 첫 호출 시 서버에 실행 계획이 캐싱됨
// 이후 호출에서는 파라미터만 바뀌어 재사용
const [user] = await sql<{ id: number; name: string; email: string }>`
SELECT id, name, email FROM users WHERE email = ${email} LIMIT 1
`;
return user ?? null;
}이메일 조회처럼 쿼리 구조는 동일하고 값만 바뀌는 반복 쿼리에서 이 효과가 두드러집니다. 구체적인 성능 수치는 워크로드와 스키마에 따라 크게 달라지므로, 실제 도입 전에는 자신의 쿼리 패턴으로 벤치마크를 돌려보는 것이 안전합니다.
PGBouncer와 함께 쓸 때 주의점
PGBouncer 트랜잭션 모드를 사용하는 환경에서는 이 자동 prepared statement가 충돌을 일으킵니다. PGBouncer가 커넥션을 클라이언트마다 고정하지 않고 풀에서 재분배하기 때문에, 한 커넥션에서 만든 prepared statement의 서버 측 상태가 다른 커넥션에서는 존재하지 않는 문제가 생깁니다.
Bun은 이를 우회하기 위해 쿼리별로 prepared statement를 끄는 방법(예: .simple() 메서드나 연결 옵션의 prepare: false)을 제공합니다. 다만 정확한 API 이름과 도입 시점은 릴리즈마다 변동이 있으니, 실제 프로젝트에 반영하기 전에는 사용 중인 Bun 버전의 공식 SQL 문서에서 현재 옵션명을 확인하는 걸 권장합니다.
인프라 레벨에서 해결하는 선택지도 있습니다. Crunchy Data의 정리에 따르면 PGBouncer 1.21부터 트랜잭션 모드에서도 prepared statement를 지원하므로, 애플리케이션 코드에서 매번 옵션을 꺼내려주기보다 PGBouncer 버전을 올리는 편이 더 깔끔한 경우가 많습니다.
트랜잭션 처리 — sql.begin()의 실제 동작
원자적 트랜잭션
sql.begin()은 내부적으로 커넥션 풀에서 전용 커넥션을 예약하고 BEGIN을 자동 전송합니다. 콜백에서 예외가 발생하면 자동으로 ROLLBACK을, 콜백이 정상 완료되면 COMMIT을 보냅니다.
async function transferFunds(fromId: number, toId: number, amount: number) {
return sql.begin(async (tx) => {
const [from] = await tx`
UPDATE accounts SET balance = balance - ${amount}
WHERE id = ${fromId} AND balance >= ${amount}
RETURNING *
`;
if (!from) throw new Error("잔액 부족");
const [to] = await tx`
UPDATE accounts SET balance = balance + ${amount}
WHERE id = ${toId}
RETURNING *
`;
return { from, to };
// 여기서 예외가 발생하면 두 UPDATE 모두 ROLLBACK됩니다
});
}트랜잭션 접근 모드 지정
sql.begin() 첫 번째 인자에는 문자열로 트랜잭션 옵션을 지정할 수 있습니다. 여기서 짚어둘 개념 정리가 하나 있는데, READ WRITE는 PostgreSQL의 **접근 모드(access mode)**이지 격리 수준(isolation level)이 아닙니다. 격리 수준은 SERIALIZABLE, REPEATABLE READ, READ COMMITTED, READ UNCOMMITTED가 해당합니다. 두 개념을 섞어서 이해하고 있으면 튜닝할 때 헷갈리기 쉬우니 나눠서 기억해두는 게 좋습니다.
// 접근 모드 지정
const [user, account] = await sql.begin("read write", async (tx) => {
const [user] = await tx`
INSERT INTO users (name) VALUES (${"Alice"}) RETURNING *
`;
const [account] = await tx`
INSERT INTO accounts (user_id) VALUES (${user.id}) RETURNING *
`;
return [user, account];
});
// 격리 수준을 함께 지정하는 경우
await sql.begin("isolation level serializable, read write", async (tx) => {
// ...
});트랜잭션 흐름 다이어그램
분산 트랜잭션이 필요하다면
여러 PostgreSQL 인스턴스에 걸친 2단계 커밋(2PC)이 필요한 경우도 있습니다. Bun.sql이 이를 위한 전용 헬퍼(예: postgres.js 계열의 beginDistributed 유사 API)를 노출하는지는 사용 중인 Bun 버전과 릴리즈 노트를 확인해야 합니다. 지원되는 헬퍼가 없더라도 PostgreSQL 자체의 PREPARE TRANSACTION / COMMIT PREPARED / ROLLBACK PREPARED 커맨드를 직접 실행해 2PC를 구현할 수 있으므로, API 존재 여부에 관계없이 프로토콜 자체로는 대응이 가능합니다.
타입 안전 쿼리 레이어 얹기
제네릭만으로 만드는 간단한 반환 타입 지정
Bun.sql의 제네릭 파라미터를 활용하면 별도 ORM 없이도 반환 행 타입을 명시할 수 있습니다.
import { sql } from "bun";
interface User {
id: number;
name: string;
email: string;
created_at: Date;
}
// 기본 사용법 — 제네릭으로 반환 행 타입 지정
const users = await sql<User[]>`
SELECT * FROM users WHERE created_at > ${new Date("2025-01-01")}
`;
// 단일 행이 필요할 때 구조 분해 활용
const [user] = await sql<User[]>`
SELECT * FROM users WHERE id = ${userId} LIMIT 1
`;
// user: User | undefined이 방식의 한계는 명확합니다. 컴파일 타임에 쿼리 결과 컬럼을 자동 추론하지 못합니다. SELECT id, name FROM users처럼 컬럼을 일부만 조회해도 개발자가 지정한 User 타입으로 취급되기 때문에, 타입은 붙어 있지만 실제 결과와 어긋날 수 있습니다. 컬럼 레벨까지 안전성이 필요하다면 Drizzle이나 Kysely 같은 빌더가 필요해집니다.
래퍼로 감싸기 전에 고민할 점
초안에서는 얇은 typedQuery 래퍼로 재사용성을 높이는 예시를 들었는데, 되짚어보면 실용성이 애매합니다. Bun.sql이 반환하는 객체는 Promise이면서도 .execute(), .simple(), .values() 같은 체이닝 메서드를 노출하는 특수한 형태입니다. 이걸 Promise<T[]>로 강제 캐스트하면 체이닝 API를 잃어버리고, await 이전에 옵션을 붙이던 코드가 조용히 깨질 수 있습니다.
그래서 저는 얇은 캐스트 래퍼보다는 레포지토리 함수 단위로 반환 타입만 지정하고 필요하면 그 안에서 체이닝을 활용하는 방식을 선호합니다.
async function findUsersSince(date: Date): Promise<User[]> {
return sql<User[]>`
SELECT id, name, email, created_at
FROM users
WHERE created_at > ${date}
ORDER BY created_at DESC
`;
}Drizzle ORM과 결합 — 컬럼 레벨 타입 안전성이 필요할 때
컬럼 레벨까지 타입을 붙이고 싶다면 Drizzle ORM의 Bun SQL 어댑터를 조합하는 선택지가 있습니다. Drizzle이 스키마 정의, 마이그레이션, 타입 안전 쿼리 빌더 역할을 맡고 Bun.sql이 실제 실행 드라이버 역할을 하는 구조입니다. Drizzle 어댑터의 정확한 export 경로와 함수 시그니처는 버전에 따라 달라지므로 실제 도입 시 공식 문서로 확인하는 것이 안전합니다.
// 개념적 예시 — 실제 API는 Drizzle 최신 문서 참고
import { drizzle } from "drizzle-orm/bun-sql";
import { sql } from "bun";
import { eq } from "drizzle-orm";
import { users } from "./schema";
const db = drizzle(sql);
const activeUsers = await db
.select()
.from(users)
.where(eq(users.active, true));Bun.sql + Drizzle 조합이 실제로 얼마나 널리 쓰이는지에 대한 객관적 지표는 아직 명확하지 않지만, Drizzle 측이 Bun SQL 어댑터를 공식 지원하고 있는 만큼 Bun 전용 스택에서 유력한 후보 중 하나로 자리잡고 있는 건 사실입니다.
트레이드오프 — 솔직한 평가
장점
| 항목 | 내용 |
|---|---|
| 의존성 축소 | pg, pg-pool, @types/pg 등 외부 npm 드라이버 불필요. Bun 런타임 설치만으로 PostgreSQL 연결 가능 |
| 성능 | 네이티브 Zig 구현으로 파이프라이닝·프로토콜 처리 최적화. 정량 수치는 워크로드마다 달라지므로 공식 릴리즈 노트와 자체 벤치마크 병행 확인 권장 |
| SQL 인젝션 방어 | Extended Query Protocol 기반의 파라미터 분리 전송으로 tagged template 사용 시 인젝션 경로 차단 |
| 커넥션 풀 | 별도 풀링 라이브러리 없이 기본 제공 |
| 콜드 스타트 | 외부 드라이버 로딩 없어 서버리스·엣지 환경에 유리 |
단점 및 고려사항
| 항목 | 내용 |
|---|---|
| Bun 전용 | Node.js에서는 동작하지 않음. 런타임을 Bun으로 고정해야 함 |
| PGBouncer 호환 | 트랜잭션 모드에서 prepared statement 충돌 가능. 애플리케이션 옵션으로 끄거나 PGBouncer 1.21+로 인프라 레벨 해결 필요 |
| 타입 추론 한계 | 컬럼 레벨 자동 추론 불가. 컴파일 타임 완전한 타입 안전성은 Drizzle·Kysely 등 별도 빌더 필요 |
| 분산 트랜잭션 API | 2PC 헬퍼가 별도 노출되는지는 버전별 확인 필요. 없더라도 PREPARE TRANSACTION 직접 실행으로 대응 가능 |
| 생태계 성숙도 | pg 드라이버 대비 플러그인·미들웨어 에코시스템이 상대적으로 빈약 |
실무에서 흔히 겪는 함정
1. 트랜잭션 콜백 안에서 sql을 직접 쓰는 실수
콜백 안에서 sql 대신 tx를 써야 합니다. sql을 쓰면 트랜잭션 밖에서 실행됩니다.
// 잘못된 예시 — tx가 아닌 sql을 쓰면 트랜잭션 밖에서 실행됨
await sql.begin(async (tx) => {
await sql`UPDATE accounts SET balance = 0 WHERE id = ${id}`; // 위험!
});
// 올바른 예시
await sql.begin(async (tx) => {
await tx`UPDATE accounts SET balance = 0 WHERE id = ${id}`;
});2. 동적 테이블명·컬럼명 삽입
Tagged template literal은 값만 파라미터화하기 때문에 테이블명이나 컬럼명은 그 자리에 넣을 수 없습니다. Bun.sql은 식별자용 이스케이프 헬퍼를 별도로 제공하는데, 정확한 함수명(예: sql() 호출 형태 등)은 릴리즈에 따라 다를 수 있으니 실제 사용 전 공식 SQL API 문서에서 현행 시그니처를 확인하는 걸 권장합니다. 어떤 경우에도 사용자 입력 문자열을 직접 concat해서 식별자로 쓰는 방식은 피해야 합니다.
3. prepare: false를 전역으로 켜는 것의 비용
PGBouncer 호환성 때문에 모든 쿼리에서 prepared statement를 끄면 반복 쿼리의 파싱·플래닝 비용이 매번 발생합니다. 정말 필요한 쿼리에만 부분 적용하거나, PGBouncer 1.21 이상으로 업그레이드해 인프라 레벨에서 해결하는 편이 좋습니다.
도입 결정에 도움이 되는 판단 프레임
트레이드오프 표에서 다룬 기술적 조건 외에, 팀·프로젝트 맥락에서 함께 따져보면 좋은 축이 몇 가지 있습니다.
정리하면 판단할 축은 크게 네 가지입니다.
- 런타임 전략: Bun과 Node.js를 병행해야 한다면
Bun.sql은 코드 이식성을 떨어뜨리므로 지금은 시기상조입니다. - 팀의 Bun 운영 경험: 프로덕션에서 Bun을 돌려본 적이 없다면, 크리티컬 서비스에 바로 넣기보다 배치 잡이나 내부 도구부터 붙여보는 것을 권합니다.
- 레거시 전환 비용: 이미
pg+ ORM 조합이 안정적으로 돌고 있고 의존성 부담이 크지 않다면 굳이 바꿀 유인이 크지 않습니다. 반대로 콜드 스타트가 병목인 서버리스 함수 같은 곳은 우선 후보입니다. - 타입 안전성 요구 수준: 단순 CRUD 위주라면 제네릭만으로 충분하지만, 조인·부분 컬럼 조회가 잦은 도메인이라면 Drizzle 같은 빌더 병행을 처음부터 전제로 두는 편이 낫습니다.
pg 드라이버가 없어지면 뭔가 불안할 것 같다는 느낌이 들 수 있는데, 실제로 써보면 API가 postgres.js와 거의 동일해서 적응 비용이 생각보다 낮습니다. 기존 코드가 있다면 import 경로부터 바꿔 테스트 환경에서 돌려보는 것만으로도 충분한 첫 걸음이 됩니다.