Bun.SQL과 Hono streamSSE로 대용량 쿼리 결과를 청크 스트림으로 내보내기
수십만 행짜리 리포트 쿼리를 그대로 배열에 담아 반환하면 서버 메모리는 폭발하고, 클라이언트는 몇 분 동안 빈 화면을 응시하게 됩니다. 운영 지표 대시보드를 만들 때 이 문제를 만나 300MB짜리 JSON을 힙에 올리는 걸 목격한 뒤부터는 접근 방식을 바꿔야 했습니다. 전체 결과를 다 모으지 말고, 청크 단위로 쪼개서 나오는 즉시 클라이언트에 밀어내는 방식입니다.
이 글은 Bun 런타임에 내장된 Bun.SQL과 Hono의 streamSSE 헬퍼로 그 파이프라인을 조립하는 방법을 다룹니다. SSE를 선택한 근거, LIMIT/OFFSET의 함정과 keyset 대안, 프록시 타임아웃·onAbort 정리·커넥션 풀 같은 운영 고려사항을 함께 살펴봅니다.
왜 SSE인가 — WebSocket과의 선택 지점
SSE(Server-Sent Events)는 text/event-stream MIME 타입으로 서버가 클라이언트에 이벤트를 계속 흘려보내는 단방향 프로토콜입니다. "대용량 쿼리 결과를 서버가 클라이언트로 밀어내는" 시나리오는 정확히 SSE가 맞는 형태입니다.
| 항목 | SSE | WebSocket |
|---|---|---|
| 프로토콜 업그레이드 | 없음 (일반 HTTP 응답) | 필요 (Upgrade: websocket) |
| 방향 | 단방향 (서버→클라이언트) | 양방향 |
| 자동 재연결 | 브라우저 EventSource 내장 |
직접 구현 필요 |
Last-Event-ID 재개 |
스펙 내장 | 애플리케이션 레벨 설계 |
| 구현 복잡도 | 낮음 | 상대적으로 높음 |
과거 HTTP/1.1 환경에서는 브라우저의 도메인당 동시 연결 수 제한(대체로 6개) 때문에 SSE 연결이 다른 요청을 막는 문제가 자주 지적됐습니다. HTTP/2 이후로는 단일 연결에서의 스트림 다중화 덕분에 이 제약이 완화되었습니다. 다만 이 이점을 실제로 얻으려면 오리진과 리버스 프록시가 HTTP/2로 서빙되도록 설정되어 있어야 합니다. HTTP/1.1로만 서빙 중이라면 여전히 동시 SSE 세션 수를 신중히 관리해야 합니다.
OpenAI, Anthropic의 스트리밍 응답이 SSE를 기본 전송 형식으로 사용하면서 프론트엔드 라이브러리·프록시·모니터링 도구에서 SSE 지원이 부쩍 성숙해진 점도 이 조합을 고르는 실용적 이유가 됩니다.
Bun.SQL: 런타임에 내장된 SQL 클라이언트
Bun.SQL은 별도의 npm 드라이버 없이 import { sql } from "bun" 한 줄로 시작할 수 있는 SQL 클라이언트입니다. 2025년 1월의 Bun 1.2 릴리스에서 PostgreSQL 지원이 처음 탑재됐고(Bun v1.2 릴리스 노트), 이후 1.2.x 릴리스에서 MySQL/MariaDB와 SQLite 지원이 확장됐습니다(Bun v1.2.21 릴리스 노트). 2026년 7월 현재 사용 중인 정확한 버전과 지원 데이터베이스는 공식 문서에서 확인하는 편이 안전합니다.
import { sql } from "bun";
const rows = await sql`SELECT id, name FROM users WHERE active = ${true}`;Zig으로 구현된 네이티브 드라이버 덕분에 성능 우위가 자주 언급되지만, 공식 문서의 벤치마크는 특정 쿼리·데이터셋 조건에서 측정한 값이므로 자신의 워크로드에서 직접 측정해보는 것을 권합니다.
2026년 7월 시점에도 sql\...`.stream()형태의 네이티브 행 단위 async iterator API는 아직 공식 릴리스되지 않았습니다([GitHub Issue #25307](https://github.com/oven-sh/bun/issues/25307)). 이 글의 기본 예제는 그 우회 경로인LIMIT/OFFSET` 배치 쿼리를 사용하고, 뒤에서 그 한계와 keyset 대안을 다룹니다.
데이터 흐름 한눈에 보기
sequenceDiagram
participant 클라이언트
participant 서버 (Hono + Bun.SQL)
participant DB
클라이언트->>서버 (Hono + Bun.SQL): GET /stream/reports
서버 (Hono + Bun.SQL)-->>클라이언트: 200 text/event-stream 헤더
Note over 클라이언트,서버 (Hono + Bun.SQL): 연결 유지, 이후 이벤트 스트림
loop 청크 반복
서버 (Hono + Bun.SQL)->>DB: WHERE id > last_id LIMIT N
DB-->>서버 (Hono + Bun.SQL): 행 N개
서버 (Hono + Bun.SQL)-->>클라이언트: event: chunk / data: JSON
end
서버 (Hono + Bun.SQL)-->>클라이언트: event: doneBun.SQL은 별도 프로세스가 아니라 Hono 서버 프로세스 안에서 동작하는 라이브러리라서 한 박스로 묶었습니다. 클라이언트가 첫 청크를 받는 순간부터 UI 렌더링이나 후속 처리를 시작할 수 있다는 점이 이 구조의 핵심입니다.
기본 구현
Hono의 streamSSE는 공식 스트리밍 헬퍼에 포함되어 있고, 임포트 경로는 hono/streaming입니다.
import { Hono } from "hono";
import { streamSSE } from "hono/streaming";
import { sql } from "bun";
const app = new Hono();
const CHUNK_SIZE = 50;
app.get("/stream/reports", (c) => {
return streamSSE(c, async (stream) => {
let lastId = 0;
stream.onAbort(() => {
console.log("클라이언트 연결 끊김");
});
while (!stream.aborted) {
const rows = await sql`
SELECT id, name, created_at
FROM large_table
WHERE id > ${lastId}
ORDER BY id
LIMIT ${CHUNK_SIZE}
`;
if (rows.length === 0) break;
await stream.writeSSE({
event: "chunk",
data: JSON.stringify(rows),
id: String(rows[rows.length - 1].id),
});
lastId = rows[rows.length - 1].id;
if (rows.length < CHUNK_SIZE) break;
}
if (!stream.aborted) {
await stream.writeSSE({ event: "done", data: "" });
}
});
});
export default app;몇 가지 짚어둘 지점이 있습니다.
ORDER BY id는 선택이 아니라 필수입니다. 정렬 순서가 없는 페이지네이션은 페이지 사이에서 행이 중복되거나 누락될 수 있고, 옵티마이저가 실행마다 다른 순서로 반환할 자유가 있습니다.id에는 인덱스가 있어야 페이지 진입 비용이 상수에 가깝게 유지됩니다.stream.aborted확인 후writeSSE호출을 해야 합니다.while (!stream.aborted)로 루프를 빠져나온 이유가 "abort" 때문일 수 있으므로, 이미 끊긴 스트림에done이벤트를 쓰면 예외가 발생합니다.- 이벤트
id에 마지막 행의 PK를 실어두면EventSource가 재연결할 때Last-Event-ID헤더로 그 값을 다시 보내주고, 서버는 그 값을lastId로 삼아 중단 지점부터 재개할 수 있습니다.
LIMIT OFFSET 대신 keyset을 쓰는 이유
초안에서 익숙한 LIMIT/OFFSET을 쓰지 않은 이유가 여기 있습니다. OFFSET N은 DB가 앞의 N개 행을 실제로 읽어서 버려야 하는 연산이라, 오프셋이 커질수록 페이지 한 장을 가져오는 비용이 선형으로 증가합니다. 수십만 행짜리 스트리밍의 후반부에서는 첫 페이지보다 마지막 페이지가 수십~수백 배 느려질 수 있습니다.
Keyset 페이지네이션(WHERE id > $last_id ORDER BY id LIMIT N)은 인덱스 상에서 시작 위치를 바로 찾아 필요한 만큼만 읽어오므로 페이지 크기가 커져도 페이지당 비용이 일정합니다. 대신 정렬 키가 인덱스에 있고 유일해야 하며, 임의 페이지로 점프하는 UX에는 부적합합니다. SSE 스트리밍처럼 처음부터 끝까지 순차적으로 훑는 워크로드와는 잘 맞습니다.
청크 루프의 제어 흐름
클라이언트 측
const source = new EventSource("/stream/reports");
const allRows = [];
source.addEventListener("chunk", (e) => {
const rows = JSON.parse(e.data);
allRows.push(...rows);
renderRows(rows);
});
source.addEventListener("done", () => {
source.close();
console.log(`총 ${allRows.length}행 수신 완료`);
});
source.onerror = () => {
console.warn("SSE 에러, 브라우저가 자동 재연결 시도");
};EventSource는 연결이 끊기면 자동으로 재연결하고, 이벤트 id가 지정되어 있었다면 Last-Event-ID 헤더에 마지막 값을 실어 보냅니다. 서버에서 이 헤더를 읽어 lastId로 사용하면 중단 지점부터 재개하는 스트리밍이 됩니다.
사용자가 스트리밍을 중단하고 싶을 때는 source.close() 한 번으로 충분합니다. 서버 쪽에서는 즉시 stream.onAbort가 트리거되어 후속 쿼리·인터벌을 정리할 수 있습니다.
실무에서 밟기 쉬운 지뢰
1. 프록시 유휴 타임아웃
리버스 프록시는 데이터가 흐르지 않는 유휴 연결을 일정 시간 뒤에 끊습니다. nginx의 proxy_read_timeout 기본값은 60초, AWS ALB의 idle timeout 기본값도 60초입니다. Cloudflare는 플랜·제품(Workers, Proxy 등)과 사용자 설정에 따라 값이 다르니, 실제 배포 환경의 값을 확인해두는 편이 안전합니다.
첫 쿼리 처리 시간이 길거나 청크 간 간격이 벌어질 수 있는 워크로드라면 heartbeat 이벤트를 주기적으로 보내 유휴 상태를 피합니다.
app.get("/stream/reports", (c) => {
return streamSSE(c, async (stream) => {
const heartbeat = setInterval(async () => {
if (stream.aborted) return;
try {
await stream.writeSSE({ event: "heartbeat", data: "" });
} catch {
clearInterval(heartbeat);
}
}, 15_000);
stream.onAbort(() => clearInterval(heartbeat));
try {
// ...청크 스트리밍 로직...
} finally {
clearInterval(heartbeat);
}
});
});setInterval 콜백 안의 await가 던지는 예외는 그대로 두면 unhandled promise rejection이 됩니다. abort 여부 확인과 실제 쓰기 사이의 짧은 race도 있을 수 있으니 try/catch로 감싸고, 실패하면 인터벌 자체를 정리해 소음을 없앱니다.
간격은 프록시 타임아웃보다 넉넉히 짧게 잡습니다. 60초 타임아웃이 흔하므로 15초 안팎이 무난한 시작점입니다.
2. onAbort 정리를 빠뜨리면 조용히 새는 자원
stream.onAbort 콜백에서 인터벌, DB 커서, 이벤트 리스너, 진행 중인 배치 쿼리를 모두 정리해야 합니다. 클라이언트가 탭을 닫아도 서버 루프가 stream.aborted를 체크하지 않으면 다음 반복 전까지는 쿼리를 계속 날립니다. 루프의 매 반복 진입 직전과 긴 작업 뒤에 stream.aborted를 확인하는 습관이 필요합니다.
3. 커넥션 풀 고갈
SSE 세션 하나는 짧게는 수 초, 길게는 수 분간 살아있고 그동안 주기적으로 쿼리를 던집니다. LIMIT + keyset 패턴은 청크 사이에 커넥션을 풀에 반환하므로 서버 사이드 커서 방식보다 풀 압력이 낮습니다. 그래도 동시 세션 수가 풀 한도에 근접하면 신규 요청이 대기하거나 실패하니, 풀 사이즈·평균 세션 길이·평균 청크 간격을 근거로 여유를 계산해두는 편이 좋습니다.
4. 청크 크기 튜닝
이 글의 예제 코드는 CHUNK_SIZE = 50을 사용합니다. 이 값 자체가 정답은 아니고, 행 크기·네트워크 왕복 시간·클라이언트 렌더링 비용에 따라 부하 테스트로 조정해야 하는 튜닝 포인트입니다. 청크가 너무 작으면 왕복 오버헤드가 지배적이 되고, 너무 크면 첫 렌더 지연과 메모리 이점 감소가 함께 옵니다.
트레이드오프 정리
| 항목 | 청크 스트리밍 | 전체 결과 한 번에 반환 |
|---|---|---|
| 메모리 사용 | 청크 크기에 비례 (일정) | 결과셋 크기에 비례 |
| 첫 데이터 도달 시간 | 첫 청크 즉시 | 전체 쿼리 완료 후 |
| 구현 복잡도 | 높음 (heartbeat, onAbort, 재개) | 낮음 |
| DB 커넥션 점유 시간 | 세션 지속 시간 동안 반복 점유 | 쿼리 완료 시 즉시 반환 |
| 클라이언트 취소 | source.close() 즉시 반영 |
별도 처리 필요 없음 |
| 재연결 후 재개 | Last-Event-ID로 lastId 복원 가능 |
처음부터 재시작 |
이 패턴이 잘 맞는 상황은 이런 경우입니다.
- 결과셋이 수만 행 이상이거나 크기를 예측하기 어려울 때
- 리포트·대시보드처럼 점진적 렌더링이 UX에 이득일 때
- 전체 응답 완료까지 수십 초 이상 걸리는 장기 실행 쿼리
반대로, 결과가 작고 응답 크기를 예측할 수 있으며 클라이언트가 어차피 전부 모아서 한 번에 계산해야 한다면 스트리밍의 복잡도가 이득을 초과합니다.
SSE는 서버→클라이언트 방향만 지원한다는 점도 기억해둘 만합니다. 스트리밍 진행 중에 필터를 바꾸거나 특정 쿼리 ID만 취소하는 등 세밀한 상향 제어가 필요하다면 별도의 짧은 HTTP 엔드포인트를 곁들여 설계해야 합니다.
앞으로 바뀔 가능성이 있는 부분
Bun.SQL에 네이티브 async iterator 스트리밍 API가 추가되면(Issue #25307) 이 글의 keyset 배치 루프는 훨씬 짧아질 수 있습니다. 개념적으로는 이런 형태가 됩니다.
// 개념적 예시 — 2026년 7월 현재 공식 릴리스되지 않은 API 형태 가정
for await (const rows of sql`SELECT * FROM large_table ORDER BY id`.stream(50)) {
if (stream.aborted) break;
await stream.writeSSE({ event: "chunk", data: JSON.stringify(rows) });
}Bun 환경에서 지금 당장 서버 사이드 커서로 비슷한 형태를 쓰고 싶다면 postgres.js가 선택지입니다. .cursor(N)이 반환하는 async iterable을 for await...of로 소비하다가 break하면 JS 스펙에 따라 async iterator의 return()이 호출되어 커서가 정리됩니다. 별도의 close() 호출을 코드에 넣지 않아도 됩니다.
import postgres from "postgres";
import { streamSSE } from "hono/streaming";
const pg = postgres(process.env.DATABASE_URL!);
app.get("/stream/reports", (c) => {
return streamSSE(c, async (stream) => {
const cursor = pg`SELECT id, name FROM large_table ORDER BY id`.cursor(50);
for await (const rows of cursor) {
if (stream.aborted) break;
await stream.writeSSE({
event: "chunk",
data: JSON.stringify(rows),
id: String(rows[rows.length - 1].id),
});
}
if (!stream.aborted) {
await stream.writeSSE({ event: "done", data: "" });
}
});
});커서 방식은 매 청크마다 새 쿼리를 준비하지 않아 계획 수립 비용을 아낄 수 있는 대신, 커서가 살아있는 동안 트랜잭션·커넥션이 계속 점유된다는 점을 인지하고 동시 세션 수를 관리해야 합니다.
정리
Bun.SQL과 Hono streamSSE는 보일러플레이트를 최소화하면서 청크 스트리밍 파이프라인을 만들 수 있는 조합입니다. 다만 "연결이 유지되면 알아서 잘 되겠지"라는 관점으로 접근하면 프록시 타임아웃, stream.aborted 이후의 쓰기 시도, OFFSET의 성능 저하, onAbort 누락 같은 지점에서 조용히 무너집니다.
정리하자면 이 정도가 프로덕션 체크리스트입니다.
ORDER BY인덱스 컬럼 + keyset 페이지네이션으로 페이지당 비용을 상수화stream.aborted체크를 루프 진입 직전과 쓰기 직전 양쪽에서 수행- heartbeat는
try/catch+onAbort에서 인터벌 정리 - 이벤트
id에 마지막 PK를 실어Last-Event-ID기반 재개 경로 확보 - 동시 세션 수 × 청크당 쿼리 빈도로 커넥션 풀 여유 계산
그다음은 자신의 실제 워크로드로 청크 크기와 heartbeat 간격을 조정하는 일이 남습니다.