GitHub Pages에서 sql.js-httpvfs로 SQLite 쿼리
Source: Dev.to
번역하려는 전체 텍스트를 제공해 주시면, 요청하신 대로 마크다운 형식과 코드 블록, URL은 그대로 유지하면서 한국어로 번역해 드리겠습니다.
문제
한 번은 670 MB SQLite 데이터베이스를 가지고 있었고, 간단한 요구 사항이 있었습니다: 정적 사이트에 올려서 사용자가 키워드로 검색할 수 있게 하는 것.
- 백엔드를 원하지 않았습니다 – 전체 프로젝트가 정적이기 때문입니다.
- 원시 DB를 업로드하고 브라우저가 다운로드하도록 하면 사용자는 670 MB 전송을 기다려야 합니다.
Source: …
솔루션 – sql.js-httpvfs
sql.js-httpvfs는 sql.js를 기반으로 HTTP Range‑Request‑ 기반 가상 파일 시스템을 추가하여 브라우저가 실제 쿼리에 필요한 SQLite 페이지만 가져오도록 합니다.
그 670 MB 데이터베이스도? 간단한 키 조회는 대략 1 KB 정도만 전송됩니다.
SQLite 페이지가 작동하는 방식
- SQLite는 고정 크기 페이지(기본 4096 바이트)에 데이터를 저장합니다.
- 모든 B‑Tree 노드, 인덱스 엔트리, 행은 특정 페이지 번호에 매핑됩니다.
- 인덱스가 적용된 쿼리는 B‑Tree 경로에 해당하는 페이지만 읽으며, 전체 테이블을 스캔하지 않습니다.
sql.js-httpvfs가 이를 활용하는 방법
Emscripten VFS 레이어를 대체합니다. 메모리의 ArrayBuffer에서 읽는 대신 HTTP Range Request를 보냅니다:
GET /data.sqlite HTTP/1.1
Range: bytes=4096-8191
서버는 해당 4096 바이트만 반환하고, 이는 SQLite 엔진에 전달됩니다.
모든 작업은 Web Worker 내부에서 실행되므로 메인 스레드는 응답성을 유지하고, 각 쿼리는 비동기로 처리됩니다.
프리페칭
라이브러리는 세 개의 가상 읽기 헤드를 구현하여 접근 패턴을 추적합니다.
읽기 헤드가 순차적인 페이지 접근을 감지하면 자동으로 프리페칭을 확대합니다—한 번에 한 페이지에서 여러 페이지로 요청을 늘립니다. 이는 많은 트리 노드를 순차적으로 탐색하는 전체 텍스트 검색에 특히 중요합니다.
인덱스는 필수
- Good – 인덱스를 사용함(쿼리를 커버하고 데이터 행을 읽지 않음).
- Bad – 전체 테이블 스캔(전체 테이블을 다운로드).
-- Good: uses index
EXPLAIN QUERY PLAN
SELECT name, price FROM products WHERE sku = 'ABC123';
-- Output: SEARCH products USING INDEX idx_sku (sku=?)
-- Bad: full table scan
EXPLAIN QUERY PLAN
SELECT * FROM products WHERE description LIKE '%keyword%';
-- Output: SCAN products
Installation
npm install sql.js-httpvfs
sql.js-httpvfs는 두 개의 추가 정적 자산이 필요합니다: sql-wasm.wasm 및 Worker JS 파일. 두 파일 모두 패키지에 포함되어 있으므로, 이를 공개 디렉터리로 복사하기만 하면 됩니다.
# With Vite (or any static‑file server)
cp node_modules/sql.js-httpvfs/dist/sql-wasm.wasm public/
cp node_modules/sql.js-httpvfs/dist/sqlite.worker.js public/
워커 초기화
import { createDbWorker } from 'sql.js-httpvfs';
// URLs must point to the static files you just copied
const workerUrl = new URL('/sqlite.worker.js', import.meta.url);
const wasmUrl = new URL('/sql-wasm.wasm', import.meta.url);
const worker = await createDbWorker(
[
{
from: 'url', // load DB from a URL (there's also an inline mode)
config: {
serverMode: 'full', // single‑file mode
url: '/data.sqlite', // path to the database
requestChunkSize: 4096, // Range Request size, aligned to SQLite page size
},
},
],
workerUrl.toString(),
wasmUrl.toString()
);
requestChunkSize는 4096을 기본값으로 하며, 이는 SQLite의 기본 페이지 크기와 일치합니다. 다른 페이지 크기를 사용하는 경우 이 값을 그에 맞게 조정하십시오.
SQLite 파일 최적화
데이터베이스의 페이지 크기는 전송 효율에 직접적인 영향을 미칩니다. 파일을 업로드하기 전에 다음 단계를 수행하십시오:
-- Smaller page size → finer‑grained Range Requests
PRAGMA page_size = 1024; -- must be set before any tables are created
-- Remove the WAL file (otherwise you’d need to keep two files in sync)
PRAGMA journal_mode = delete;
-- Rebuild the DB to apply the new page size and remove fragmentation
VACUUM;
인덱스 설계
쿼리 패턴을 고려하고 가능한 경우 커버링 인덱스를 사용하세요.
-- Example: common query is
-- WHERE category = ? ORDER BY created_at DESC LIMIT 20
-- A covering index includes all columns needed by SELECT,
-- so the query never touches the data rows.
CREATE INDEX idx_category_date_cover
ON articles(category, created_at DESC, title, slug);
FTS5를 이용한 전체 텍스트 검색
CREATE VIRTUAL TABLE articles_fts USING fts5(
title,
content,
content='articles', -- reference the source table to avoid duplicate storage
content_rowid='id'
);
-- Populate the FTS index on initial data load
INSERT INTO articles_fts(articles_fts) VALUES('rebuild');
데이터베이스 쿼리
워커가 설정되면, 쿼리는 일반 sql.js와 비슷하게 보이지만 모든 결과가 Promise를 반환합니다.
표준 쿼리
const results = await worker.db.query(
`SELECT title, slug, created_at
FROM articles
WHERE category = ?
ORDER BY created_at DESC
LIMIT 20`,
['frontend']
);
// results → { columns: string[], values: any[][] }
console.log(results.columns); // ['title', 'slug', 'created_at']
console.log(results.values); // [['Article title', 'slug-here', '2024-01-01'], ...]
전체 텍스트 검색
const ftsResults = await worker.db.query(
`SELECT a.title,
a.slug,
snippet(articles_fts, 1, '', '', '...', 20) AS excerpt
FROM articles_fts
JOIN articles a ON articles_fts.rowid = a.id
WHERE articles_fts MATCH ?
ORDER BY rank
LIMIT 10`,
[keyword]
);
전송 크기 측정
제가 가장 좋아하는 기능 중 하나: 각 쿼리가 정확히 몇 바이트를 가져오는지 확인할 수 있습니다.
// 쿼리 전 통계 기록
const bytesBefore = worker.getStats().totalFetchedBytes;
await worker.db.query('SELECT * FROM articles WHERE id = ?', [42]);
// 쿼리 후 비교
const bytesAfter = worker.getStats().totalFetchedBytes;
console.log(`Query transferred: ${bytesAfter - bytesBefore} bytes`);
getStats()는 다음과 같은 속성을 가진 객체를 반환합니다:
totalFetchedBytes– 지금까지 누적된 전송 바이트 수.totalRequests– 지금까지 수행된 HTTP Range 요청의 누적 횟수.
TL;DR
sql.js-httpvfs은 정적 사이트가 전체 파일을 다운로드하지 않고도 대용량 SQLite DB를 조회할 수 있게 합니다.- 정확히 필요한 페이지에 대해 HTTP Range Requests 를 발행함으로써 동작합니다.
- 인덱스(특히 covering 인덱스)는 전송량을 최소화하는 데 필수적입니다.
- 라이브러리는 Web Worker와 WASM 빌드를 함께 제공하므로 두 개의 정적 자산을 복사하고 워커를 초기화한 뒤
sql.js와 동일하게 쿼리하면 됩니다.
정적‑사이트 검색을 즐기세요!
브라우저용 청크된 SQLite 파일
개발 중에는 인덱스가 실제로 작동하는지 확인하기 위해 화면에 요청 수를 표시합니다.
대용량 데이터베이스의 경우 파일을 고정 크기의 청크로 나눌 수 있으며, 이렇게 하면 CDN 캐싱이 훨씬 효율적입니다.
시스템 split 명령으로 분할 (Linux/macOS)
split -b 10m data.sqlite data.sqlite.
# Produces: data.sqlite.aa, data.sqlite.ab, …
또는 sql.js‑httpvfs에 번들된 도구 사용
npx sql.js-httpvfs-tools split data.sqlite --chunk-size 10485760
# Produces split files and a JSON manifest describing the chunks
sql.js-httpvfs를 청크 모드로 설정
{
from: 'url',
config: {
serverMode: 'chunked',
serverChunkSize: 10 * 1024 * 1024, // 10 MB per chunk
urlPrefix: '/db/data.sqlite.', // prefix shared by all chunk files
urlSuffix: '',
fromCache: false,
requestChunkSize: 4096,
},
}
- 데이터베이스와 정적 자산을 저장소에 넣고(또는 Git LFS 사용) 푸시합니다.
GitHub의 정적 파일 서버는 Range Requests를 기본적으로 지원하므로 별도 설정이 필요 없습니다. - S3, Cloudflare Pages, Netlify도 Range Requests를 지원하므로 이들 중 어느 것이든 바로 사용할 수 있습니다.
CORS 고려 사항
프런트엔드와 데이터베이스가 다른 오리진에 있는 경우, 서버는 다음 헤더를 반환해야 합니다:
Access-Control-Allow-Origin: *
Access-Control-Allow-Headers: Range
Access-Control-Expose-Headers: Content-Range, Accept-Ranges
이 접근 방식을 채택하기 전에 알아야 할 점
- 읽기 전용 – HTTP Range Requests는 읽기 작업만 수행합니다; 쓰기는 백엔드가 필요합니다.
브라우저에서 읽기/쓰기를 원한다면 공식 SQLite Wasm with OPFS 또는 wa‑sqlite를 참고하세요. - 캐시 소거 없음 – 세션 중에 다운로드된 페이지는 Worker 메모리에 캐시되며, 이 캐시는 절대 축소되지 않습니다. 쿼리를 많이 수행하면 메모리 사용량이 증가할 수 있습니다.
- 실험적 – 저자는 README에서 이를 데모 수준 코드라고 설명하고 있으며, 고안정성 프로덕션에서는 권장되지 않습니다.
추가 읽을거리
- sql.js 시작하기 – 브라우저에서 SQLite (기초)
- sql.js와 IndexedDB를 이용한 오프라인 웹 앱 – 오프라인 쓰기를 위한 완전 구현
- 브라우저 스토리지 솔루션 비교 – 다양한 브라우저 스토리지 옵션에 대한 포괄적인 비교