대용량 PostgreSQL 쿼리를 커서로 흘려보내기 — Bun 1.2 SQL API와 backpressure의 만남
지난 분기, 이벤트 감사 로그 조회 API 하나를 붙였습니다. 하루 수천만 건이 쌓이는 events 테이블에서 특정 기간의 payload를 뽑아 관리자 화면에 뿌리는 단순한 엔드포인트였는데, 배포 이틀 만에 컨테이너 RSS가 4GB를 넘겼고 결국 OOM으로 재시작이 걸렸습니다. 처음엔 "쿼리가 좀 느린 거겠지" 하고 넘겼다가, 로그를 다시 훑고 나서야 원인이 드라이버 레이어에 있다는 걸 알아챘습니다.
문제는 단순했습니다. SELECT id, actor_id, payload FROM events WHERE created_at > '2026-04-01'로 수십만 행을 한 번에 메모리에 올린 다음, 다 처리하고 응답을 내려보내는 패턴이었습니다. DB가 느린 게 아니라, 드라이버가 결과셋을 통째로 버퍼링하면서 힙을 잡아먹고 있었죠. payload 컬럼이 JSONB로 평균 몇 KB씩 붙어 있으니 행 수가 그리 크지 않아도 쉽게 GB 단위로 부풀었습니다.
이 글에서는 Bun 1.2에 내장된 SQL API를 출발점 삼아, 커서 기반 페이지네이션으로 인덱스 스캔을 최적화하고, backpressure 제어로 메모리를 안정적으로 유지하는 방법을 코드와 함께 살펴봅니다. Node.js에서 pg, postgres.js를 쓰던 분들도 그대로 적용할 수 있는 내용입니다.
왜 지금 이 조합인가
Bun 1.2 내장 SQL 클라이언트
Bun 1.2가 2025년 1월에 릴리스되면서 눈에 띈 변화 중 하나가 외부 패키지 없이 바로 쓰는 PostgreSQL 클라이언트였습니다. Zig로 작성된 네이티브 드라이버라 바이너리 wire protocol을 직접 다루고, 자동 prepared statement, 쿼리 파이프라이닝, 커넥션 풀링이 기본으로 들어와 있습니다.
import { SQL } from "bun";
const sql = new SQL("postgres://user:pass@localhost/db");
const rows = await sql`SELECT id, name FROM users WHERE active = ${true}`;태그드 템플릿 리터럴은 파라미터 값에 한해 SQL 인젝션을 자동으로 방지합니다. 다만 sql`SELECT ${userColumnName} FROM ...` 처럼 컬럼명·테이블명 같은 쿼리 구조를 동적으로 조합하는 경우엔 여전히 취약하므로 화이트리스트 검증이 별도로 필요합니다. 이후 버전에서는 MySQL·MariaDB·SQLite까지 포괄하는 단일 인터페이스로 확장됐다고 알려져 있는데, 정확한 버전과 지원 범위는 Bun 공식 SQL 문서에서 확인하는 것을 권장합니다.
그래서 Bun이 Node.js보다 얼마나 빠른가
솔직히 이 질문부터 답하고 넘어가는 게 맞을 것 같습니다. 결론부터 말하면, DB가 병목인 구간에서 런타임 교체 효과는 제한적입니다. 대부분의 시간이 네트워크 라운드트립과 DB 실행에 쓰이기 때문에, HTTP 서버 벤치마크에서 보이는 큰 격차가 그대로 재현되지 않습니다. 벤치마크마다 워크로드·하드웨어에 따라 편차가 크고, 신뢰할 만한 단일 수치를 인용하기가 어렵습니다.
그러니 이 글의 핵심은 Bun 예찬이 아니라, 어떤 런타임을 써도 적용할 수 있는 스트리밍 + 커서 패턴에 있습니다. 런타임을 바꾸기 전에 쿼리 전략부터 바꿔보자는 이야기입니다.
오프셋 페이지네이션의 문제
LIMIT 100 OFFSET 10000이 왜 느린지 이해하려면 PostgreSQL이 실제로 무엇을 하는지 보면 됩니다. 오프셋 방식은 처음 10,100개 행을 스캔한 뒤 앞의 10,000개를 버립니다. 페이지가 뒤로 갈수록 버리는 양이 늘어나는 구조입니다.
Sequin의 키셋 커서 vs 오프셋 비교 글을 보면, 테이블 크기가 커지고 페이지가 뒤로 갈수록 오프셋 방식은 스캔량이 선형으로 증가하는 반면 커서 방식은 인덱스 탐색 한 번으로 시작점을 잡기 때문에 일정 시간에 수렴합니다. 구체 수치는 스키마·인덱스·하드웨어에 따라 크게 달라지므로 원문의 벤치 조건을 그대로 참고하시고, 여기서는 테이블 크기가 클수록 격차가 지수적으로 벌어진다는 경향만 기억하면 됩니다.
커서 기반 페이지네이션의 핵심 쿼리
SELECT id, created_at, payload
FROM events
WHERE (created_at, id) > ($1, $2)
ORDER BY created_at ASC, id ASC
LIMIT 500;(created_at, id) 복합 조건이 포인트입니다. 단일 컬럼만 쓰면 타임스탬프가 같은 행이 여러 개일 때 누락이나 중복이 생길 수 있어서, 고유한 id를 두 번째 정렬 키로 함께 씁니다. 이 패턴을 쓰려면 정렬 키와 일치하는 복합 인덱스가 반드시 있어야 합니다.
CREATE INDEX idx_events_created_at_id ON events(created_at ASC, id ASC);스트리밍과 backpressure
데이터를 "스트리밍"한다는 건 결과를 한 번에 다 가져오지 않고 조각 단위로 처리한다는 뜻입니다. 여기서 자주 놓치는 개념이 backpressure입니다.
생산자(DB)가 소비자(애플리케이션)보다 빠르면 버퍼가 쌓입니다. backpressure는 소비자의 처리 속도에 맞춰 생산자의 전송 속도를 조절하는 메커니즘입니다. Node.js 스트림에서 이게 제대로 작동하지 않으면 조용히 메모리를 먹어치우는 상황이 됩니다.
아래 시퀀스는 서버 사이드 커서(DECLARE CURSOR + FETCH) 방식의 흐름입니다. 뒤에서 소개할 async generator 방식은 FETCH 대신 커서 조건이 붙은 SELECT를 반복한다는 점에서 근본 메커니즘이 다르니 구분해 두시길 바랍니다.
for await...of 비동기 이터레이터가 이 흐름을 자연스럽게 표현합니다. await processBatch(rows)가 끝나기 전까지 다음 요청이 나가지 않으니까요.
실제 코드로 살펴보기
1. Bun.SQL + 제너레이터로 키셋 페이지네이션
이 방식은 서버 사이드 커서를 열지 않고, 커서 조건이 붙은 SELECT를 반복 호출하는 클라이언트 측 루프입니다. 트랜잭션을 잡지 않고 커넥션을 점유하지 않는다는 게 장점입니다.
import { SQL } from "bun";
const sql = new SQL("postgres://user:pass@localhost/db");
async function* fetchInCursor(lastId = 0, lastCreatedAt = new Date(0)) {
while (true) {
const rows = await sql`
SELECT id, created_at, payload
FROM events
WHERE (created_at, id) > (${lastCreatedAt}, ${lastId})
ORDER BY created_at ASC, id ASC
LIMIT 500
`;
if (rows.length === 0) break;
yield rows;
lastCreatedAt = rows.at(-1).created_at;
lastId = rows.at(-1).id;
}
}
for await (const batch of fetchInCursor()) {
await processBatch(batch);
}주의할 점 하나. 위 코드는 rows.at(-1).created_at을 다음 쿼리의 파라미터로 다시 넘기는데, 이때 timestamptz 컬럼이 JavaScript Date로 매핑되는지 문자열로 오는지는 드라이버 구현에 따라 다릅니다. 2026년 상반기 기준 Bun.SQL은 timestamptz를 Date 객체로 반환하도록 문서화되어 있지만, 로컬 스키마에서 실제로 어떻게 매핑되는지는 한 번 로그를 찍어 확인하고 넘어가는 편이 안전합니다. 문자열로 오는 경우엔 new Date(...)로 감싸거나 명시적 캐스팅($1::timestamptz)을 걸어야 예기치 않은 timezone 이슈를 피할 수 있습니다.
processBatch가 비동기 작업(파일 쓰기, 외부 API 호출 등)을 포함해도 backpressure는 자동으로 걸립니다. await가 흐름을 막아주니까요.
2. postgres.js 커서 스트리밍 (Bun 호환)
porsager/postgres는 Bun에서도 동작하며, 서버 사이드 커서를 통한 스트리밍을 공식 지원합니다.
import postgres from "postgres";
const sql = postgres("postgres://user:pass@localhost/db");
async function streamLargeTable() {
const cursor = sql`SELECT * FROM large_table ORDER BY id ASC`.cursor(100);
for await (const rows of cursor) {
await processBatch(rows);
}
}내부적으로 DECLARE CURSOR + FETCH N을 사용합니다. 전체 결과셋을 드라이버 버퍼에 올리지 않아서 테이블이 수천만 행이어도 메모리 사용량이 청크 크기에 비례합니다.
Node.js 진영에서 같은 목적을 달성하는 또 다른 옵션이 pg-query-stream입니다. pg 위에서 Readable 스트림을 노출하며, 서버 사이드 커서로 결과를 흘려보냅니다. 아래 결정 플로우차트에 등장하니 이름만 기억해 두시면 됩니다.
3. PostgreSQL 서버 사이드 커서 직접 다루기
가장 세밀한 제어가 필요할 때는 SQL 레벨에서 직접 다룰 수 있습니다. 이 예제만 sql을 파라미터로 받는 이유는 명확합니다. DECLARE CURSOR는 트랜잭션 안에서 동일 커넥션을 유지해야 하기 때문에, 호출자가 트랜잭션 스코프로 전용 커넥션을 확보해서 넘겨야 하는 상황을 나타냈습니다. 일반 쿼리처럼 모듈 스코프 풀에서 매번 새 커넥션을 잡으면 커서가 닫혀버립니다.
async function fetchWithServerCursor(tx) {
await tx`
DECLARE log_cursor CURSOR FOR
SELECT id, ts, message FROM logs
WHERE ts > '2026-01-01'
ORDER BY ts ASC, id ASC
`;
while (true) {
const rows = await tx`FETCH 1000 FROM log_cursor`;
if (rows.length === 0) break;
await processBatch(rows);
}
await tx`CLOSE log_cursor`;
}
await sql.begin(async (tx) => {
await fetchWithServerCursor(tx);
});sql.begin(...)(또는 사용하는 드라이버의 트랜잭션 헬퍼)이 예외 시 자동 롤백을 담당하니, 함수 내부에서 다시 try/catch를 감쌀 필요는 없습니다. 이 방식은 메모리를 가장 효율적으로 쓸 수 있지만, 커서가 열려있는 동안 커넥션 하나를 점유한다는 걸 기억해야 합니다.
4. HTTP 스트리밍 응답과 결합
대용량 CSV 내보내기 같은 케이스에는 DB 스트림을 HTTP 응답에 직접 연결할 수 있습니다. 아래는 개념 예시이며, 실제 프로덕션에서 쓰려면 CSV 이스케이프 처리를 반드시 넣어야 합니다.
function toCsvCell(value) {
if (value === null || value === undefined) return "";
const s = typeof value === "string" ? value : JSON.stringify(value);
if (/[",\n\r]/.test(s)) {
return `"${s.replace(/"/g, '""')}"`;
}
return s;
}
export default {
port: 3000,
async fetch(req) {
const stream = new ReadableStream({
async start(controller) {
try {
controller.enqueue("id,created_at,payload\n");
for await (const batch of fetchInCursor()) {
for (const row of batch) {
const line = [
toCsvCell(row.id),
toCsvCell(row.created_at.toISOString()),
toCsvCell(row.payload),
].join(",") + "\n";
controller.enqueue(line);
}
}
controller.close();
} catch (err) {
controller.error(err);
}
},
});
return new Response(stream, {
headers: {
"Content-Type": "text/csv",
"Transfer-Encoding": "chunked",
},
});
},
};두 가지가 초안 대비 핵심 변경입니다. 첫째, toCsvCell로 쉼표·줄바꿈·큰따옴표를 이스케이프합니다. 그렇지 않으면 payload에 쉼표 한 개만 들어와도 CSV가 깨집니다. 둘째, start 내부를 try/catch로 감싸 controller.error(err)를 명시적으로 호출합니다. 그렇지 않으면 이미 200 OK 헤더가 나간 뒤 스트림만 조용히 끊겨서 클라이언트가 성공한 CSV라고 오인하는, 무음 데이터 소실이 발생합니다.
Bun의 Response는 ReadableStream을 직접 받아서 클라이언트로 청크를 흘려보냅니다. 서버 메모리에는 청크 크기만큼만 상주합니다.
어떤 방식을 언제 쓸까
pg-query-stream은 pg 생태계를 이미 쓰고 있는 팀이 최소 변경으로 스트리밍을 도입할 때 자연스러운 선택입니다. 신규 코드라면 API가 더 깔끔한 postgres.js의 .cursor()가 무난합니다.
트레이드오프
솔직히 이 패턴에도 주의할 점이 있습니다.
| 방식 | 장점 | 주의할 점 |
|---|---|---|
| 키셋(커서) 페이지네이션 | 테이블 크기에 덜 민감한 조회 성능, 동시 쓰기 중 중복·누락 없음 | 임의 페이지 접근 불가, 복합 인덱스 필수, 정렬 컬럼이 API 스펙에 노출됨 |
| 서버 사이드 커서 | 전체 결과를 메모리에 올리지 않아 OOM 방지 | 커서가 열린 동안 커넥션 1개 점유 — 풀 크기가 20이면 동시 대형 쿼리 상한도 20 |
| async generator backpressure | 코드가 직관적, 소비 속도에 자동 맞춤 | 단일 실행 흐름 — 병렬 처리가 필요하면 별도 워커 큐 필요 |
Bun 내장 SQL 클라이언트 (SQL) |
추가 의존성 없음, 파이프라이닝으로 고동시성 처리 우수 | 2026년 상반기 기준 네이티브 .stream() 미구현(Issue #25307), COPY 프로토콜·LISTEN/NOTIFY 등 일부 기능은 지원 범위 확인 필요 |
자주 하는 실수들
청크 크기 튜닝을 감으로: 최적 배치 크기는 행 평균 크기(수백 바이트 vs 수십 KB), 네트워크 레이턴시, 처리 복잡도에 따라 달라집니다. 특정 수치를 미리 정해두기보다, 프로파일링해서 처리량-메모리 곡선의 무릎을 찾는 편이 확실합니다. 너무 작으면 라운드트립이 병목이 되고, 너무 크면 청크 하나가 메모리 스파이크를 일으키니 그 사이를 좁혀가는 감각으로 접근하세요.
트랜잭션 없이 서버 사이드 커서: DECLARE CURSOR는 기본적으로 트랜잭션 안에서만 살아있습니다(WITH HOLD 옵션 예외). BEGIN ... COMMIT 블록 밖에서 쓰면 커서가 즉시 닫힙니다.
await 빠뜨리기: for await (const batch of cursor) { processBatch(batch) } — await를 빼면 backpressure가 사라지고 배치가 병렬로 쌓입니다. 스트림이 조용히 메모리를 먹는 전형적인 패턴입니다.
커서 컬럼에 인덱스 빠뜨리기: (created_at, id) 복합 인덱스가 없으면 키셋 쿼리가 오히려 오프셋보다 느려질 수 있습니다. EXPLAIN으로 인덱스 스캔인지 반드시 확인하세요.
언제 이 패턴이 의미 있고, 어디까지 파볼까
이 패턴은 만능이 아닙니다. 결과셋이 수백~수천 행 수준이면 오프셋 + LIMIT 하나로 충분하고, 키셋 인덱스 설계와 커서 프로토콜 관리 비용이 오히려 오버엔지니어링이 됩니다. 대략적인 신호는 이렇습니다.
- 응답이 수만 행을 넘어가거나 payload 컬럼이 KB 단위인 경우
- 백오피스·감사 로그처럼 페이지가 뒤로 깊게 들어가는 UX가 있는 경우
- 데이터 익스포트, 야간 배치, 마이그레이션처럼 결과가 처음부터 대용량인 경우
이 신호 중 하나라도 걸린다면 커서 + backpressure 조합을 검토할 가치가 있습니다. 반대로 첫 페이지 몇 개만 보여주고 끝나는 목록 UI라면 굳이 도입할 이유가 없습니다.
다음으로 파볼 만한 것들도 짚어두겠습니다. 첫째, COPY (SELECT ...) TO STDOUT을 활용하면 CSV/바이너리 스트리밍이 커서보다도 빠릅니다 — 텍스트 익스포트 용도라면 반드시 벤치해볼 값어치가 있습니다. 둘째, 결과가 반복적으로 쓰이는 워크로드라면 커서 대신 논리적 복제(LISTEN/NOTIFY, pgoutput)로 이벤트 스트림을 소비하는 게 근본적 해법입니다. 셋째, Bun 내장 SQL의 네이티브 스트리밍 API가 Issue #25307에서 논의 중이니, 진척 상황을 팔로우해두면 언젠가 postgres.js 의존성을 걷어낼 수 있습니다.
당장의 처방을 요약하자면, 일반 쿼리에는 Bun 내장 SQL을, 스트리밍이 필요한 대형 쿼리에는 postgres.js의 .cursor()를 혼용하는 게 2026년 상반기 기준 가장 현실적인 조합입니다. 런타임을 갈아엎기 전에 쿼리 형태를 바꿔보세요 — 대부분의 OOM은 거기서 이미 사라집니다.
참고 자료
- Bun 1.2 공식 릴리스 노트
- Bun SQL 공식 문서
- Bun SQL native iterator/stream — GitHub Issue #25307
- porsager/postgres — GitHub
- pg-query-stream — node-postgres
- Keyset Cursors, Not Offsets, for Postgres Pagination — Sequin
- DECLARE CURSOR로 메모리 소비 줄이기 — Cybertec PostgreSQL
- Your Node.js Streams Aren't Backpressuring — Frontend Masters
- Processing 1 Million SQL Rows to CSV using Node.js Streams — DEV Community