별도 서버 없이 Parquet·CSV 수억 건을 SQL로 분석하는 Node.js 백엔드 구축 방법
데이터 분석 기능을 백엔드에 붙여야 한다는 요구사항이 생겼을 때, 대부분 처음으로 떠올리는 선택지는 Redshift나 BigQuery 같은 데이터 웨어하우스입니다. 저도 한동안 그랬어요. 그런데 세팅하고 나면 "쿼리 한 번 돌리는 데 이 인프라가 다 필요한가?" 싶은 순간이 옵니다. DuckDB는 그 고민의 답이 될 수 있습니다. 서버도, 클러스터도, 별도 프로세스도 없이 pnpm add @duckdb/node-api 한 줄로 Node.js 프로세스 안에서 수억 건짜리 Parquet 파일을 SQL로 분석할 수 있거든요.
이 글에서는 인프로세스 OLAP 데이터베이스라는 개념부터, 최신 권장 패키지 @duckdb/node-api로 TypeScript 백엔드에 DuckDB를 통합하는 구체적인 방법, 그리고 실제로 운영하면서 마주치는 동시성·보안·메모리 이슈까지 다룹니다. S3의 Parquet 파일을 직접 SQL로 쿼리하는 패턴까지 포함해서요. NestJS 기준으로 예시 코드를 작성하지만, Express나 Fastify에도 그대로 적용됩니다.
핵심 개념
DuckDB가 "SQLite for Analytics"인 이유
DuckDB를 처음 들으면 "또 다른 데이터베이스?" 싶을 텐데, 기존 DB들과 결정적으로 다른 점이 있습니다. 별도 서버 프로세스가 없습니다. SQLite처럼 라이브러리 형태로 애플리케이션에 링크되어 동작합니다. PostgreSQL을 쓰면 앱 서버 → 네트워크 → DB 서버를 거쳐야 하지만, DuckDB는 함수 호출입니다. 네트워크 레이턴시가 아예 없어요.
SQLite와 다른 점은 OLAP에 특화되어 있다는 것입니다. SQLite는 행(row) 단위로 데이터를 저장하고 처리하는 OLTP 친화적 구조지만, DuckDB는 컬럼(column) 단위 벡터화 실행 엔진입니다. DuckDB 공식 TPC-H 벤치마크에서 1억 5천만 건짜리 Q1 집계 쿼리를 일반 노트북 기준 1초 이내에 처리합니다.
Node.js 클라이언트 선택 — 레거시 함정 주의
npm에 duckdb와 @duckdb/node-api, 두 패키지가 있습니다. 결론부터 말하면 duckdb 패키지는 deprecated입니다. v1.4.x가 마지막이고 v1.5.x부터 배포가 중단됐습니다.
| 패키지 | 상태 | API 스타일 |
|---|---|---|
duckdb |
Deprecated — 사용하지 마세요 | 콜백 기반 레거시 |
@duckdb/node-api |
현재 권장 (v1.5.4+) | async/await 네이티브 |
@duckdb/node-bindings |
저수준 C API 바인딩 | node-api가 내부적으로 사용 |
Stack Overflow나 오래된 블로그 글에 duckdb 패키지 기반 예시가 많습니다. 저도 처음에 그거 보고 세팅했다가 뒤늦게 deprecated 공지를 발견하고 마이그레이션했던 기억이 있네요.
실행 모델과 동시성 이해
운영 환경에서 꼭 알아야 할 동시성 모델입니다.
핵심은 세 가지입니다:
- 다중 Reader는 동시에 가능: MVCC로 여러 커넥션이 동시에 읽을 수 있습니다
- Writer는 한 번에 하나: 쓰기 커넥션이 독점 잠금을 보유하는 동안 다른 커넥션은 대기합니다
- 멀티프로세스는 불가: Node.js 클러스터 모드에서 여러 프로세스가 같은
.duckdb파일을 공유하면 파일 잠금 오류가 납니다
분석용으로 주로 읽기만 한다면 거의 문제없습니다. 쓰기가 빈번하다면 단일 Writer 프로세스를 통해 직렬화하는 구조를 잡아두면 좋습니다.
실전 적용
설치 및 기본 설정
pnpm add @duckdb/node-apiDuckDB는 플랫폼별 네이티브 바이너리를 npm 설치 시 자동으로 다운로드합니다. macOS, Linux(x64/arm64), Windows 모두 지원됩니다.
인메모리 모드로 시작하는 가장 단순한 예시입니다:
// src/analytics/duckdb.client.ts
import { DuckDBInstance } from '@duckdb/node-api';
async function quickStart() {
// ':memory:' — 인메모리 DB, 프로세스 종료 시 데이터 사라짐
const instance = await DuckDBInstance.create(':memory:');
const conn = await instance.connect();
const result = await conn.run(`
SELECT 42 AS answer, 'DuckDB works!' AS message
`);
const rows = await result.fetchAllRows();
console.log(rows);
// [{ answer: 42, message: 'DuckDB works!' }]
await conn.close();
await instance.close();
}
quickStart();영속 파일 DB를 원한다면 ':memory:' 대신 파일 경로를 넘기면 됩니다:
const instance = await DuckDBInstance.create('./analytics.duckdb');경로 검증 유틸리티 — 먼저 준비해두기
이후 모든 파일 경로 예시에서 쓸 검증 함수입니다. Parquet·CSV 파일 경로를 SQL 문자열에 삽입할 때, Prepared Statement로는 FROM 절 경로를 바인딩할 수 없습니다. 대신 허용 디렉터리 밖으로의 탈출(Path Traversal)과 SQL 특수문자를 동시에 차단하는 화이트리스트 검증을 씁니다.
// src/analytics/path-validator.ts
import path from 'node:path';
export const DATA_BASE_DIR = path.resolve(process.env.DATA_BASE_DIR ?? './data');
export function validateDataPath(input: string): string {
// 영숫자·슬래시·하이픈·언더스코어·점·glob(*) 외 문자 거부
// 작은따옴표(')를 포함한 SQL 특수문자를 이 단계에서 차단
if (!/^[\w\-./\*]+$/.test(input)) {
throw new Error('경로에 허용되지 않은 문자가 포함되어 있습니다');
}
const resolved = path.resolve(DATA_BASE_DIR, input);
if (!resolved.startsWith(DATA_BASE_DIR + path.sep) && resolved !== DATA_BASE_DIR) {
throw new Error('허용된 디렉터리 외부 경로입니다');
}
return resolved;
}Parquet 파일 직접 쿼리
파일을 미리 로드할 필요 없이 SQL에서 직접 참조합니다:
// src/analytics/analytics.service.ts
import { validateDataPath } from './path-validator';
async getSalesSummary(year: number) {
// number 타입이라도 정수·범위 검증
if (!Number.isInteger(year) || year < 2000 || year > 2100) {
throw new Error('유효하지 않은 연도입니다');
}
// glob 패턴으로 여러 Parquet 파일을 한 번에 쿼리
const safePath = validateDataPath(`sales/${year}/*.parquet`);
const result = await this.conn.run(`
SELECT
region,
SUM(amount) AS total_sales,
COUNT(*) AS order_count,
AVG(amount) AS avg_order_value
FROM '${safePath}'
WHERE status = 'completed'
GROUP BY region
ORDER BY total_sales DESC
`);
return result.fetchAllRows();
}
async analyzeCSV(relativePath: string) {
const safePath = validateDataPath(relativePath);
const result = await this.conn.run(`
SELECT *
FROM read_csv_auto('${safePath}', header = true)
LIMIT 100
`);
return result.fetchAllRows();
}Prepared Statement로 필터 조건 안전하게 바인딩
파일 경로가 아닌 WHERE 조건 값에 사용자 입력이 들어온다면 반드시 Prepared Statement를 씁니다:
async getTopProducts(categoryId: number, limit: number) {
const safePath = validateDataPath('orders/*.parquet');
const stmt = await this.conn.prepare(`
SELECT
product_id,
product_name,
SUM(quantity) AS total_sold
FROM '${safePath}'
WHERE category_id = $categoryId
GROUP BY product_id, product_name
ORDER BY total_sold DESC
LIMIT $limit
`);
try {
const result = await stmt.run({ categoryId, limit });
return await result.fetchAllRows();
} finally {
await stmt.close();
}
}동일 쿼리를 반복 실행한다면 Prepared Statement를 인스턴스 레벨에서 캐싱해두면 파싱·계획 비용을 아낄 수 있습니다.
NestJS 모듈로 통합하기
// src/analytics/duckdb.provider.ts
import { DuckDBInstance, DuckDBConnection } from '@duckdb/node-api';
import { ConfigService } from '@nestjs/config';
export const DUCKDB_INSTANCE = 'DUCKDB_INSTANCE';
export const DUCKDB_CONNECTION = 'DUCKDB_CONNECTION';
export const DuckDBProviders = [
{
provide: DUCKDB_INSTANCE,
useFactory: async (config: ConfigService): Promise<DuckDBInstance> => {
const dbPath = config.get<string>('DUCKDB_PATH', ':memory:');
const instance = await DuckDBInstance.create(dbPath);
// threads·memory_limit은 SET 명령으로 설정
// DuckDBInstance.create의 두 번째 인수 시그니처는 버전마다 다를 수 있으므로
// 표준 SQL SET 명령이 가장 안전하고 버전에 독립적입니다
const configConn = await instance.connect();
try {
const threads = parseInt(config.get<string>('DUCKDB_THREADS', '4'), 10);
const memLimit = config.get<string>('DUCKDB_MEMORY_LIMIT', '2GB');
await configConn.run(`SET threads = ${threads}`);
await configConn.run(`SET memory_limit = '${memLimit}'`);
} finally {
await configConn.close();
}
return instance;
},
inject: [ConfigService],
},
{
provide: DUCKDB_CONNECTION,
useFactory: async (instance: DuckDBInstance): Promise<DuckDBConnection> => {
return instance.connect();
},
inject: [DUCKDB_INSTANCE],
},
];// src/analytics/analytics.module.ts
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { DuckDBProviders } from './duckdb.provider';
import { AnalyticsService } from './analytics.service';
import { AnalyticsController } from './analytics.controller';
@Module({
imports: [ConfigModule],
providers: [...DuckDBProviders, AnalyticsService],
controllers: [AnalyticsController],
exports: [AnalyticsService],
})
export class AnalyticsModule {}// src/analytics/analytics.service.ts
import { Injectable, Inject, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
import { DuckDBInstance, DuckDBConnection } from '@duckdb/node-api';
import { DUCKDB_INSTANCE, DUCKDB_CONNECTION } from './duckdb.provider';
import { validateDataPath } from './path-validator';
@Injectable()
export class AnalyticsService implements OnModuleInit, OnModuleDestroy {
constructor(
@Inject(DUCKDB_INSTANCE) private readonly instance: DuckDBInstance,
@Inject(DUCKDB_CONNECTION) private readonly conn: DuckDBConnection,
) {}
async onModuleInit() {
// httpfs 확장은 모듈 초기화 시 한 번만 로드
// INSTALL은 최초 실행 시에만 실제 다운로드가 발생하고 이후는 no-op
await this.conn.run(`INSTALL httpfs; LOAD httpfs;`);
// S3 자격증명: CREDENTIAL_CHAIN이 환경변수·IAM Role·인스턴스 프로파일을
// 순서대로 탐색하므로 코드에 키를 노출하지 않아도 됩니다
await this.conn.run(`
CREATE OR REPLACE SECRET aws_s3 (
TYPE S3,
PROVIDER CREDENTIAL_CHAIN
);
`);
}
async onModuleDestroy() {
try {
await this.conn.close();
} finally {
await this.instance.close();
}
}
async getDailySummary(date: string) {
// YYYY-MM-DD 형식 검증
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
throw new Error('유효하지 않은 날짜 형식입니다');
}
const safePath = validateDataPath(`events/${date}/*.parquet`);
const stmt = await this.conn.prepare(`
SELECT
DATE_TRUNC('hour', event_time) AS hour,
event_type,
COUNT(*) AS event_count
FROM '${safePath}'
WHERE event_time >= $startDate::TIMESTAMP
AND event_time < $startDate::TIMESTAMP + INTERVAL 1 DAY
GROUP BY 1, 2
ORDER BY 1, 2
`);
try {
const result = await stmt.run({ startDate: date });
return await result.fetchAllRows();
} finally {
await stmt.close();
}
}
}S3 원격 Parquet 직접 쿼리
onModuleInit에서 httpfs 로드와 인증을 마쳤으므로, 이후 쿼리는 S3 경로를 그냥 쓰면 됩니다:
async queryS3Parquet(s3Prefix: string) {
// s3Prefix 예: 'logs/2026/07'
// S3 경로는 별도 허용 prefix 검증 권장
if (!/^[\w\-./]+$/.test(s3Prefix)) {
throw new Error('유효하지 않은 S3 경로입니다');
}
const result = await this.conn.run(`
SELECT
user_id,
COUNT(*) AS page_views,
COUNT(DISTINCT session_id) AS sessions
FROM 's3://my-analytics-bucket/${s3Prefix}/*.parquet'
WHERE event_type = 'page_view'
GROUP BY user_id
HAVING page_views > 100
ORDER BY page_views DESC
LIMIT 1000
`);
return result.fetchAllRows();
}AWS 자격증명은 CREDENTIAL_CHAIN이 AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY 환경변수 → ~/.aws/credentials → EC2/ECS IAM Role 순서로 자동 탐색하므로, 코드에 키를 직접 노출할 필요가 없습니다.
대용량 결과 페이지 단위 처리
수백만 건 결과를 fetchAllRows()로 한 번에 올리면 메모리 문제가 생깁니다. LIMIT/OFFSET 기반 제너레이터로 청크 단위로 처리하는 패턴입니다:
async *streamResultByPage(
relativePath: string,
pageSize = 10_000,
): AsyncGenerator<Record<string, unknown>[]> {
const safePath = validateDataPath(relativePath);
let offset = 0;
while (true) {
const stmt = await this.conn.prepare(`
SELECT * FROM '${safePath}'
LIMIT $pageSize OFFSET $offset
`);
try {
const result = await stmt.run({ pageSize, offset });
const rows = await result.fetchAllRows() as Record<string, unknown>[];
if (rows.length === 0) break;
yield rows;
if (rows.length < pageSize) break;
offset += pageSize;
} finally {
await stmt.close();
}
}
}사용 예시:
for await (const page of service.streamResultByPage('large-dataset.parquet')) {
await processBatch(page);
}청크 단위 스트리밍이 필요하다면 result.fetchChunk() API도 제공됩니다. 청크 내 컬럼별 벡터 접근 방식은 공식 문서를 참고하세요. 대부분의 배치 처리 시나리오에서는 위의 LIMIT/OFFSET 제너레이터 패턴이 가독성과 안전성 면에서 더 실용적입니다.
CSV → Parquet 변환 파이프라인
DuckDB로 CSV를 받아 Parquet으로 저장하면 이후 쿼리 속도가 극적으로 올라갑니다:
async convertCSVToParquet(csvRelPath: string, outRelPath: string) {
const csvPath = validateDataPath(csvRelPath);
const outPath = validateDataPath(outRelPath);
await this.conn.run(`
COPY (
SELECT * FROM read_csv_auto('${csvPath}', header = true)
) TO '${outPath}'
(FORMAT PARQUET, COMPRESSION ZSTD, ROW_GROUP_SIZE 100000)
`);
}장단점 분석
장점 요약
| 장점 | 설명 |
|---|---|
| 인프라 제로 | DB 서버·클러스터 불필요. 패키지 설치 한 줄로 시작 |
| 파일 직접 쿼리 | SELECT … FROM '*.parquet' 형식, ETL 없이 바로 분석 |
| 뛰어난 분석 성능 | 벡터화 실행으로 1억 건 이상 스캔을 단일 노드에서 수초 내 |
| Larger-than-memory | 메모리 초과 데이터도 디스크 스필로 처리 |
| 표준 SQL 완벽 지원 | 윈도 함수, CTE, LATERAL, PIVOT 모두 지원 |
| 로컬 개발 친화적 | 운영 환경과 동일 코드를 로컬에서 그대로 실행 |
단점 및 한계
| 한계 | 내용 | 대안 |
|---|---|---|
| 동시 쓰기 제한 | 단일 Writer만 허용 | Appender API + 직렬화 큐 |
| OLTP 부적합 | 행 단위 빈번한 INSERT/UPDATE는 느림 | PostgreSQL을 OLTP로, DuckDB를 분석용으로 분리 |
| 멀티프로세스 제한 | 파일 잠금 충돌 | 단일 프로세스 또는 MotherDuck |
| 수평 확장 불가 | 단일 노드 설계 | MotherDuck, DuckLake |
| 서버리스 메모리 제한 | Lambda 128MB 등 상한 주의 | 메모리 한도 설정 + 페이지 처리 |
실무에서 흔한 실수
1. 레거시 패키지 사용
npm에서 duckdb 검색 후 설치하면 deprecated 패키지입니다. 반드시 @duckdb/node-api를 쓰세요.
2. FROM 절 경로의 잘못된 보안 처리
path.basename()은 디렉터리 탈출만 막고 SQL 인젝션은 막지 못합니다. FROM 절 경로는 Prepared Statement 파라미터로 바인딩할 수 없기 때문에, 허용 문자 화이트리스트 + 디렉터리 prefix 검사를 함께 써야 합니다:
// 위험 — 경로 탈출도, SQL 특수문자도 차단 못함
await conn.run(`SELECT * FROM '${userInput}'`);
// 잘못된 방향 — path.basename은 디렉터리 탈출만 막고
// userInput에 ' 문자가 있으면 SQL이 여전히 깨집니다
const name = path.basename(userInput);
await conn.run(`SELECT * FROM './data/${name}'`);
// 올바른 방향 — validateDataPath로 허용 문자 + 디렉터리 경계 동시 검증
const safePath = validateDataPath(userInput);
await conn.run(`SELECT * FROM '${safePath}'`);3. Prepared Statement 리소스 누수
stmt.close()는 반드시 try/finally로 감싸야 합니다. 쿼리 실행 중 예외가 나면 close()가 호출되지 않아 리소스가 누수됩니다.
4. 대용량 결과 fetchAllRows() 남용
수백만 건 결과를 fetchAllRows()로 한 번에 올리면 메모리 문제가 생깁니다. 위에서 보여드린 LIMIT/OFFSET 제너레이터 패턴을 사용하세요.
5. INSTALL httpfs를 API 함수 안에서 반복 호출
INSTALL은 최초 1회만 실제 다운로드를 하고 이후는 no-op이지만, LOAD는 커넥션마다 한 번 해야 합니다. 이 로직을 API 함수 안에 두면 호출마다 실행되고, 의도가 코드에서 전혀 드러나지 않습니다. onModuleInit에 모아두는 것이 올바른 패턴입니다.
마치며
정리하면 이렇습니다. DuckDB는 분석 워크로드에 특화된 인프로세스 OLAP 엔진입니다. pnpm add @duckdb/node-api 한 줄로 Node.js 백엔드에 심을 수 있고, Parquet·CSV 파일을 ETL 없이 SQL로 직접 쿼리할 수 있습니다. 읽기 위주의 분석 API, 로그 집계, 리포트 생성 시나리오에서 특히 강력합니다. 반대로 OLTP, 다중 프로세스 동시 쓰기, 멀티노드 수평 확장이 필요하다면 적합하지 않고, 그 경우엔 PostgreSQL이나 MotherDuck 같은 대안을 검토하면 됩니다.
지금 시작하는 3단계를 제안한다면:
- 설치 후 인메모리 모드로 빠르게 검증:
pnpm add @duckdb/node-api로 설치하고, 기존 CSV나 Parquet 파일을 인메모리 DB에서 쿼리해 성능을 직접 확인해보세요. - NestJS 모듈로 통합: 위 코드 예시를 기반으로
AnalyticsModule을 만들어 기존 서비스에 붙이면 됩니다. 기존 DB와 DuckDB를 병렬로 운영하다가 검증 후 전환해도 됩니다. - S3 파일 직접 쿼리 적용:
httpfs익스텐션과CREDENTIAL_CHAIN으로 S3 버킷의 Parquet 파일을 직접 쿼리하는 패턴을 적용하면 ETL 파이프라인 하나를 없앨 수 있습니다.
참고 자료
- DuckDB 공식 사이트
- Node.js Client (Neo) 공식 문서
- Node.js API LTS 문서
- DuckDB Node Neo 클라이언트 발표 블로그
- @duckdb/node-api npm 패키지
- duckdb-node-neo GitHub
- DuckDB Parquet 읽기/쓰기 공식 문서
- DuckDB 동시성 공식 문서
- DuckDB 성능 가이드
- DuckDB v1.4.1 LTS 발표
- DuckDB v1.5.0 발표
- duckdb-node-neo DeepWiki 성능 최적화
- X 데이터 분석 Node.js + DuckDB 실습 (MotherDuck)
- AWS Lambda에서 DuckDB 사용하기
- Cloudflare Workers용 DuckDB WASM 커스텀 빌드
- 임베디드 데이터베이스 2026 비교 (Kestra)
- DuckDB 프로덕션 운영 가이드
- dlt + SQLMesh + DuckDB ETL 파이프라인 예시