데이터 웨어하우스 없이 분석 쿼리를 Node.js 서비스 안으로 — DuckDB + S3 Parquet + MotherDuck
전용 데이터 웨어하우스가 정당화되는 순간이 언제인지 생각해본 적 있으신가요? 동시 대시보드 사용자가 수천 명이거나 집계 대상 데이터가 페타바이트 단위라면 Snowflake·BigQuery 같은 전용 플랫폼이 맞습니다. 그런데 그 전 단계, 즉 수십~수백 GB 규모의 이벤트 로그를 가진 SaaS 백엔드라면 이야기가 달라집니다.
이 구간에서 전용 웨어하우스를 24시간 운영하면 쿼리가 없을 때도 컴퓨팅 비용이 발생합니다. 반면 DuckDB를 Node.js 프로세스에 직접 임베딩하면 쿼리가 없을 때 추가 비용은 없습니다. 분석 쿼리가 하루에 수십~수백 번 수준이라면 인프라 비용 구조가 완전히 달라집니다.
DuckDB는 SQLite처럼 프로세스 안에 임베딩되지만 집계·스캔에 특화된 OLAP 엔진입니다. PostgreSQL이 CRUD를 담당하는 동안, 무거운 집계는 S3에 올려둔 Parquet 파일을 DuckDB가 직접 읽어 처리합니다. 별도 ETL 파이프라인 없이 쿼리 시점에 필요한 파티션만 읽어오는 온-디맨드 집계가 이 패턴의 핵심입니다.
이 글은 @duckdb/node-api로 Fastify 서버에 DuckDB를 붙이는 것부터, S3 Parquet 직접 쿼리, Lambda 패키징, MotherDuck 서버리스 배포, 동시성 제한까지 각 시나리오를 실제 코드와 함께 다룹니다.
핵심 개념
DuckDB가 OLAP에 적합한 이유 — SQLite와의 차이
DuckDB는 "분석용 SQLite"라는 별명대로 둘 다 인-프로세스 임베딩 방식이며 별도 서버 데몬이 없습니다. 차이는 내부 스토리지 레이아웃에서 시작됩니다.
| 특성 | SQLite | DuckDB |
|---|---|---|
| 스토리지 방향 | 행 지향 (row-oriented) | 열 지향 (columnar) |
| 최적화 대상 | 단건 읽기·쓰기 (OLTP) | 대규모 스캔·집계 (OLAP) |
| 벡터화 실행 | 없음 | 있음 (SIMD 활용) |
| Parquet 지원 | 없음 | 네이티브 |
| 집계 성능 | PostgreSQL과 비슷 | 집계·스캔에서 현저히 빠름 |
열 지향 스토리지의 핵심 이점은 SUM(revenue) 같은 쿼리를 실행할 때 revenue 컬럼만 읽으면 되기 때문에, 수백만 행이 있어도 I/O를 최소화할 수 있다는 점입니다. 여기에 벡터화 실행 엔진이 SIMD 명령어로 한 번에 여러 값을 처리합니다. 이 두 가지 조합이 집계·스캔 쿼리에서 행 지향 RDBMS 대비 체감 차이를 만듭니다.
하이브리드 아키텍처 — OLTP와 OLAP를 한 서비스에서
PostgreSQL의 write-path를 그대로 유지하면서, 이벤트 로그나 집계 대상 데이터만 S3 Parquet으로 내보내는 구조입니다.
두 엔진이 같은 앱 프로세스 안에 공존하며, CRUD 쿼리는 PostgreSQL이, 집계 쿼리는 DuckDB + S3 Parquet이 담당합니다. 별도 인프라를 추가하지 않고 역할만 분리하는 것이 이 패턴의 구조입니다.
@duckdb/node-api — 어떤 패키지를 선택해야 하는가
npm에 duckdb와 @duckdb/node-api 두 패키지가 존재합니다.
duckdb(레거시): 콜백 기반 C 바인딩. 신규 버전 대응이 중단될 예정이며 공식 deprecated 예정.@duckdb/node-api(공식 권장): Promise 네이티브, 더 안정적인 비동기 지원.
버전 기준으로 지원이 갑자기 전환되는 것이 아니라, @duckdb/node-api가 공식 권장 패키지로 교체된 것입니다. 새 프로젝트라면 이쪽을 선택하는 것이 맞습니다.
실전 구현
시나리오 1: Fastify에 DuckDB 싱글턴으로 붙이기
pnpm add @duckdb/node-api fastifyDuckDB 인스턴스를 서버 시작 시 한 번 초기화하고 재사용하는 패턴이 권장됩니다. 매 요청마다 인스턴스를 새로 만들면 httpfs 설치와 초기화 비용으로 수백ms 오버헤드가 발생합니다.
// src/analytics/duckdb.ts
import { DuckDBInstance } from '@duckdb/node-api';
let instance: DuckDBInstance | null = null;
export async function getDuckDB(): Promise<DuckDBInstance> {
if (instance) return instance;
instance = await DuckDBInstance.create(':memory:');
const conn = await instance.connect();
await conn.run(`INSTALL httpfs; LOAD httpfs;`);
// Secret은 인스턴스 수준으로 저장 — 이 연결을 닫은 후에도 유지됨
await conn.run(`
CREATE SECRET IF NOT EXISTS s3_cred (
TYPE s3,
KEY_ID '${process.env.AWS_ACCESS_KEY_ID}',
SECRET '${process.env.AWS_SECRET_ACCESS_KEY}',
REGION '${process.env.AWS_REGION ?? 'ap-northeast-2'}'
);
`);
// threads는 인스턴스 전체에 영향을 주는 전역 설정 — 여기서 한 번만 지정
await conn.run(`SET threads = 4;`);
// 반복 쿼리의 S3 LIST 비용을 줄이는 메타데이터 캐시
await conn.run(`SET enable_object_cache = true;`);
conn.close();
return instance;
}SET threads, SET enable_object_cache, CREATE SECRET는 모두 인스턴스 수준 설정입니다. 초기화에 사용한 conn을 닫아도 설정은 instance가 살아있는 동안 유지됩니다.
라우트 핸들러에서는 요청마다 연결을 열고 반드시 닫습니다.
// src/routes/analytics.ts
import { FastifyInstance } from 'fastify';
import { getDuckDB } from '../analytics/duckdb.js';
export async function analyticsRoutes(app: FastifyInstance) {
app.get('/api/analytics/revenue-by-region', async (req, reply) => {
const db = await getDuckDB();
const conn = await db.connect();
try {
const result = await conn.runAndReadAll(`
SELECT
region,
date_trunc('day', ts) AS day,
SUM(revenue) AS total_revenue,
COUNT(*) AS order_count
FROM read_parquet(
's3://my-bucket/events/year=*/month=*/*.parquet',
hive_partitioning = true
)
WHERE year >= 2025
GROUP BY region, day
ORDER BY day DESC, total_revenue DESC
`);
return result.getRowObjects();
} finally {
conn.close();
}
});
}conn.close()를 finally 블록에 두는 것이 중요합니다. 연결을 명시적으로 닫지 않으면 메모리가 누수됩니다.
시나리오 2: S3 Parquet 직접 쿼리 — Hive 파티셔닝으로 비용 줄이기
이벤트 데이터가 S3에 아래처럼 파티셔닝되어 있다고 가정합니다.
s3://my-bucket/events/
├── year=2025/
│ ├── month=11/
│ │ └── data.parquet
│ └── month=12/
│ └── data.parquet
└── year=2026/
└── month=01/
└── data.parquethive_partitioning=true 옵션을 켜면 WHERE year=2026 AND month=01 조건이 있을 때 DuckDB는 2025년 파티션 파일을 아예 읽지 않습니다. S3 GET 요청 수가 극적으로 줄어드는 효과입니다.
const result = await conn.runAndReadAll(`
SELECT
event_type,
COUNT(*) AS cnt,
AVG(duration_ms) AS avg_duration
FROM read_parquet(
's3://my-bucket/events/**/*.parquet',
hive_partitioning = true,
filename = true
)
WHERE year = 2026 AND month = 1
GROUP BY event_type
ORDER BY cnt DESC
`);S3 Parquet 성능 최적화 체크리스트
| 항목 | 권장 설정 | 이유 |
|---|---|---|
| 파일 크기 | 128~512MB | S3 요청 수 최소화 |
| 파티셔닝 | Hive 스타일 (year=/month=/day=) |
자동 파티션 프루닝 |
| 메타데이터 캐시 | SET enable_object_cache=true (초기화 시 설정) |
반복 쿼리 S3 LIST 비용 절감 |
| 쿼리 조건 | 파티션 키 명시 (WHERE year=2026) |
프루닝이 적용되도록 |
| 행 그룹 크기 | 일정하게 유지 | 컬럼 수준 Predicate Pushdown 효율화 |
시나리오 3: AWS Lambda에 DuckDB 패키징하기
DuckDB 바이너리는 약 40~50MB입니다. Lambda 패키지 사이즈 제한(250MB 압축 해제)을 고려하면 Lambda Layer로 분리하는 것이 권장됩니다.
// handler.ts
import { DuckDBInstance } from '@duckdb/node-api';
const DB_PATH = '/tmp/analytics.db';
export const handler = async (event: {
startDate: string;
endDate: string;
}) => {
const db = await DuckDBInstance.create(DB_PATH);
const conn = await db.connect();
await conn.run(`INSTALL httpfs; LOAD httpfs;`);
await conn.run(`
CREATE SECRET IF NOT EXISTS s3_cred (
TYPE s3,
PROVIDER credential_chain
);
`);
const result = await conn.runAndReadAll(
`SELECT date_trunc('week', ts) AS week, SUM(revenue) AS total
FROM read_parquet('s3://my-bucket/events/**/*.parquet', hive_partitioning=true)
WHERE ts BETWEEN $1 AND $2
GROUP BY 1 ORDER BY 1`,
[event.startDate, event.endDate]
);
conn.close();
return result.getRowObjects();
};날짜 값은 문자열 보간 대신 $1, $2 플레이스홀더와 배열로 바인딩합니다. 외부 이벤트 객체의 값을 SQL에 직접 보간하면 SQL 인젝션에 노출됩니다. PROVIDER credential_chain을 사용하면 Lambda IAM Role에서 자격증명을 자동으로 가져오므로 AWS 키를 코드에 직접 넣지 않아도 됩니다.
시나리오 4: MotherDuck으로 서버리스 배포
DuckDB 바이너리를 직접 관리하기 어려운 환경이라면 MotherDuck을 고려할 수 있습니다. MotherDuck은 PostgreSQL Wire Protocol을 지원하므로 pg 패키지로 바로 연결됩니다.
// MotherDuck — pg 패키지로 연결 (DuckDB 바이너리 불필요)
import { Pool } from 'pg';
const pool = new Pool({
connectionString: `postgresql://${process.env.MOTHERDUCK_TOKEN}@motherduck.com/my_database`,
});
export async function queryRevenue(startDate: string) {
const { rows } = await pool.query(
`SELECT region, SUM(revenue) AS total
FROM events
WHERE ts >= $1
GROUP BY region
ORDER BY total DESC`,
[startDate]
);
return rows;
}md: 프리픽스는 DuckDB 네이티브 클라이언트 연결 문자열입니다. pg 패키지(PostgreSQL Wire Protocol)를 사용할 때는 postgresql:// 형태를 따릅니다. 두 형식을 혼용하면 연결에 실패합니다.
Vercel Edge Functions나 Cloudflare Workers처럼 Node.js 네이티브 바이너리를 실행할 수 없는 환경에서 DuckDB 분석 쿼리를 사용하는 유일한 방법이기도 합니다.
시나리오 5: 동시성 제한 — 쿼리 큐 도입
DuckDB는 기본적으로 머신의 모든 CPU 코어를 사용합니다. 동시 요청이 여러 개 들어오면 각 쿼리가 전 코어를 경쟁해 서버 전체가 느려질 수 있습니다.
두 가지 설정을 함께 사용해야 합니다.
① 인스턴스 수준에서 스레드 수 제한 — 시나리오 1의 getDuckDB() 초기화 시 이미 SET threads = 4로 설정했습니다. threads는 연결이 아닌 인스턴스 전체에 영향을 미치는 전역 설정이므로, 각 요청마다 재설정하는 것은 중복이며 오해를 유발합니다. 초기화 시점에 한 번만 지정하는 것이 올바른 패턴입니다.
② 쿼리 큐로 동시 실행 수 제한:
// src/analytics/queue.ts
import PQueue from 'p-queue';
// threads=4, concurrency=2 → 최대 8 스레드 동시 사용 (코어 수에 맞춰 조정)
const analyticsQueue = new PQueue({ concurrency: 2 });
export async function runAnalyticsQuery<T>(
fn: () => Promise<T>
): Promise<T> {
return analyticsQueue.add(fn) as Promise<T>;
}// 사용 예시
const result = await runAnalyticsQuery(async () => {
const conn = await db.connect();
try {
const rows = await conn.runAndReadAll(query);
return rows.getRowObjects();
} finally {
conn.close();
}
});총 동시 스레드 수는 threads × concurrency로 추산합니다. 8코어 서버라면 threads=4, concurrency=2로 시작해 모니터링하면서 조정하는 것이 현실적입니다. pnpm add p-queue로 설치할 수 있습니다.
이 패턴의 한계와 실무 주의점
장점
| 항목 | 내용 |
|---|---|
| 설치 간소화 | pnpm add @duckdb/node-api, 별도 서버 불필요 |
| 집계 성능 | 열 지향 스토리지 + 벡터화 실행으로 집계·스캔에 최적화 |
| 포맷 지원 | Parquet·CSV·JSON·Arrow·Iceberg·Delta Lake |
| 표준 SQL | 윈도우 함수·CTE·LATERAL JOIN 완전 지원 |
| 운영 부담 | 웨어하우스 클러스터 관리 불필요 |
단점 및 주의점
| 항목 | 주의사항 |
|---|---|
| 동시성 | 기본 설정이 전 코어 사용 — 초기화 시 SET threads=N + 큐잉 필수 |
| 수평 확장 | 단일 프로세스 한계 — 페타바이트급·수천 동시 접속은 적합하지 않음 |
| OLTP 쓰기 | 단건 INSERT/UPDATE 비효율 — 트랜잭션 처리는 PostgreSQL 유지 |
| 인증·권한 | 내장 유저 시스템 없음 — Row-Level Security는 앱 레이어에서 직접 구현 필요 |
| 실시간 스트림 | Kafka·Flink 수준 불가 — 마이크로배치까지는 가능 |
| 파티션 수 | 수백 개 이상이면 S3 LIST 비용 모니터링 필요 |
가장 흔한 실수 두 가지:
-
매 요청마다 DuckDB 인스턴스 생성 — httpfs 설치와 Secret 생성, 인스턴스 초기화에 수백ms가 걸립니다. 서버 시작 시 싱글턴으로 만들고 재사용하는 것이 맞습니다.
-
conn.close()누락 — 연결을 명시적으로 닫지 않으면 메모리가 누수됩니다.try/finally패턴으로 항상 닫아줘야 합니다.
어떤 상황에서 무엇을 선택하는가
마치며
DuckDB는 "분석 쿼리가 필요하지만 전용 웨어하우스를 운영할 여유가 없는" 팀에게 현실적인 선택지입니다. PostgreSQL의 OLTP 역할을 건드리지 않으면서 S3에 쌓인 Parquet 파일을 쿼리 시점에 직접 읽어 집계하는 패턴은, 소규모 SaaS부터 수백 GB 데이터까지 별도 인프라 없이 커버합니다.
페타바이트급 데이터나 수천 명의 동시 대시보드 사용자가 필요한 환경이라면 전용 웨어하우스가 여전히 맞는 선택입니다. 그 전 단계에서 DuckDB는 분석 쿼리를 서비스 안에 직접 내재화하는 가장 낮은 진입 장벽을 제공합니다.
지금 시작하는 3단계:
- 설치 및 로컬 검증:
pnpm add @duckdb/node-api로 설치 후 로컬 Parquet 파일로 쿼리 테스트.:memory:모드로 시작하면 부담이 없습니다. - 분석 엔드포인트 분리: 기존 서비스에서 가장 무거운 집계 쿼리 하나를 골라 DuckDB 기반 엔드포인트로 교체해봅니다. 쿼리 실행 시간을 직접 비교해보시면 체감 차이가 납니다.
- S3 Parquet 연동: 이벤트 로그를 Hive 파티셔닝 구조로 S3에 적재하고
httpfs로 직접 쿼리하는 파이프라인을 붙입니다.enable_object_cache=true와 파티션 키 명시는 잊지 마세요.
참고 자료
- DuckDB 공식 문서
- Node.js API — DuckDB 공식 문서
- S3 Parquet Import — DuckDB 공식 가이드
- Querying Parquet Files — DuckDB 공식 가이드
- Announcing DuckDB 1.5.0
- duckdb/duckdb-node (GitHub)
- MotherDuck 공식 문서 — Architecture and capabilities
- MotherDuck Vercel 통합 가이드
- 5 DuckDB + S3 Patterns: Multi-Part, S3 Select, Costs (Medium)
- DuckDB vs Postgres for Embedded Analytics (MotherDuck Blog)
- Customer-facing analytics with DuckDB and MotherDuck (codecentric)
- Running analytics queries from AWS S3 using DuckDB (Engineer's Notes)
- tobilg/duckdb-nodejs-layer (GitHub)