Bun 1.2 내장 PostgreSQL 드라이버로 트랜잭션 격리 수준을 제어하고 대용량 쿼리를 처리하는 방법
런타임을 Bun으로 바꿔볼까 검토하면서 가장 먼저 든 생각이 "그러면 DB 드라이버는?"이었습니다. Node.js에서는 pg나 postgres.js를 당연하게 설치했는데, Bun 1.2(2025년 1월 출시)에 외부 npm 패키지 없이 동작하는 내장 PostgreSQL 클라이언트가 생겼다는 소식을 보고 처음엔 반신반의했거든요.
써보니 생각보다 잘 되어 있었습니다. Zig으로 작성된 네이티브 드라이버가 PostgreSQL 와이어 프로토콜과 직접 통신하고, API 형태는 postgres.js에서 영감을 받은 Tagged Template Literal 방식이라 마이그레이션 부담도 크지 않았습니다. 이 글에서는 Bun SQL로 트랜잭션 격리 수준을 실제로 어떻게 다루는지, 그리고 대용량 데이터를 처리할 때 현재 어떤 방법이 현실적인지 — 공식 문서에서 잘 보여주지 않는 부분들을 중심으로 풀어보겠습니다.
시점 기준은 이 글 전체에서 2026년 8월 기준으로 통일했습니다.
bun:sql이 등장한 맥락과 현재 위치
제로 의존성 드라이버가 만들어진 이유
Node.js 생태계에서 PostgreSQL 드라이버는 node-postgres(pg) 또는 postgres.js 둘 중 하나를 선택해왔습니다. 두 라이브러리 모두 훌륭하지만, 외부 패키지라는 사실 자체가 번들 크기, 보안 감사, 버전 충돌 같은 관리 비용을 만들어냅니다.
Bun은 이 지점을 bun:sql(또는 Bun.SQL)로 건드렸습니다. 런타임 자체에 드라이버가 포함되어 있어 package.json 의존성이 하나 줄어듭니다. Bun v1.2 릴리즈 노트는 특정 벤치마크에서 pg 대비 행 읽기 속도가 두 배가량 빠르다는 자체 수치를 제시하는데, 어디까지나 Bun 측 발표이므로 실제 워크로드에서는 직접 측정한 값과 함께 해석하는 게 좋습니다.
2025년 8월 v1.2.21에서 MySQL/MariaDB 지원이 추가되면서 PostgreSQL·MySQL·SQLite를 하나의 API에서 다루는 방향으로 확장됐습니다.
트랜잭션 격리 수준, 언제 헷갈리기 시작하나
READ COMMITTED가 기본값이라는 건 알아도 REPEATABLE READ와 SERIALIZABLE의 차이를 실무에서 명확히 구분해서 쓰는 팀은 생각보다 많지 않습니다. 저도 처음엔 "SERIALIZABLE이 제일 안전하니까 그냥 다 SERIALIZABLE 쓰면 되지 않나?" 하고 생각했는데, 실제로는 격리 수준이 올라갈수록 충돌 감지와 재시도 비용이 커집니다.
PostgreSQL 공식 문서를 기준으로 정리하면 이렇습니다. PostgreSQL은 READ UNCOMMITTED를 요청해도 내부적으로 READ COMMITTED로 승격 처리하므로, 두 수준의 실제 관찰 가능한 이상 현상은 같습니다:
| 격리 수준 | Dirty Read | Non-Repeatable Read | Phantom Read | 주요 사용처 |
|---|---|---|---|---|
| READ UNCOMMITTED | 방지 | 발생 가능 | 발생 가능 | PG에서는 READ COMMITTED와 동일 |
| READ COMMITTED | 방지 | 발생 가능 | 발생 가능 | 일반 CRUD, PostgreSQL 기본값 |
| REPEATABLE READ | 방지 | 방지 | 방지 (PG 특성) | 읽기 전용 리포트, 일관된 스냅샷 |
| SERIALIZABLE | 방지 | 방지 | 방지 | 금융 이체, 재고 차감 등 쓰기 충돌 민감 로직 |
어떤 수준을 선택할지 판단 흐름을 정리하면 다음과 같습니다:
코드로 살펴보는 Bun SQL의 격리 수준 제어
연결 설정과 기본 쿼리
import { SQL } from "bun";
const sql = new SQL({
url: "postgres://user:pass@localhost/mydb",
});
const users = await sql`SELECT * FROM users WHERE email = ${email}`;${email} 부분은 문자열 보간이 아니라 파라미터 바인딩으로 처리되기 때문에 SQL 인젝션 걱정 없이 쓸 수 있습니다. postgres.js를 써본 분이라면 API가 거의 동일하다고 느낄 겁니다.
시나리오 1: 금융 이체 — SERIALIZABLE과 재시도
PostgreSQL의 SERIALIZABLE은 SSI(Serializable Snapshot Isolation)로 구현되어 있어, 잠금을 걸지 않고 트랜잭션 간의 read-write 의존성을 추적합니다. 위험한 의존성 그래프가 감지되면 커밋 시점(또는 실행 도중)에 SQLSTATE 40001 serialization_failure 오류를 던지고 트랜잭션을 중단시킵니다.
여기서 두 가지 실수를 피해야 합니다.
FOR UPDATE를 함께 쓰지 않기.FOR UPDATE는 비관적 행 잠금이라 SSI의 낙관적 방식과 성격이 다릅니다. SERIALIZABLE 예제에FOR UPDATE를 얹으면 "SERIALIZABLE에서도 명시적 잠금이 필요하다"는 잘못된 인식을 심어줍니다. SSI에 맡길 거면 잠금 힌트 없이 순수한 SELECT/UPDATE만 씁니다.- 재시도 로직 없이 상위로 예외 전파하지 않기.
40001은 "잠시 후 다시 시도하라"는 뜻의 일시적 오류이므로, 애플리케이션 레벨에서 다시 트랜잭션을 여는 래퍼가 필수입니다.
async function transfer(fromId, toId, amount, { maxRetries = 3 } = {}) {
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
await sql.begin("isolation level serializable", async (tx) => {
const [from] = await tx`
SELECT balance FROM accounts WHERE id = ${fromId}
`;
const [to] = await tx`
SELECT balance FROM accounts WHERE id = ${toId}
`;
if (from.balance < amount) {
throw new Error("잔액 부족");
}
await tx`UPDATE accounts SET balance = balance - ${amount} WHERE id = ${fromId}`;
await tx`UPDATE accounts SET balance = balance + ${amount} WHERE id = ${toId}`;
});
return;
} catch (err) {
if (err.code === "40001" && attempt < maxRetries - 1) {
const backoff = 2 ** attempt * 50 + Math.random() * 25;
await new Promise((r) => setTimeout(r, backoff));
continue;
}
throw err;
}
}
throw new Error("이체 재시도 한도 초과");
}sql.begin() API에는 격리 수준을 문자열로 전달합니다. "isolation level serializable read write", "isolation level repeatable read read only" 같은 형태가 PostgreSQL BEGIN 문에 그대로 전달됩니다.
한 가지 걸리는 점은 이 문자열이 타입 안전하지 않다는 것입니다. "isolation levl serializable" 같은 오타를 내도 컴파일 타임에 잡히지 않으므로 상수로 빼두는 편이 안전합니다:
const ISOLATION = {
serializable: "isolation level serializable",
repeatableReadOnly: "isolation level repeatable read read only",
readCommitted: "isolation level read committed",
};
await sql.begin(ISOLATION.serializable, async (tx) => { /* ... */ });시나리오 2: 읽기 전용 리포트 — REPEATABLE READ
일별 매출 리포트처럼 여러 쿼리가 동일한 시점의 스냅샷을 기준으로 계산되어야 할 때 REPEATABLE READ를 씁니다. READ ONLY를 함께 선언하면 PostgreSQL이 관련된 잠금·예측 검사 부담을 줄일 수 있어 리포트성 워크로드에 잘 맞습니다.
async function getDailyReport(today) {
return await sql.begin(
"isolation level repeatable read read only",
async (tx) => {
const [summary] = await tx`
SELECT SUM(amount) AS total FROM orders WHERE order_date = ${today}
`;
const items = await tx`
SELECT product_id, SUM(quantity) AS qty
FROM order_items
WHERE order_date = ${today}
GROUP BY product_id
`;
return { total: summary.total, items };
}
);
}PostgreSQL MVCC가 트랜잭션 시작 시점의 스냅샷을 고정하기 때문에, 리포트 실행 중에 다른 트랜잭션이 데이터를 변경해도 이 안에서 보이는 결과는 흔들리지 않습니다.
시나리오 3: Savepoint로 부분 롤백 처리하기
트랜잭션 전체를 날리지 않고 특정 구간만 롤백하고 싶을 때 Savepoint가 유용합니다. Bun SQL은 tx.savepoint()로 이를 지원합니다:
await sql.begin(async (tx) => {
await tx`INSERT INTO audit_logs (event, created_at) VALUES ('batch_start', NOW())`;
try {
await tx.savepoint(async (sp) => {
await sp`INSERT INTO risky_table (data) VALUES (${riskyData})`;
await sp`UPDATE counters SET value = value + 1 WHERE key = 'risky'`;
});
} catch (err) {
console.warn("risky 작업 실패, 건너뜀:", err.message);
}
await tx`INSERT INTO audit_logs (event, created_at) VALUES ('batch_end', NOW())`;
});tx.savepoint() 콜백 내부에서 에러가 발생하면 해당 savepoint까지만 롤백되고, 외부 트랜잭션은 계속 진행됩니다. 감사 로그는 남기고 위험한 연산만 선택적으로 건너뛰는 패턴에 잘 맞습니다.
대용량 데이터 처리 — 현실과 타협하기
2026년 8월 기준으로, 조사하면서 가장 기대했던 sql\SELECT ...`.stream()` 형태의 네이티브 AsyncIterator API는 아직 공식 지원되지 않습니다. GitHub Issue #25307에 기능 요청이 올라와 논의가 이어지고 있습니다.
그렇다면 Bun 환경에서 대용량 데이터는 어떻게 처리할 수 있을까요?
옵션 1: postgres.js를 병행 사용
postgres.js는 .cursor() API로 서버 사이드 커서를 지원하고 Bun 런타임에서도 정상 동작합니다. 스트리밍이 꼭 필요하다면 이 라이브러리를 병행하는 것이 가장 현실적입니다:
import postgres from "postgres";
const pg = postgres(process.env.DATABASE_URL);
for await (const [row] of pg`SELECT * FROM large_table`.cursor(100)) {
await processRow(row);
}
await pg.end();옵션 2: keyset 페이지네이션(OFFSET 대안)
OFFSET 방식은 페이지가 깊어질수록 성능이 급격히 떨어집니다. 마지막으로 처리한 키를 기준으로 다음 페이지를 가져오는 keyset 방식이, Bun SQL만으로 처리할 때 더 안정적입니다.
단, 아래 예시는 id가 단조 증가하는 정수형 PK(예: bigserial)이고 정렬 컬럼이 유일한 경우를 전제로 합니다. UUIDv4·비순차 시퀀스·복합 PK에는 그대로 쓸 수 없고, 이런 경우에는 정렬 가능한 UUIDv7이나 (created_at, id) 복합 커서 같은 별도 설계가 필요합니다.
let lastId = 0;
while (true) {
const rows = await sql`
SELECT * FROM large_table
WHERE id > ${lastId}
ORDER BY id
LIMIT 1000
`;
if (rows.length === 0) break;
for (const row of rows) {
await processRow(row);
lastId = row.id;
}
}스트리밍이 필요한 상황에서 선택 흐름을 정리하면 다음과 같습니다:
실무에서 마주치는 트레이드오프
전체 비교 (2026년 8월 기준)
| 항목 | Bun SQL | node-postgres (pg) | postgres.js |
|---|---|---|---|
| 의존성 | 없음 (런타임 내장) | npm 패키지 | npm 패키지 |
| 서버 사이드 커서 스트리밍 | 미지원 (Issue #25307 논의 중) | 지원 | 지원 (cursor()) |
| PgBouncer 트랜잭션 풀링 | 기본 설정에서 충돌 | 호환 | 호환 |
| ORM 연동 | 제한적 | 안정적 | Drizzle 등 지원 |
| Savepoint | 지원 | 지원 | 지원 |
| 격리 수준 제어 | 문자열 방식 | 문자열 방식 | 문자열 방식 |
| 런타임 종속 | Bun 전용 | Node.js / Bun | Node.js / Bun |
PgBouncer 트랜잭션 풀링과 prepare: false의 함정
Bun SQL은 기본적으로 Prepared Statement를 사용합니다. 문제는 PgBouncer의 트랜잭션 풀링 모드가 Prepared Statement를 지원하지 않아 연결 오류가 발생한다는 점입니다. GitHub Issue #17044에 관련 사례가 정리되어 있습니다.
우회 방법은 prepare: false입니다:
const sql = new SQL({
url: process.env.DATABASE_URL,
prepare: false,
});여기서 반드시 짚어야 할 트레이드오프가 있습니다. prepare: false를 켜면 매 쿼리마다 파싱과 플래닝 비용이 다시 발생합니다. Bun SQL이 강조하는 성능 이점(파싱된 계획의 재사용, 바인딩 오버헤드 감소) 상당수가 이 옵션 하나로 사실상 사라진다고 보는 것이 정확합니다. PgBouncer 트랜잭션 풀링을 유지해야 한다면 "드라이버 성능을 어느 정도 포기하는 대신 커넥션 풀 이점을 취한다"는 결정을 명시적으로 내리고 가야 합니다. 프로젝트가 Supabase 환경이라면 Supavisor로 옮기는 것을 검토해볼 만합니다.
ORM과의 조합
Drizzle이나 Prisma를 쓰는 경우, 2026년 8월 기준으로 Bun.SQL을 드라이버 레이어로 직접 지원하는 통합은 제한적입니다. Drizzle은 postgres.js 위에 얹는 방식이 여전히 가장 안정적이고, Prisma는 자체 드라이버를 사용합니다. ORM이 필수인 프로젝트라면 Bun SQL 단독보다 "Bun 런타임 + postgres.js 드라이버 + ORM" 조합이 현실적인 출발점입니다.
정리하며: 두 가지 의사결정 기준
이 글에서 강조하고 싶은 지점은 두 가지입니다.
첫째, SERIALIZABLE은 재시도 로직과 세트다. sql.begin("isolation level serializable", ...) 한 줄로 격리 수준을 올릴 수 있다고 해서 안전해지는 것이 아닙니다. SSI는 낙관적 방식이라 충돌 시 40001을 던지고 트랜잭션을 죽이므로, 애플리케이션에서 지수 백오프로 다시 여는 래퍼가 없으면 오히려 실패율이 늘어납니다. FOR UPDATE를 얹어 잠금 세계관을 섞지 않는 것도 함께 기억할 부분입니다.
둘째, 스트리밍 요구 사항이 Bun SQL 단독 사용의 분기점이다. 격리 수준 제어, Savepoint, 트랜잭션 콜백 API는 이미 실무에서 무리 없이 쓸 수 있는 수준입니다. 하지만 서버 사이드 커서 기반 스트리밍이 워크로드에 필수라면, 2026년 8월 기준으로는 postgres.js 병행이 답에 가깝습니다. keyset 페이지네이션으로 대체할 수 있는 워크로드인지 먼저 확인하고, 그렇지 않다면 굳이 Bun SQL로 통일하려 애쓰지 않는 편이 낫습니다.
여기에 PgBouncer 트랜잭션 풀링을 쓰고 있다면 prepare: false가 드라이버 성능 이점을 상당 부분 상쇄한다는 점까지 인지한 상태로 결정을 내리면, Bun 전환에 따른 예상 밖의 회귀는 대체로 피할 수 있습니다.