브라우저 탭 하나로 Parquet을 SQL로 뒤집기: DuckDB WASM을 실제 프로젝트에 붙여본 기록
처음 DuckDB WASM을 접했을 때 솔직히 반신반의했습니다. "브라우저에서 SQL 분석이 된다고? 그게 실제로 쓸 만한 수준이야?" 그런데 몇백만 행짜리 Parquet을 로컬 탭에서 GROUP BY로 돌려보고 나서 관점이 조금 바뀌었습니다. 벤치마크 수치는 하드웨어와 데이터 분포에 따라 크게 달라지지만(참고 사례), 체감적으로 "장난감이 아니다"라는 느낌은 분명했습니다.
이 글은 데이터 파이프라인이나 분석 도구를 다루는 풀스택·백엔드 개발자를 위해 씁니다. 서버 없이 브라우저에서 OLAP 쿼리를 돌리는 게 어떤 의미인지, 실제로 어떻게 붙이는지, 그리고 어디서 벽에 부딪히는지를 정리했습니다.
왜 지금 이 조합이 흥미로운가
프론트엔드에서 "데이터 분석" 기능을 만들면 보통 이런 구조가 됩니다.
클라이언트가 요청을 보내고, 서버가 DB에 질의하고, 결과가 돌아옵니다. 상시 클러스터를 두는 아키텍처라면 트래픽에 비례해 비용이 오르고, Athena나 BigQuery 같은 서버리스 엔진은 스캔 데이터량 기준으로 청구됩니다. 아키텍처마다 비용 곡선이 다르지만, 어느 쪽이든 "브라우저에서 완결되면 사라지는 비용"이 있다는 점은 공통입니다.
DuckDB WASM은 이 흐름 자체를 뒤집습니다. 분석 엔진 전체를 .wasm 바이너리로 컴파일해서 브라우저 탭에 올려버립니다. SQL 실행이 클라이언트 머신 위에서 완결되니, 로컬 파일을 다룰 때는 분석 API로의 왕복이 아예 없어집니다.
WASM 위의 DuckDB, 어떻게 동작하는가
몇 가지 포인트가 있습니다.
- Web Worker 위에서 실행: 쿼리가 메인 스레드를 블로킹하지 않아서 UI가 얼지 않습니다.
- Apache Arrow 데이터 교환: Worker와 메인 스레드 사이에 컬럼 데이터를 Arrow 포맷으로 주고받습니다.
SharedArrayBuffer가 활성화된 환경(COOP/COEP 헤더 설정)에서는 공유 메모리로 복사 없이 접근할 수 있지만, 그렇지 않은 표준 환경에서는 structured clone이 발생합니다. "직렬화 비용이 매우 낮다"는 것과 "복사가 전혀 없다"는 것은 구분해서 이해할 필요가 있습니다. - OPFS 영속 캐시: Origin Private File System을 이용하면 페이지를 새로 고쳐도 데이터가 남아 있습니다. 실제 저장 한도는 브라우저·운영체제·기기 잔여 용량에 따라 달라지므로, "대용량 파일도 세션 간 유지 가능하다" 정도로 이해하는 게 안전합니다.
실전: 실제로 붙여보는 코드
설치와 초기화
npm install @duckdb/duckdb-wasm apache-arrow@duckdb/duckdb-wasm은 번들 타입을 여러 개 제공합니다. selectBundle()이 현재 환경에 맞는 번들을 골라줍니다.
import * as duckdb from '@duckdb/duckdb-wasm';
async function initDuckDB() {
const JSDELIVR_BUNDLES = duckdb.getJsDelivrBundles();
const bundle = await duckdb.selectBundle(JSDELIVR_BUNDLES);
const worker_url = URL.createObjectURL(
new Blob([`importScripts("${bundle.mainWorker!}");`], {
type: 'text/javascript',
})
);
const worker = new Worker(worker_url);
const logger = new duckdb.ConsoleLogger();
const db = new duckdb.AsyncDuckDB(logger, worker);
await db.instantiate(bundle.mainModule, bundle.pthreadWorker);
URL.revokeObjectURL(worker_url);
return { db, worker };
}Worker 참조를 함께 반환하는 이유는 뒤에서 정리 로직에 쓰기 위해서입니다.
로컬 파일 드롭 → SQL 쿼리
사용자가 CSV나 Parquet 파일을 드래그하면 서버 업로드 없이 바로 쿼리를 돌립니다. 이때 파일명이 SQL에 직접 삽입되지 않도록 주의해야 합니다.
function escapeSqlLiteral(value: string): string {
return value.replace(/'/g, "''");
}
async function queryDroppedFile(db: duckdb.AsyncDuckDB, file: File) {
const conn = await db.connect();
const virtualName = `dropped_${Date.now()}.parquet`;
await db.registerFileHandle(
virtualName,
file,
duckdb.DuckDBDataProtocol.BROWSER_FILEREADER,
true
);
const safeName = escapeSqlLiteral(virtualName);
const table = await conn.query(`
SELECT
category,
COUNT(*) AS cnt,
AVG(amount) AS avg_amount
FROM read_parquet('${safeName}')
GROUP BY category
ORDER BY cnt DESC
LIMIT 20
`);
await conn.close();
return table;
}원본 파일명 대신 내부에서 생성한 가상 파일명을 등록하고, 이스케이핑까지 겹으로 걸어두면 사용자 파일명에 작은따옴표가 섞여 있어도 안전합니다. 프로덕션에서는 등록 이름을 완전히 통제하는 방식이 가장 확실합니다.
결과를 일반 객체 배열로 바꾸고 싶을 때는 다음처럼 하면 됩니다. 다만 수십만 행을 매번 이렇게 변환하면 느려지니, 시각화 라이브러리가 Arrow를 직접 받는다면 그쪽이 훨씬 유리합니다.
const rows = table.toArray().map(row => row.toJSON());원격 Parquet 쿼리 (httpfs)
S3나 CDN에 올린 Parquet을 직접 쿼리할 수 있습니다. 정적 호스팅 대시보드의 핵심 패턴입니다.
주의할 점은 WASM 빌드의 익스텐션 관리 방식이 네이티브와 다르다는 것입니다. 네이티브에서 익숙한 INSTALL httpfs;는 WASM에서 일반적으로 no-op이거나 오류가 됩니다. httpfs는 대체로 사전 번들되어 있어서 LOAD만으로 충분한 경우가 많고, 최신 빌드에서는 LOAD조차 필요 없는 경우도 있습니다. 실행 환경에서 한 번 확인하고 붙이는 게 안전합니다(공식 문서 참고).
async function queryRemoteParquet(conn: duckdb.AsyncDuckDBConnection) {
try {
await conn.query(`LOAD httpfs;`);
} catch {
// 번들에 이미 포함되어 별도 LOAD가 필요 없는 경우가 있습니다.
}
const table = await conn.query(`
SELECT
year,
origin,
dest,
COUNT(*) AS flight_count,
AVG(dep_delay) AS avg_delay
FROM read_parquet('https://your-cdn.example.com/flights-2024.parquet')
WHERE dep_delay > 0
GROUP BY year, origin, dest
ORDER BY avg_delay DESC
`);
return table;
}read_parquet()에 HTTP URL을 넣으면 DuckDB가 Parquet row group 단위로 필요한 부분만 range request로 fetch합니다. 이 경로는 분석 API 서버 왕복은 없지만, 데이터 자체를 가져오는 HTTP 트래픽은 여전히 발생한다는 점을 짚어두는 게 정확합니다.
React 컴포넌트에서 연결하기
DB 인스턴스는 앱 전체에서 하나만 공유하는 게 원칙입니다. Context나 singleton으로 관리합니다.
// useDuckDB.ts
import { useEffect, useRef, useState } from 'react';
import * as duckdb from '@duckdb/duckdb-wasm';
type Handle = { db: duckdb.AsyncDuckDB; worker: Worker };
export function useDuckDB() {
const handleRef = useRef<Handle | null>(null);
const [ready, setReady] = useState(false);
useEffect(() => {
let cancelled = false;
initDuckDB().then(handle => {
if (cancelled) {
handle.db.terminate?.();
handle.worker.terminate();
return;
}
handleRef.current = handle;
setReady(true);
});
return () => {
cancelled = true;
const handle = handleRef.current;
if (handle) {
handle.db.terminate?.();
handle.worker.terminate();
}
};
}, []);
return { db: handleRef.current?.db ?? null, ready };
}AsyncDuckDB.terminate()는 버전에 따라 존재 여부와 동작이 다를 수 있어서, optional chaining으로 호출한 뒤 Worker 자체도 명시적으로 종료합니다. Worker 정리 누락은 탭이 살아 있는 동안 리소스 누수로 이어지기 때문에 이중으로 걸어두는 게 안전합니다.
// AnalyticsDashboard.tsx
function AnalyticsDashboard() {
const { db, ready } = useDuckDB();
const [rows, setRows] = useState<Record<string, unknown>[]>([]);
const runQuery = async (file: File) => {
if (!db) return;
const table = await queryDroppedFile(db, file);
setRows(table.toArray().map(r => r.toJSON()));
};
if (!ready) return <div>DuckDB 로딩 중...</div>;
return (
<div>
<FileDropzone onFileDrop={runQuery} />
<DataTable rows={rows} />
</div>
);
}Arrow Table을 받는 쪽 변수명을 table로 두고, JSON으로 변환한 뒤에야 rows로 부르는 편이 코드를 그대로 참고하는 독자에게 덜 헷갈립니다.
OPFS로 세션 간 캐시 유지
파일이 크면 매번 새로 로드하는 게 부담스럽습니다. OPFS를 쓰면 한 번 올린 파일이 다음 세션에도 남아 있습니다.
async function registerWithOPFS(db: duckdb.AsyncDuckDB, file: File) {
const opfsRoot = await navigator.storage.getDirectory();
const fileHandle = await opfsRoot.getFileHandle(file.name, { create: true });
const writable = await fileHandle.createWritable();
await writable.write(file);
await writable.close();
await db.registerFileHandle(
file.name,
fileHandle,
duckdb.DuckDBDataProtocol.BROWSER_FSACCESS,
true
);
}용량 한도는 브라우저마다 다릅니다. Chromium 계열은 상대적으로 관대한 편이지만, Safari는 더 제한적이므로 사전에 navigator.storage.estimate()로 확인해두는 편이 낫습니다.
언제 쓰고, 언제 넘어가야 할까
솔직히 모든 상황에 맞는 솔루션은 아닙니다. 판단이 필요한 지점을 흐름으로 정리하면 이렇습니다.
특성을 표로 정리하면 다음과 같습니다.
| 항목 | 내용 |
|---|---|
| 레이턴시 | 분석 API 왕복 없음. 원격 Parquet 쿼리 시 데이터 fetch 트래픽은 발생 |
| 비용 구조 | 분석용 상시 클러스터 불필요, 서버리스 스캔 과금도 감소 여지 |
| 프라이버시 | 로컬 파일 케이스에서 데이터가 네트워크로 전송되지 않음 |
| 배포 | 정적 호스팅(예: GitHub Pages, S3)만으로 분석 앱 배포 가능 |
| 포맷 | Parquet, CSV, JSON, Apache Arrow 네이티브 지원 |
| 메모리 한계 | WebAssembly 최대 4GB 제한, 실제로는 브라우저별로 더 엄격 |
| 멀티스레딩 | 기본 단일 스레드. SharedArrayBuffer 기반 멀티스레딩은 COOP/COEP 헤더 필요 |
| 영속성 | 기본 세션 종료 시 초기화. OPFS나 IndexedDB 연동 필요 |
| 초기 로딩 | WASM 모듈 다운로드·초기화 시간 필요(세션당 1회) |
| 적합 규모 | 소~중규모 데이터셋. 수 TB 이상은 서버 처리와 병행 권장 |
흔히 밟는 지뢰
DB 인스턴스를 컴포넌트마다 생성하는 것: WASM 모듈 초기화 비용이 만만치 않습니다. 앞의 useDuckDB 훅처럼 앱 전체에서 하나만 유지해야 합니다.
결과를 무조건 JSON으로 펼치는 것: toArray().map(r => r.toJSON())은 편하지만, 수십만 행을 이 방식으로 매번 변환하면 눈에 띄게 느려집니다. Arrow를 직접 소비할 수 있는 시각화 라이브러리를 쓸 수 있다면 그 경로가 낫습니다.
Worker 종료를 누락하는 것: DB 객체만 정리하고 Worker를 남겨두면 탭이 열려 있는 동안 리소스가 계속 물려 있습니다. 훅에서 Worker와 DB를 함께 정리해야 합니다.
파일명을 SQL에 그대로 넣는 것: 위 queryDroppedFile 예시처럼 등록 이름을 내부에서 통제하거나, 최소한 작은따옴표 이스케이핑을 걸어야 합니다.
2026년 기준으로 뭐가 달라졌나
DuckDB WASM 초기(2021년)와 지금은 생태계가 꽤 다릅니다.
2025년 11월 발표 기준으로 duckdb-iceberg 익스텐션이 INSERT, UPDATE, DELETE, MERGE INTO, ALTER TABLE을 지원하게 됐습니다. 다만 이 발표는 네이티브 DuckDB 기준이며, WASM 빌드에서의 익스텐션 지원 범위와 시점은 별개입니다. WASM은 익스텐션 로딩 방식과 사용 가능한 기능 집합이 네이티브와 다르기 때문에, 브라우저에서 Iceberg 쓰기를 시도할 계획이라면 실제 사용하는 @duckdb/duckdb-wasm 버전에서 어떤 익스텐션이 번들되었는지, 쓰기 경로가 활성화되어 있는지를 반드시 검증해야 합니다.
브라우저에서 Iceberg REST Catalog와 통신하며 레이크하우스 워크플로를 구성하려는 움직임(관련 정리)이 확산되고는 있지만, 이 조합이 "production 안정"이라고 단정하기에는 아직 팀·환경별로 편차가 큽니다. PoC 단계에서 CORS, 인증 토큰 취급, 쓰기 트랜잭션 원자성 같은 요소를 하나씩 확인하는 게 정직한 접근입니다.
어디에 붙여보면 좋을까
드래그앤드롭 로컬 분석 도구: 사용자가 파일을 브라우저에 드롭하면 SQL로 즉시 탐색. 데이터가 네트워크로 나가지 않아서 민감 데이터를 다루는 팀에서 특히 유용합니다.
정적 호스팅 인터랙티브 대시보드: Evidence나 Observable Framework 같은 도구가 이 방식을 활용합니다. 정적 파일 호스팅만으로 인터랙티브 차트를 제공할 수 있고, 별도 분석 API 서버가 없습니다.
React SPA 내 제로 ops 분석: GROUP BY, 집계, 조인 쿼리를 클라이언트 단에서 처리. 분석 전용 백엔드를 따로 운영하지 않아도 됩니다.
SQL 교육·데모 환경: Python이나 DB 설치 없이 웹 페이지 하나로 실습 환경을 제공할 수 있습니다. DuckDB 공식이 공개한 비행 기록 데이터셋 데모(공식 블로그 소개)가 대표 사례입니다.
정리하며
DuckDB WASM을 붙일지 말지 결정하는 기준은 대체로 세 가지로 좁혀집니다. 데이터가 한 사용자의 세션에서 완결되는가, 규모가 브라우저 메모리 안에 들어오는가, 그리고 팀이 익스텐션·헤더·Worker 라이프사이클 같은 브라우저 특유의 제약을 감당할 수 있는가. 이 셋에 모두 "그렇다"라고 답할 수 있다면 분석 백엔드 한 층을 통째로 걷어낼 수 있는 선택지가 되고, 하나라도 걸린다면 서버 사이드 처리와 병행하거나 그쪽에 계속 의존하는 편이 낫습니다.
브라우저에서 완결되는 분석이라는 발상 자체가 아직 익숙하지 않은 분들이 많습니다. 붙일지 말지 결정하기 전에, 실제로 쓰는 데이터 한 조각을 드롭해서 GROUP BY 하나를 돌려보는 것만으로도 판단에 필요한 감각의 절반은 얻을 수 있습니다.
참고 자료
- DuckDB Wasm Client 공식 문서
- DuckDB-Wasm GitHub 리포지토리
- MotherDuck: DuckDB Wasm — Analytical SQL Database in Your Browser
- DuckDB 공식 블로그: DuckDB-Wasm: Efficient Analytical SQL in the Browser
- InfoQ: DuckDB Iceberg Browser S3 (2026.01)
- DuckDB 공식 블로그: Writes in DuckDB-Iceberg (2025.11)
- @duckdb/duckdb-wasm npm 페이지
- LakeClient Blog: DuckDB WASM 관련 브라우저 SQL 분석 정리
- Medium: Iceberg in the Browser — Zero-Server Analytics with DuckDB WASM
- Medium: My browser WASM't prepared — DuckDB, Apache Arrow, Web Workers in real life
- Awesome DuckDB