Bun.sql로 PostgreSQL 트랜잭션 격리 수준 제어하기 — isolation 옵션과 savepoint의 역할 구분
두 사용자가 마지막 재고 1개를 거의 같은 순간에 결제합니다. 두 요청 모두 200 OK를 받고, DB의 inventory.qty는 -1이 됩니다. 아래 타임라인이 문제의 실제 모습입니다.
기본 격리 수준 READ COMMITTED가 이 시나리오를 막지 못한다는 게 핵심입니다. Bun 1.2가 도입한 Zig 기반 제로 의존성 PostgreSQL 드라이버 Bun.sql은 sql.begin("isolation level serializable", ...) 한 줄로 격리 수준을 제어할 수 있습니다. 그런데 같은 문서에서 자주 마주치는 tx.savepoint()가 비슷한 문제를 푼다고 착각하는 경우를 자주 봅니다. 격리 수준과 savepoint는 직교하는 개념입니다. 하나는 동시성 이상(anomaly)을 막고, 다른 하나는 트랜잭션 내 부분 롤백을 가능하게 합니다. 이 글은 두 도구의 역할을 코드 시나리오로 구분한 뒤, 조합 패턴과 흔한 실수까지 정리합니다.
PostgreSQL 격리 수준의 실제 동작
SQL 표준은 4개 격리 수준을 정의하지만, PostgreSQL은 사실상 3개만 서로 다르게 동작합니다. READ UNCOMMITTED는 내부적으로 READ COMMITTED와 동일합니다. MVCC 구조상 Dirty Read를 애초에 허용하지 않기 때문입니다. 자세한 MVCC 스냅샷 동작은 PostgreSQL 공식 문서 13장을 참고하면 되고, 실무에서 필요한 요약은 아래 표로 충분합니다.
| 격리 수준 | Dirty Read | Non-Repeatable Read | Phantom Read | Serialization Anomaly |
|---|---|---|---|---|
| READ UNCOMMITTED | 불가 (READ COMMITTED와 동일) | 가능 | 가능 | 가능 |
| READ COMMITTED (기본값) | 불가 | 가능 | 가능 | 가능 |
| REPEATABLE READ | 불가 | 불가 | 불가 (MVCC로 차단) | 가능 |
| SERIALIZABLE | 불가 | 불가 | 불가 | 불가 |
주목할 점은 두 가지입니다. 첫째, PostgreSQL은 REPEATABLE READ에서도 Phantom Read를 차단합니다(표준보다 엄격). 그래서 REPEATABLE READ와 SERIALIZABLE의 실질적 차이는 Serialization Anomaly, 그중에서도 write skew 하나로 좁혀집니다. 둘째, SERIALIZABLE은 스냅샷 격리 위에 SSI(Serializable Snapshot Isolation)를 얹어, 실제 충돌이 감지됐을 때만 한 쪽을 SQLSTATE 40001로 실패시킵니다.
Bun.sql에서 격리 수준 지정하기
sql.begin()의 첫 인자로 BEGIN 뒤에 붙일 옵션 문자열을 그대로 전달합니다. Bun이 BEGIN [options] → 콜백 실행 → 성공 시 COMMIT / 예외 시 ROLLBACK 흐름을 처리합니다.
// READ COMMITTED (기본값, 명시 없어도 동일)
await sql.begin(async tx => { /* ... */ });
// REPEATABLE READ
await sql.begin("isolation level repeatable read", async tx => { /* ... */ });
// SERIALIZABLE
await sql.begin("isolation level serializable", async tx => { /* ... */ });
// 조합: 읽기 전용 + REPEATABLE READ
await sql.begin("isolation level repeatable read read only", async tx => { /* ... */ });문자열 방식이 처음엔 raw하게 느껴질 수 있지만, PostgreSQL BEGIN 문법과 1:1 대응된다는 장점이 있습니다. DEFERRABLE 같은 추가 옵션도 문서에 있는 그대로 붙이면 됩니다. 참고로 postgres.js도 동일한 문자열 시그니처를 씁니다 — 두 라이브러리의 격리 수준 지정 방식은 다르지 않습니다.
시나리오 1 — 재고 차감 + 주문 생성 (SERIALIZABLE + 재시도)
READ COMMITTED에서 발생하는 oversell은 서두의 sequence 다이어그램에서 이미 확인했습니다. SERIALIZABLE로 바꾸면 SSI가 이 패턴을 감지하고 뒤늦게 커밋하려는 쪽을 SQLSTATE 40001로 실패시킵니다. 애플리케이션이 이 코드를 잡아 재시도합니다.
async function placeOrder(userId: number, productId: number) {
for (let attempt = 0; attempt < 3; attempt++) {
try {
return await sql.begin("isolation level serializable", async tx => {
const [item] = await tx`
SELECT qty FROM inventory
WHERE product_id = ${productId}
`;
if (!item) throw new Error("product_not_found");
if (item.qty < 1) throw new Error("out_of_stock");
await tx`
UPDATE inventory SET qty = qty - 1
WHERE product_id = ${productId}
`;
const [order] = await tx`
INSERT INTO orders (user_id, product_id)
VALUES (${userId}, ${productId})
RETURNING *
`;
return order;
});
} catch (e: any) {
if (e.code === "40001" && attempt < 2) {
await new Promise(r => setTimeout(r, 50 * (attempt + 1)));
continue;
}
throw e;
}
}
}초안에서는 FOR UPDATE도 함께 걸었지만 이번엔 뺐습니다. SERIALIZABLE 트랜잭션에서 FOR UPDATE를 섞으면 SSI의 낙관적 동시성 이점(읽기가 서로 블로킹되지 않음)을 잃고, 잠금 순서에 따라 deadlock 가능성이 올라갑니다. 확실히 경합이 심하고 조기 실패가 유리한 hot row가 있을 때만 선택적으로 걸고, 그렇지 않다면 SSI에만 맡기는 편이 낫습니다. 재시도 횟수와 backoff는 트래픽 특성에 맞춰 조정하세요.
시나리오 2 — 월말 결산 리포트 (REPEATABLE READ + READ ONLY)
다단계 집계 사이에 다른 트랜잭션이 커밋되면 합계와 카테고리별 합이 어긋납니다. REPEATABLE READ는 트랜잭션 시작 시점의 스냅샷을 고정하므로, 몇 초에 걸친 여러 쿼리가 같은 뷰를 봅니다.
async function generateMonthlyReport(month: string) {
return await sql.begin("isolation level repeatable read read only", async tx => {
const [totals] = await tx`
SELECT SUM(amount) AS total, COUNT(*) AS count
FROM transactions
WHERE date_trunc('month', created_at) = ${month}::date
`;
const breakdown = await tx`
SELECT category, SUM(amount) AS subtotal
FROM transactions
WHERE date_trunc('month', created_at) = ${month}::date
GROUP BY category
ORDER BY subtotal DESC
`;
return { totals, breakdown };
});
}read only를 함께 붙이면 PostgreSQL이 쓰기를 하지 않을 트랜잭션임을 알고 일부 부기(bookkeeping) 오버헤드를 생략합니다. 리포트·대시보드용 조회 트랜잭션이라면 습관적으로 붙여두는 편이 좋습니다.
시나리오 3 — 배치 삽입에서 부분 실패 허용 (Savepoint)
수천 건 이벤트를 한 트랜잭션으로 넣을 때, 일부 중복 레코드 때문에 배치 전체를 롤백하고 싶지 않은 경우가 있습니다. 이때 savepoint를 씁니다.
await sql.begin(async tx => {
let inserted = 0;
let skipped = 0;
for (const record of records) {
await tx.savepoint(async sp => {
await sp`
INSERT INTO events (id, data, created_at)
VALUES (${record.id}, ${record.data}, ${record.createdAt})
`;
inserted++;
}).catch(e => {
if (e.code === "23505") {
// UniqueViolation — savepoint만 롤백, 외부 트랜잭션 유지
skipped++;
return;
}
throw e;
});
}
console.log(`삽입: ${inserted}, 중복 건너뜀: ${skipped}`);
});핵심은 .catch()에서 23505가 아닌 오류를 throw e로 재던지는 부분입니다. 예상 못한 오류가 조용히 삼켜지면 안 됩니다.
시나리오 4 — 격리 수준 + Savepoint 조합
두 도구는 다른 계층에서 동작하므로 함께 씁니다. SERIALIZABLE로 열고, 내부에 실패해도 되는 감사 로그를 savepoint로 감쌉니다.
async function createUser(email: string, ip: string, userAgent: string) {
await sql.begin("isolation level serializable", async tx => {
const [user] = await tx`
INSERT INTO users (email) VALUES (${email}) RETURNING *
`;
// 감사 로그가 실패해도 user 생성은 유지
await tx.savepoint(async sp => {
await sp`
INSERT INTO audit_log (user_id, action, metadata)
VALUES (${user.id}, 'user_created', ${{ ip, userAgent }})
`;
}).catch(e => {
console.error("감사 로그 실패, 계속 진행:", e.message);
});
await tx`
INSERT INTO welcome_emails (user_id, scheduled_at)
VALUES (${user.id}, NOW() + INTERVAL '5 minutes')
`;
});
}metadata 컬럼이 jsonb라면 객체를 그대로 template literal에 넣습니다. JSON.stringify()로 미리 문자열화하면 텍스트로 저장되어 ->>, @> 같은 JSON 연산자가 동작하지 않습니다. 격리 수준은 동시성 이상을, savepoint는 트랜잭션 내 부분 실패를 담당하므로 조합에 충돌이 없습니다.
언제 무엇을 고를지
| 구분 | isolation 옵션 | savepoint |
|---|---|---|
| 해결하는 문제 | 동시성 이상 (Non-Repeatable Read, write skew 등) | 트랜잭션 내 부분 실패 복구 |
| 주요 장점 | DB 엔진 수준 보장, 앱 코드 단순 | 배치 처리, UniqueViolation 무시 패턴 |
| 주요 단점 | 격리 수준 상승 시 40001 빈도 증가, 재시도 필수 | 동시성 이상 방지 불가, 남용 시 WAL 부하 |
| 조합 | savepoint와 자유롭게 결합 가능 | isolation 옵션과 자유롭게 결합 가능 |
흔한 실수 다섯 가지
1. READ UNCOMMITTED로 성능 이득을 기대하는 경우
PostgreSQL에서 READ UNCOMMITTED는 READ COMMITTED와 동일하게 동작합니다. 성능 차이가 발생하지 않습니다.
2. REPEATABLE READ면 모든 쓰기 충돌이 사라진다는 오해
REPEATABLE READ는 읽기 스냅샷을 고정하고, 같은 행에 대한 동시 UPDATE는 잠금 대기 또는 40001(첫 판독자가 뒤에 다른 커밋된 버전을 봤을 때)로 처리됩니다. 하지만 서로 다른 행을 참조해 발생하는 write skew(예: 두 트랜잭션이 상대방이 읽은 행을 각자 갱신) 같은 Serialization Anomaly는 REPEATABLE READ가 막지 못합니다. 여기까지 차단하려면 SERIALIZABLE이 필요합니다.
3. SERIALIZABLE을 걸고 재시도 로직을 안 짜는 경우
SERIALIZABLE을 쓴다면 SQLSTATE 40001 재시도는 선택이 아니라 필수입니다. 재시도 없이 배포하면 트래픽이 몰릴 때 그대로 5xx가 클라이언트로 나갑니다.
4. savepoint로 격리 수준을 대체하려는 시도 savepoint는 격리 수준과 무관합니다. savepoint를 아무리 잘게 나눠도 Phantom Read나 write skew를 막을 수 없습니다.
5. isolation level 문자열 오타
Bun.sql은 이 문자열을 서버에 그대로 넘기므로 오타가 나면 PostgreSQL이 문법 오류를 냅니다. 타입 안전성이 없으니 상수로 관리하는 편이 낫습니다.
export const ISOLATION = {
READ_COMMITTED: "isolation level read committed",
REPEATABLE_READ: "isolation level repeatable read",
SERIALIZABLE: "isolation level serializable",
REPEATABLE_READ_READ_ONLY: "isolation level repeatable read read only",
} as const;
await sql.begin(ISOLATION.SERIALIZABLE, async tx => { /* ... */ });정리
같은 트랜잭션 API 안에서 등장한다는 이유로 격리 수준과 savepoint를 혼동하기 쉽지만, 판단 기준은 단순합니다. 여러 트랜잭션 사이의 이상 현상을 막고 싶다면 isolation 옵션을 올리고, 한 트랜잭션 내부에서 특정 블록만 선택적으로 되돌리고 싶다면 savepoint를 씁니다. 두 문제는 한 요청에서 동시에 나타날 수 있고, 그때는 그냥 함께 쓰면 됩니다.
Bun.sql의 문자열 옵션 방식은 PostgreSQL BEGIN 문법과 그대로 대응되므로, 헷갈릴 때는 라이브러리 문서보다 PostgreSQL 트랜잭션 문서를 먼저 열어보는 편이 빠릅니다. 2026년 8월 기준으로 Bun.sql은 MySQL/MariaDB, SQLite까지 포함하는 통합 Bun.SQL API로 확장돼 있으니, 멀티 DB 환경이라면 Bun 런타임 SQL 문서의 최신 지원 매트릭스를 확인하세요.
참고 자료
- Bun SQL 공식 문서
- Bun SQL.begin 메서드 API 레퍼런스
- Bun TransactionSQL TypeScript 인터페이스
- Bun SavepointSQL TypeScript 인터페이스
- Bun 1.2 릴리스 블로그 포스트
- PostgreSQL 공식 문서 — 13.2 트랜잭션 격리
- PostgreSQL 공식 문서 — SET TRANSACTION
- PostgreSQL 공식 문서 — SAVEPOINT
- PostgreSQL 공식 문서 — 13.5 직렬화 실패 처리
- Bun.sql tracking issue (Postgres client) — GitHub