- Authors
- Name
- 1. DuckDB가 필요한 순간
- 2. 아키텍처 심층 분석
- 3. 설치 및 기본 사용법
- 4. 비교 분석: DuckDB vs 대안 기술
- 5. Parquet, CSV, JSON 직접 쿼리
- 6. Python 통합: pandas와 polars 연동
- 7. 원격 데이터 소스 쿼리
- 8. 성능 벤치마크와 최적화 기법
- 9. DuckDB 익스텐션 생태계
- 10. 운영 시 주의사항
- 11. 실패 사례와 대응 전략
- 12. 실전 활용 패턴
- 13. 운영 체크리스트
- 14. 마치며
- 참고 자료

1. DuckDB가 필요한 순간
데이터 분석 워크플로에서 흔히 겪는 시나리오가 있다. 수 GB짜리 CSV 파일을 pandas로 읽으려다 MemoryError를 만나거나, 간단한 집계 쿼리를 위해 ClickHouse 클러스터를 프로비저닝하는 상황이다. DuckDB는 이런 간극을 정확히 메운다.
DuckDB는 네덜란드 CWI(Centrum Wiskunde & Informatica)에서 Mark Raasveldt와 Hannes Muehleisen이 개발한 인프로세스(in-process) OLAP 데이터베이스다. 2019년 SIGMOD에서 발표된 논문 "DuckDB: an Embeddable Analytical Database"에서 처음 공개되었으며, SQLite의 임베디드 철학을 분석 워크로드에 적용한 것이 핵심 아이디어다.
DuckDB가 적합한 시나리오는 다음과 같다.
- 로컬 머신에서 수 GB~수십 GB 규모의 분석 쿼리를 서브초(sub-second) 만에 실행하고 싶은 경우
- Parquet, CSV, JSON 파일을 ETL 파이프라인 없이 즉시 SQL로 쿼리하고 싶은 경우
- Jupyter Notebook에서 pandas보다 빠른 대규모 집계 분석이 필요한 경우
- CI/CD 파이프라인에서 데이터 품질 검증용 임시 분석이 필요한 경우
- S3에 저장된 Parquet 파일을 서버 없이 직접 분석하고 싶은 경우
반면 DuckDB가 적합하지 않은 경우도 명확하다.
- 동시 다발적인 쓰기가 필요한 OLTP 워크로드 (PostgreSQL, MySQL 사용)
- 수백 명이 동시에 접속하는 서비스 백엔드 (동시성 제한)
- 페타바이트 규모의 분산 처리 (Spark, ClickHouse 클러스터 필요)
- 높은 가용성과 복제가 필요한 프로덕션 데이터 저장소
2. 아키텍처 심층 분석
2.1 벡터화 실행 엔진 (Vectorized Execution Engine)
DuckDB의 핵심 성능 비결은 벡터화 실행 엔진이다. 전통적인 행 기반 데이터베이스(예: PostgreSQL, MySQL)는 Volcano 모델을 사용하여 한 번에 한 행씩 처리한다. 이 방식은 함수 호출 오버헤드가 크고, CPU 분기 예측(branch prediction)을 방해하며, SIMD 명령어를 활용하기 어렵다.
DuckDB는 한 번에 2048개(기본값)의 값을 벡터로 묶어 처리한다. 이를 통해 다음과 같은 이점을 얻는다.
- CPU 캐시 효율 극대화: 벡터가 L1/L2 캐시에 완전히 들어가는 크기로 설계되어 캐시 미스를 최소화한다
- SIMD 활용: 컴파일러가 벡터 연산을 자동으로 SIMD 명령어(AVX2, AVX-512)로 변환할 수 있다
- 함수 호출 오버헤드 감소: 행 단위가 아닌 벡터 단위로 연산자를 호출하므로 오버헤드가 1/2048로 줄어든다
2.2 컬럼 기반 저장 구조
DuckDB는 내부적으로 데이터를 컬럼 단위로 저장한다. 분석 쿼리의 특성상 전체 컬럼 중 일부만 접근하는 경우가 대부분이므로, 불필요한 컬럼을 읽지 않아 I/O를 크게 절약한다.
-- 이 쿼리는 name과 amount 컬럼만 읽는다
-- 행 기반 DB는 모든 컬럼을 읽어야 하지만, DuckDB는 2개 컬럼만 접근
SELECT name, SUM(amount)
FROM sales
GROUP BY name;
컬럼 기반 저장은 같은 타입의 데이터가 연속으로 배치되므로 압축률도 높다. DuckDB는 내부적으로 Lightweight Compression 기법(Run-Length Encoding, Dictionary Encoding, BitPacking 등)을 자동 적용하여 메모리 사용량을 줄인다.
2.3 모젤 기반 병렬 처리 (Morsel-Driven Parallelism)
DuckDB는 데이터를 "morsel"이라 불리는 작은 청크(약 10,000행)로 분할하고, 이를 여러 워커 스레드에 동적으로 배분한다. 이 접근 방식은 정적 파티셔닝 대비 부하 불균형(skew)에 강하며, 코어 수에 비례하여 성능이 향상된다.
-- 병렬 처리 설정 확인 및 조정
SELECT current_setting('threads');
-- 스레드 수 명시적 설정
SET threads TO 8;
-- 워커당 메모리 설정
SET memory_limit = '4GB';
2.4 아웃오브코어(Out-of-Core) 처리
DuckDB 0.8부터 도입된 아웃오브코어 처리는, 가용 메모리보다 큰 데이터셋을 처리할 때 중간 결과를 디스크로 스필(spill)하는 기능이다. 이를 통해 메모리 제한을 넘어서는 분석이 가능해졌다.
-- 임시 디렉토리 설정 (스필 대상)
SET temp_directory = '/tmp/duckdb_spill';
-- 메모리 제한 설정 (이 값을 초과하면 디스크로 스필)
SET memory_limit = '2GB';
-- 아웃오브코어 처리로 대규모 정렬 수행
SELECT *
FROM large_table
ORDER BY timestamp_col;
3. 설치 및 기본 사용법
3.1 Python 설치 및 기본 쿼리
DuckDB의 가장 일반적인 사용 환경은 Python이다. pip 한 줄로 설치할 수 있으며, 외부 의존성이 전혀 없다.
# 설치
# pip install duckdb
import duckdb
# 인메모리 데이터베이스 생성
con = duckdb.connect()
# 기본 쿼리 실행
result = con.execute("""
SELECT
range AS id,
'user_' || range AS name,
random() * 1000 AS score
FROM range(1000000)
""").fetchdf()
print(f"행 수: {len(result)}")
print(result.head())
# 집계 쿼리
con.execute("""
CREATE TABLE sales AS
SELECT
range AS id,
CASE WHEN random() < 0.3 THEN 'electronics'
WHEN random() < 0.6 THEN 'clothing'
ELSE 'food' END AS category,
(random() * 500)::INTEGER AS amount,
DATE '2025-01-01' + INTERVAL (range % 365) DAY AS sale_date
FROM range(10000000)
""")
# 카테고리별 월간 매출 집계
result = con.execute("""
SELECT
category,
DATE_TRUNC('month', sale_date) AS month,
COUNT(*) AS cnt,
SUM(amount) AS total_amount,
AVG(amount)::INTEGER AS avg_amount
FROM sales
GROUP BY category, DATE_TRUNC('month', sale_date)
ORDER BY month, total_amount DESC
""").fetchdf()
print(result)
3.2 CLI 사용법
DuckDB는 독립 실행형 CLI도 제공한다. 데이터 탐색과 일회성 분석에 매우 유용하다.
# 설치 (macOS)
brew install duckdb
# 인메모리 모드로 시작
duckdb
# 파일 기반 데이터베이스로 시작
duckdb my_analytics.duckdb
CLI에서의 기본 사용 예시는 다음과 같다.
-- 테이블 생성 및 데이터 삽입
CREATE TABLE metrics (
timestamp TIMESTAMP,
server_id VARCHAR,
cpu_usage DOUBLE,
memory_usage DOUBLE
);
INSERT INTO metrics
SELECT
TIMESTAMP '2026-01-01' + INTERVAL (range * 60) SECOND,
'server-' || (range % 10)::VARCHAR,
50 + random() * 50,
30 + random() * 60
FROM range(100000);
-- 서버별 CPU 사용률 통계
SELECT
server_id,
MIN(cpu_usage)::DECIMAL(5,2) AS min_cpu,
AVG(cpu_usage)::DECIMAL(5,2) AS avg_cpu,
MAX(cpu_usage)::DECIMAL(5,2) AS max_cpu,
PERCENTILE_CONT(0.95) WITHIN GROUP (ORDER BY cpu_usage)::DECIMAL(5,2) AS p95_cpu
FROM metrics
GROUP BY server_id
ORDER BY avg_cpu DESC;
-- 시간대별 이상 탐지 (CPU 95% 이상)
SELECT
DATE_TRUNC('hour', timestamp) AS hour,
COUNT(*) AS anomaly_count,
LIST(DISTINCT server_id) AS affected_servers
FROM metrics
WHERE cpu_usage > 95
GROUP BY DATE_TRUNC('hour', timestamp)
HAVING COUNT(*) > 5
ORDER BY anomaly_count DESC;
3.3 Node.js 통합
Node.js 환경에서도 DuckDB를 사용할 수 있다. 서버리스 함수나 API 엔드포인트에서 분석 쿼리를 처리하는 데 유용하다.
// npm install duckdb
const duckdb = require('duckdb')
// 인메모리 데이터베이스 생성
const db = new duckdb.Database(':memory:')
const con = db.connect()
// Promise 래퍼 함수
function query(sql) {
return new Promise((resolve, reject) => {
con.all(sql, (err, rows) => {
if (err) reject(err)
else resolve(rows)
})
})
}
async function main() {
// Parquet 파일 직접 쿼리
const results = await query(`
SELECT
category,
COUNT(*) as count,
SUM(amount) as total
FROM read_parquet('sales_data.parquet')
GROUP BY category
ORDER BY total DESC
LIMIT 10
`)
console.log('Top categories:', results)
// CSV에서 데이터 로드 후 분석
await query(`
CREATE TABLE logs AS
SELECT * FROM read_csv_auto('access_logs.csv')
`)
const hourlyStats = await query(`
SELECT
DATE_TRUNC('hour', timestamp) AS hour,
COUNT(*) AS request_count,
AVG(response_time_ms) AS avg_response_ms
FROM logs
GROUP BY DATE_TRUNC('hour', timestamp)
ORDER BY hour
`)
console.log('Hourly stats:', hourlyStats)
}
main().catch(console.error)
4. 비교 분석: DuckDB vs 대안 기술
분석 워크로드를 처리하는 도구는 다양하다. 각 도구의 특성을 정확히 이해해야 올바른 선택을 할 수 있다.
4.1 종합 비교표
| 항목 | DuckDB | SQLite | ClickHouse | Polars | Pandas |
|---|---|---|---|---|---|
| 설계 목적 | 임베디드 OLAP | 임베디드 OLTP | 분산 OLAP 서버 | DataFrame 분석 | DataFrame 분석 |
| 저장 방식 | 컬럼 기반 | 행 기반 | 컬럼 기반 | 컬럼 기반 | 컬럼 기반 |
| 실행 모델 | 벡터화 | 행 단위 | 벡터화 | 벡터화 | 행/블록 혼합 |
| 쿼리 언어 | SQL (PostgreSQL 호환) | SQL (독자 방언) | SQL (독자 방언) | Python API/SQL | Python API |
| 동시성 | 단일 쓰기/다중 읽기 | 단일 쓰기/다중 읽기 | 다중 쓰기/읽기 | 해당 없음 | 해당 없음 |
| 서버 필요 | 불필요 (인프로세스) | 불필요 (인프로세스) | 필요 (서버 프로세스) | 불필요 (라이브러리) | 불필요 (라이브러리) |
| 10GB CSV 집계 | 약 3초 | 약 60초 이상 | 약 1초 | 약 5초 | 약 30초 (OOM 위험) |
| 메모리 효율 | 높음 (아웃오브코어) | 보통 | 매우 높음 | 높음 | 낮음 |
| 확장성 | 단일 노드 | 단일 노드 | 수평 확장 (클러스터) | 단일 노드 | 단일 노드 |
| 설치 난이도 | 매우 쉬움 | 내장 | 중간 (서버 설정) | 쉬움 | 매우 쉬움 |
| Parquet 지원 | 네이티브 | 미지원 | 네이티브 | 네이티브 | pyarrow 필요 |
| 적합한 데이터 규모 | MB~수십 GB | KB~수 GB | GB~PB | MB~수십 GB | MB~수 GB |
4.2 핵심 판단 기준
DuckDB를 선택해야 할 때:
- SQL로 분석하고 싶고, 서버 설치 없이 즉시 시작하고 싶을 때
- Parquet/CSV 파일을 직접 쿼리해야 할 때
- Jupyter Notebook에서 대규모 데이터 탐색이 필요할 때
ClickHouse를 선택해야 할 때:
- 다수의 동시 사용자가 분석 쿼리를 실행할 때
- 페타바이트 규모의 데이터를 처리해야 할 때
- 실시간 데이터 수집과 분석이 동시에 필요할 때
Polars를 선택해야 할 때:
- Python API 기반의 데이터 변환 파이프라인을 구축할 때
- Lazy evaluation으로 최적화된 복잡한 데이터 변환이 필요할 때
- SQL보다 프로그래밍적 접근을 선호할 때
5. Parquet, CSV, JSON 직접 쿼리
DuckDB의 가장 강력한 기능 중 하나는 외부 파일을 테이블로 로드하지 않고도 직접 SQL로 쿼리할 수 있다는 점이다. 이를 "zero-ETL" 분석이라고 부른다.
5.1 Parquet 파일 쿼리
Parquet는 DuckDB와 궁합이 가장 좋은 포맷이다. 컬럼 기반 포맷이므로 DuckDB의 컬럼 기반 엔진과 자연스럽게 맞물리며, 컬럼 프루닝(column pruning)과 행 그룹 필터링(predicate pushdown)이 자동 적용된다.
import duckdb
con = duckdb.connect()
# 단일 Parquet 파일 쿼리
result = con.execute("""
SELECT
customer_region,
COUNT(*) AS order_count,
SUM(total_amount) AS revenue,
AVG(total_amount)::DECIMAL(10,2) AS avg_order_value
FROM read_parquet('orders_2025.parquet')
WHERE order_date >= '2025-06-01'
GROUP BY customer_region
ORDER BY revenue DESC
""").fetchdf()
# 와일드카드로 여러 Parquet 파일 한번에 쿼리
result = con.execute("""
SELECT
filename,
DATE_TRUNC('month', event_time) AS month,
COUNT(*) AS event_count
FROM read_parquet('events/year=2025/month=*/data_*.parquet',
filename=true,
hive_partitioning=true)
GROUP BY filename, DATE_TRUNC('month', event_time)
""").fetchdf()
# Parquet 메타데이터만 조회 (파일을 읽지 않음)
metadata = con.execute("""
SELECT * FROM parquet_metadata('orders_2025.parquet')
""").fetchdf()
print(f"행 그룹 수: {len(metadata)}")
print(f"컬럼: {metadata['path_in_schema'].unique()}")
# Parquet 스키마 조회
schema = con.execute("""
SELECT * FROM parquet_schema('orders_2025.parquet')
""").fetchdf()
print(schema)
5.2 CSV 파일 쿼리
CSV 파일도 스키마 자동 탐지(auto-detection)를 통해 즉시 쿼리할 수 있다.
# CSV 자동 탐지 쿼리
result = con.execute("""
SELECT *
FROM read_csv_auto('sales_data.csv')
LIMIT 10
""").fetchdf()
# 명시적 스키마 지정 (대규모 파일에서 안정적)
result = con.execute("""
SELECT
product_name,
SUM(quantity) AS total_qty,
SUM(price * quantity) AS total_revenue
FROM read_csv('sales_data.csv',
columns = {
'order_id': 'INTEGER',
'product_name': 'VARCHAR',
'quantity': 'INTEGER',
'price': 'DECIMAL(10,2)',
'order_date': 'DATE'
},
dateformat = '%Y-%m-%d',
header = true,
delim = ','
)
GROUP BY product_name
ORDER BY total_revenue DESC
LIMIT 20
""").fetchdf()
# 여러 CSV 파일을 글로브 패턴으로 한번에 읽기
result = con.execute("""
SELECT COUNT(*) AS total_rows
FROM read_csv_auto('logs/access_log_2025_*.csv')
""").fetchone()
print(f"전체 로그 행 수: {result[0]:,}")
5.3 JSON 파일 쿼리
# JSON 파일 쿼리
result = con.execute("""
SELECT
json_extract_string(data, '$.user.name') AS user_name,
json_extract_string(data, '$.event_type') AS event_type,
json_extract(data, '$.metadata.duration')::INTEGER AS duration_ms
FROM read_json_auto('events.json')
WHERE json_extract_string(data, '$.event_type') = 'page_view'
""").fetchdf()
# NDJSON (Newline Delimited JSON) 파일 쿼리
result = con.execute("""
SELECT
timestamp,
level,
message,
service
FROM read_json_auto('application.ndjson', format='newline_delimited')
WHERE level = 'ERROR'
ORDER BY timestamp DESC
LIMIT 100
""").fetchdf()
5.4 멀티 포맷 조인
서로 다른 포맷의 파일을 하나의 쿼리에서 조인할 수 있다. 이것이 DuckDB의 진정한 강점이다.
# Parquet 주문 데이터 + CSV 고객 데이터 + JSON 설정 데이터 조인
result = con.execute("""
SELECT
c.customer_name,
c.tier,
COUNT(o.order_id) AS order_count,
SUM(o.amount) AS total_spent,
AVG(o.amount)::DECIMAL(10,2) AS avg_order
FROM read_parquet('orders/*.parquet') o
JOIN read_csv_auto('customers.csv') c
ON o.customer_id = c.id
WHERE o.order_date >= '2025-01-01'
GROUP BY c.customer_name, c.tier
HAVING COUNT(o.order_id) >= 5
ORDER BY total_spent DESC
LIMIT 20
""").fetchdf()
6. Python 통합: pandas와 polars 연동
6.1 pandas DataFrame 제로카피 연동
DuckDB는 pandas DataFrame을 직접 SQL 테이블처럼 쿼리할 수 있다. Apache Arrow를 통한 제로카피 데이터 전달로, 대규모 DataFrame도 복사 없이 즉시 분석할 수 있다.
import duckdb
import pandas as pd
import numpy as np
# pandas DataFrame 생성
df_orders = pd.DataFrame({
'order_id': range(1, 1000001),
'customer_id': np.random.randint(1, 10001, 1000000),
'amount': np.random.uniform(10, 500, 1000000).round(2),
'category': np.random.choice(['electronics', 'clothing', 'food', 'books'], 1000000),
'order_date': pd.date_range('2025-01-01', periods=1000000, freq='30s')
})
df_customers = pd.DataFrame({
'id': range(1, 10001),
'name': [f'Customer_{i}' for i in range(1, 10001)],
'tier': np.random.choice(['gold', 'silver', 'bronze'], 10000)
})
# DataFrame을 직접 SQL로 쿼리 (변수명이 테이블명이 됨)
result = duckdb.sql("""
SELECT
c.tier,
o.category,
COUNT(*) AS order_count,
SUM(o.amount)::DECIMAL(12,2) AS total_revenue,
AVG(o.amount)::DECIMAL(10,2) AS avg_order_value
FROM df_orders o
JOIN df_customers c ON o.customer_id = c.id
GROUP BY c.tier, o.category
ORDER BY c.tier, total_revenue DESC
""").fetchdf()
print(result)
6.2 polars DataFrame 연동
polars DataFrame도 동일한 방식으로 쿼리할 수 있다. polars는 이미 Arrow 포맷을 사용하므로 데이터 변환 오버헤드가 전혀 없다.
import duckdb
import polars as pl
# polars DataFrame 생성
df_events = pl.DataFrame({
'event_id': range(1, 5000001),
'user_id': [i % 100000 for i in range(1, 5000001)],
'event_type': ['click', 'view', 'purchase', 'search'] * 1250000,
'duration_ms': [abs(int(x)) for x in (100 + 50 * i % 7 for i in range(5000000))],
'timestamp': pl.date_range(
pl.datetime(2025, 1, 1),
pl.datetime(2025, 12, 31),
eager=True
).sample(5000000, with_replacement=True).sort()
})
# polars DataFrame을 DuckDB로 쿼리
result = duckdb.sql("""
SELECT
event_type,
DATE_TRUNC('month', timestamp) AS month,
COUNT(*) AS event_count,
AVG(duration_ms)::INTEGER AS avg_duration,
PERCENTILE_CONT(0.95) WITHIN GROUP (ORDER BY duration_ms)::INTEGER AS p95_duration
FROM df_events
GROUP BY event_type, DATE_TRUNC('month', timestamp)
ORDER BY month, event_count DESC
""").fetchdf()
# 결과를 다시 polars로 변환
result_pl = pl.from_pandas(result)
print(result_pl)
6.3 DuckDB와 pandas 성능 비교
동일한 작업에 대한 pandas와 DuckDB의 성능 차이를 직접 확인해 보자.
import duckdb
import pandas as pd
import numpy as np
import time
# 1000만 행 테스트 데이터 생성
n_rows = 10_000_000
df = pd.DataFrame({
'group_a': np.random.choice(['A', 'B', 'C', 'D', 'E'], n_rows),
'group_b': np.random.randint(1, 101, n_rows),
'value': np.random.uniform(0, 1000, n_rows)
})
# pandas로 그룹별 집계
start = time.time()
pandas_result = df.groupby(['group_a', 'group_b']).agg(
count=('value', 'count'),
sum_val=('value', 'sum'),
mean_val=('value', 'mean'),
std_val=('value', 'std')
).reset_index().sort_values('sum_val', ascending=False).head(20)
pandas_time = time.time() - start
# DuckDB로 동일한 집계
start = time.time()
duckdb_result = duckdb.sql("""
SELECT
group_a,
group_b,
COUNT(*) AS count,
SUM(value)::DECIMAL(15,2) AS sum_val,
AVG(value)::DECIMAL(10,2) AS mean_val,
STDDEV(value)::DECIMAL(10,2) AS std_val
FROM df
GROUP BY group_a, group_b
ORDER BY sum_val DESC
LIMIT 20
""").fetchdf()
duckdb_time = time.time() - start
print(f"pandas: {pandas_time:.3f}s")
print(f"DuckDB: {duckdb_time:.3f}s")
print(f"속도 향상: {pandas_time / duckdb_time:.1f}x")
# 일반적으로 DuckDB가 3~10배 빠름
7. 원격 데이터 소스 쿼리
7.1 S3 Parquet 파일 직접 쿼리
DuckDB의 httpfs 익스텐션을 사용하면 S3에 저장된 Parquet 파일을 로컬 파일처럼 쿼리할 수 있다. DuckDB 공식 문서(duckdb.org/docs/extensions/httpfs)에 따르면, 이 익스텐션은 HTTP Range 요청을 활용하여 필요한 바이트 범위만 다운로드한다.
import duckdb
con = duckdb.connect()
# httpfs 익스텐션 설치 및 로드
con.execute("INSTALL httpfs")
con.execute("LOAD httpfs")
# AWS 자격증명 설정
con.execute("""
SET s3_region = 'ap-northeast-2';
SET s3_access_key_id = 'YOUR_ACCESS_KEY';
SET s3_secret_access_key = 'YOUR_SECRET_KEY';
""")
# S3 Parquet 파일 직접 쿼리
result = con.execute("""
SELECT
DATE_TRUNC('day', event_time) AS day,
event_type,
COUNT(*) AS event_count
FROM read_parquet('s3://my-data-lake/events/year=2025/month=12/*.parquet',
hive_partitioning=true)
GROUP BY DATE_TRUNC('day', event_time), event_type
ORDER BY day, event_count DESC
""").fetchdf()
print(result)
7.2 HTTP URL에서 직접 쿼리
공개된 데이터셋을 URL로 직접 쿼리할 수도 있다.
con = duckdb.connect()
con.execute("INSTALL httpfs")
con.execute("LOAD httpfs")
# HTTP URL에서 CSV 직접 쿼리
result = con.execute("""
SELECT
Country,
SUM(Confirmed) AS total_confirmed,
SUM(Deaths) AS total_deaths,
(SUM(Deaths)::FLOAT / NULLIF(SUM(Confirmed), 0) * 100)::DECIMAL(5,2) AS fatality_rate
FROM read_csv_auto(
'https://raw.githubusercontent.com/datasets/covid-19/main/data/countries-aggregated.csv'
)
WHERE Date >= '2023-01-01'
GROUP BY Country
ORDER BY total_confirmed DESC
LIMIT 20
""").fetchdf()
print(result)
7.3 MotherDuck 클라우드 연동
MotherDuck(motherduck.com)은 DuckDB의 클라우드 서비스로, 로컬 DuckDB와 클라우드를 하이브리드로 연동할 수 있다. MotherDuck 블로그에서 소개된 바와 같이, 로컬에서 시작하여 점진적으로 클라우드로 확장하는 패턴을 지원한다.
import duckdb
# MotherDuck 연결 (인증 토큰 필요)
con = duckdb.connect('md:my_database')
# 로컬 데이터와 클라우드 데이터 조인
result = con.execute("""
SELECT
local_table.id,
cloud_table.aggregated_value
FROM read_parquet('local_data.parquet') AS local_table
JOIN my_database.cloud_table
ON local_table.id = cloud_table.id
""").fetchdf()
8. 성능 벤치마크와 최적화 기법
8.1 TPC-H 벤치마크 결과
DuckDB GitHub 저장소(github.com/duckdb/duckdb)에 공개된 TPC-H 벤치마크에서, DuckDB는 Scale Factor 10(약 10GB)에서 다음과 같은 성능을 보여준다.
| 쿼리 | DuckDB (초) | SQLite (초) | pandas (초) | 비고 |
|---|---|---|---|---|
| Q1 (가격 요약) | 0.4 | 28.5 | 12.3 | 단순 집계, 전체 스캔 |
| Q3 (배송 우선순위) | 0.8 | 45.2 | OOM | 조인 + 집계 |
| Q6 (매출 변동) | 0.1 | 15.8 | 5.2 | 필터 + 집계 |
| Q9 (제품 수익성) | 2.1 | 120+ | OOM | 복잡한 다중 조인 |
| Q18 (대량 주문 고객) | 1.5 | 90+ | OOM | 서브쿼리 + 집계 |
참고: 테스트 환경 - Apple M2 Pro, 16GB RAM, macOS. DuckDB 1.1, SQLite 3.45, pandas 2.2
8.2 쿼리 최적화 기법
-- 1. EXPLAIN ANALYZE로 실행 계획 확인
EXPLAIN ANALYZE
SELECT customer_id, SUM(amount)
FROM orders
WHERE order_date >= '2025-01-01'
GROUP BY customer_id
HAVING SUM(amount) > 10000;
-- 2. 프로파일링 활성화
PRAGMA enable_profiling;
PRAGMA profiling_output = '/tmp/duckdb_profile.json';
PRAGMA profiling_mode = 'detailed';
-- 쿼리 실행 후 프로파일 확인
SELECT customer_id, SUM(amount)
FROM orders
GROUP BY customer_id;
-- 3. 통계 정보 확인
CALL pragma_storage_info('orders');
-- 4. 인덱스 활용 (DuckDB는 ART 인덱스 지원)
-- 주의: DuckDB는 OLAP 최적화 DB이므로 인덱스가 항상 유리한 것은 아님
CREATE INDEX idx_orders_date ON orders(order_date);
-- 5. 파티셔닝된 Parquet으로 내보내기 (쿼리 성능 향상)
COPY (
SELECT * FROM orders
) TO 'orders_partitioned' (
FORMAT PARQUET,
PARTITION_BY (order_year, order_month),
OVERWRITE_OR_IGNORE true
);
8.3 메모리 최적화 전략
-- 현재 메모리 사용량 확인
SELECT * FROM duckdb_memory();
-- 메모리 제한 설정
SET memory_limit = '8GB';
-- 임시 디렉토리 설정 (메모리 초과 시 디스크 스필)
SET temp_directory = '/tmp/duckdb_temp';
-- 프로그레스 바 활성화 (장시간 쿼리 모니터링)
SET enable_progress_bar = true;
SET enable_progress_bar_print = true;
-- 불필요한 컬럼 제거로 메모리 절약
-- Bad: SELECT * 는 모든 컬럼을 메모리에 로드
-- Good: 필요한 컬럼만 명시
SELECT order_id, amount, order_date
FROM read_parquet('large_orders.parquet');
8.4 대규모 데이터 처리 패턴
import duckdb
con = duckdb.connect()
con.execute("SET memory_limit = '4GB'")
con.execute("SET temp_directory = '/tmp/duckdb_spill'")
# 청크 단위 처리로 메모리 효율적 분석
con.execute("""
CREATE TABLE daily_summary AS
SELECT
DATE_TRUNC('day', event_time) AS day,
event_type,
COUNT(*) AS cnt,
SUM(value) AS total_value
FROM read_parquet('s3://data-lake/events/year=2025/**/*.parquet',
hive_partitioning=true)
GROUP BY DATE_TRUNC('day', event_time), event_type
""")
# 결과를 Parquet로 내보내기
con.execute("""
COPY daily_summary
TO 'daily_summary.parquet' (FORMAT PARQUET, COMPRESSION ZSTD)
""")
# 처리 완료 후 메모리 해제
con.execute("DROP TABLE daily_summary")
9. DuckDB 익스텐션 생태계
DuckDB는 익스텐션 시스템을 통해 기능을 확장할 수 있다. DuckDB 익스텐션 문서(duckdb.org/docs/extensions/overview)에 따르면, 주요 익스텐션은 다음과 같다.
9.1 주요 익스텐션 목록
| 익스텐션 | 용도 | 설치 명령 |
|---|---|---|
| httpfs | S3/HTTP 원격 파일 접근 | INSTALL httpfs |
| json | JSON 처리 강화 | 기본 내장 |
| parquet | Parquet 읽기/쓰기 | 기본 내장 |
| icu | 국제화/정렬/로케일 | INSTALL icu |
| fts | 전문 검색(Full-Text Search) | INSTALL fts |
| spatial | 공간 데이터 처리 (PostGIS 유사) | INSTALL spatial |
| postgres_scanner | PostgreSQL 테이블 직접 쿼리 | INSTALL postgres_scanner |
| mysql_scanner | MySQL 테이블 직접 쿼리 | INSTALL mysql_scanner |
| sqlite_scanner | SQLite 파일 직접 쿼리 | INSTALL sqlite_scanner |
| excel | Excel 파일 읽기 | INSTALL excel |
9.2 PostgreSQL 직접 연동
import duckdb
con = duckdb.connect()
con.execute("INSTALL postgres_scanner")
con.execute("LOAD postgres_scanner")
# PostgreSQL에 직접 연결하여 쿼리
con.execute("""
CALL postgres_attach(
'host=localhost port=5432 dbname=mydb user=analyst password=secret'
)
""")
# PostgreSQL 테이블을 DuckDB SQL로 분석
result = con.execute("""
SELECT
DATE_TRUNC('month', created_at) AS month,
status,
COUNT(*) AS order_count,
SUM(total_amount)::DECIMAL(15,2) AS revenue
FROM postgres_scan('public', 'orders')
WHERE created_at >= '2025-01-01'
GROUP BY DATE_TRUNC('month', created_at), status
ORDER BY month
""").fetchdf()
# 로컬 Parquet와 PostgreSQL 데이터 조인
result = con.execute("""
SELECT
p.product_name,
o.order_count,
o.total_revenue
FROM read_parquet('product_catalog.parquet') p
JOIN (
SELECT product_id, COUNT(*) AS order_count, SUM(amount) AS total_revenue
FROM postgres_scan('public', 'order_items')
GROUP BY product_id
) o ON p.id = o.product_id
ORDER BY o.total_revenue DESC
LIMIT 20
""").fetchdf()
print(result)
10. 운영 시 주의사항
10.1 동시성 제한
DuckDB는 단일 프로세스 내에서 동작하는 임베디드 데이터베이스다. SIGMOD 2019 논문에서도 명시하듯이, OLTP 수준의 동시성은 설계 목표가 아니다.
핵심 제약사항:
- 단일 쓰기 연결: 하나의 프로세스만 쓰기 모드로 데이터베이스에 접근할 수 있다
- 다중 읽기 가능: 여러 프로세스가 동시에 읽기 전용으로 접근할 수 있다
- 웹 서버 백엔드로 부적합: 동시 요청이 많은 API 서버에서는 커넥션 병목이 발생한다
import duckdb
# 읽기 전용 모드로 여러 프로세스 접근
con = duckdb.connect('analytics.duckdb', read_only=True)
# 쓰기가 필요한 경우 단일 프로세스만 접근
con_write = duckdb.connect('analytics.duckdb', read_only=False)
10.2 메모리 관리 전략
DuckDB는 인메모리 처리를 기본으로 하므로, 메모리 관리가 매우 중요하다.
import duckdb
import os
# 시스템 메모리의 70%를 DuckDB에 할당 (나머지는 OS와 다른 프로세스용)
total_memory_gb = os.sysconf('SC_PAGE_SIZE') * os.sysconf('SC_PHYS_PAGES') / (1024**3)
duckdb_memory = int(total_memory_gb * 0.7)
con = duckdb.connect()
con.execute(f"SET memory_limit = '{duckdb_memory}GB'")
con.execute("SET temp_directory = '/tmp/duckdb_spill'")
# 메모리 사용량 모니터링
def check_memory():
result = con.execute("""
SELECT
tag,
memory_usage_bytes / (1024*1024) AS memory_mb
FROM duckdb_memory()
ORDER BY memory_usage_bytes DESC
""").fetchdf()
print(result)
return result
check_memory()
10.3 데이터 영속성과 백업
import duckdb
import shutil
from datetime import datetime
# 파일 기반 데이터베이스 사용 (영속성 확보)
con = duckdb.connect('production_analytics.duckdb')
# WAL(Write-Ahead Log) 모드 확인
# DuckDB는 자체 WAL 구현을 사용
con.execute("""
CREATE TABLE IF NOT EXISTS etl_log (
run_id INTEGER,
table_name VARCHAR,
rows_processed BIGINT,
started_at TIMESTAMP,
completed_at TIMESTAMP,
status VARCHAR
)
""")
# 백업 (DuckDB 파일을 직접 복사)
def backup_database(db_path, backup_dir):
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
backup_path = f"{backup_dir}/analytics_backup_{timestamp}.duckdb"
# 쓰기 커넥션을 닫은 후 복사
shutil.copy2(db_path, backup_path)
print(f"백업 완료: {backup_path}")
return backup_path
# EXPORT DATABASE로 SQL 덤프 생성
con.execute("""
EXPORT DATABASE '/tmp/analytics_export' (FORMAT PARQUET)
""")
11. 실패 사례와 대응 전략
11.1 메모리 초과 (OOM) 문제
증상: 대규모 GROUP BY나 JOIN 실행 시 프로세스가 killed 되거나 OutOfMemoryException 발생
import duckdb
con = duckdb.connect()
# 문제 상황: 10억 행 데이터에 대한 고카디널리티 GROUP BY
# 이 쿼리는 메모리를 과도하게 사용할 수 있다
try:
result = con.execute("""
SELECT user_id, COUNT(DISTINCT session_id), AVG(duration)
FROM read_parquet('huge_events_*.parquet')
GROUP BY user_id
""").fetchdf()
except duckdb.OutOfMemoryException:
print("메모리 초과 발생!")
# 해결 방법 1: 아웃오브코어 처리 활성화
con.execute("SET memory_limit = '4GB'")
con.execute("SET temp_directory = '/ssd/duckdb_temp'") # SSD 추천
# 해결 방법 2: 단계적 처리 (partition pruning 활용)
for month in range(1, 13):
result = con.execute(f"""
SELECT user_id, COUNT(DISTINCT session_id), AVG(duration)
FROM read_parquet('events/month={month:02d}/*.parquet',
hive_partitioning=true)
GROUP BY user_id
""").fetchdf()
result.to_parquet(f'partial_results/month_{month:02d}.parquet')
# 부분 결과를 합산
final = con.execute("""
SELECT user_id,
SUM(count_distinct_session) AS total_sessions,
AVG(avg_duration) AS overall_avg_duration
FROM read_parquet('partial_results/month_*.parquet')
GROUP BY user_id
""").fetchdf()
# 해결 방법 3: 근사 집계 사용 (정확도 vs 메모리 트레이드오프)
result = con.execute("""
SELECT
user_segment,
APPROX_COUNT_DISTINCT(user_id) AS approx_users,
APPROX_QUANTILE(amount, 0.5) AS median_amount,
APPROX_QUANTILE(amount, 0.95) AS p95_amount
FROM read_parquet('huge_events_*.parquet')
GROUP BY user_segment
""").fetchdf()
11.2 동시 쓰기 충돌
증상: 여러 프로세스가 동시에 같은 .duckdb 파일에 쓰기를 시도할 때 IOException: Could not set lock on file 발생
import duckdb
import time
import random
# 문제 상황: 여러 ETL 잡이 동일 DB에 동시 쓰기 시도
# 해결: 쓰기는 단일 프로세스로 직렬화
# 패턴 1: 파일 잠금 + 재시도
def safe_write(db_path, query, max_retries=5):
for attempt in range(max_retries):
try:
con = duckdb.connect(db_path)
con.execute(query)
con.close()
return True
except duckdb.IOException as e:
if "lock" in str(e).lower():
wait_time = (2 ** attempt) + random.uniform(0, 1)
print(f"잠금 충돌, {wait_time:.1f}초 후 재시도 ({attempt + 1}/{max_retries})")
time.sleep(wait_time)
else:
raise
raise Exception("최대 재시도 횟수 초과")
# 패턴 2: 각 프로세스가 개별 파일에 쓰고 나중에 병합
def write_to_individual_file(worker_id, data_path):
output_path = f'worker_output/worker_{worker_id}.parquet'
con = duckdb.connect() # 인메모리로 처리
con.execute(f"""
COPY (
SELECT * FROM read_parquet('{data_path}')
WHERE some_condition
) TO '{output_path}' (FORMAT PARQUET)
""")
con.close()
# 병합 단계
def merge_results():
con = duckdb.connect('final_analytics.duckdb')
con.execute("""
CREATE OR REPLACE TABLE results AS
SELECT * FROM read_parquet('worker_output/worker_*.parquet')
""")
con.close()
11.3 Parquet 스키마 불일치
증상: 여러 Parquet 파일을 함께 읽을 때 Binder Error: Types mismatch 또는 컬럼 누락 오류
import duckdb
con = duckdb.connect()
# 문제 상황: 스키마가 다른 Parquet 파일들을 함께 읽기
# 시간이 지나면서 컬럼이 추가되거나 타입이 변경된 경우
# 해결 방법 1: union_by_name 옵션 사용
result = con.execute("""
SELECT *
FROM read_parquet(
'events/year=*/month=*/*.parquet',
hive_partitioning = true,
union_by_name = true -- 컬럼명 기준으로 자동 합치기
)
LIMIT 10
""").fetchdf()
# 해결 방법 2: 스키마 확인 후 안전한 쿼리 작성
schema_info = con.execute("""
SELECT file_name, name, type
FROM parquet_schema('events/year=2025/month=01/*.parquet')
""").fetchdf()
print("스키마 확인:", schema_info)
# 해결 방법 3: COALESCE로 누락 컬럼 처리
result = con.execute("""
SELECT
event_id,
event_type,
COALESCE(new_column, 'default_value') AS new_column,
timestamp
FROM read_parquet(
'events/**/*.parquet',
union_by_name = true
)
""").fetchdf()
12. 실전 활용 패턴
12.1 ETL 파이프라인
import duckdb
from datetime import datetime
def daily_etl_pipeline(date_str):
"""일별 ETL 파이프라인: S3 원본 -> 변환 -> Parquet 저장"""
con = duckdb.connect()
con.execute("INSTALL httpfs; LOAD httpfs;")
con.execute("""
SET s3_region = 'ap-northeast-2';
SET s3_access_key_id = 'YOUR_KEY';
SET s3_secret_access_key = 'YOUR_SECRET';
""")
# Step 1: 원본 데이터 읽기 + 클린징
con.execute(f"""
CREATE TEMP TABLE raw_events AS
SELECT
event_id,
LOWER(TRIM(event_type)) AS event_type,
user_id,
COALESCE(amount, 0) AS amount,
TRY_CAST(timestamp AS TIMESTAMP) AS event_time,
properties
FROM read_parquet(
's3://raw-data/events/date={date_str}/*.parquet'
)
WHERE TRY_CAST(timestamp AS TIMESTAMP) IS NOT NULL
AND user_id IS NOT NULL
""")
# Step 2: 사용자 세그먼트 매핑
con.execute("""
CREATE TEMP TABLE enriched_events AS
SELECT
e.*,
u.segment,
u.registration_date,
DATEDIFF('day', u.registration_date, e.event_time) AS days_since_registration
FROM raw_events e
LEFT JOIN read_parquet('s3://dim-data/users/latest/*.parquet') u
ON e.user_id = u.user_id
""")
# Step 3: 집계 테이블 생성
con.execute(f"""
COPY (
SELECT
DATE_TRUNC('hour', event_time) AS hour,
segment,
event_type,
COUNT(*) AS event_count,
COUNT(DISTINCT user_id) AS unique_users,
SUM(amount) AS total_amount,
AVG(amount)::DECIMAL(10,2) AS avg_amount
FROM enriched_events
GROUP BY
DATE_TRUNC('hour', event_time),
segment,
event_type
) TO 's3://processed-data/hourly_summary/date={date_str}/data.parquet'
(FORMAT PARQUET, COMPRESSION ZSTD)
""")
row_count = con.execute("SELECT COUNT(*) FROM enriched_events").fetchone()[0]
print(f"[{date_str}] ETL 완료: {row_count:,}행 처리")
con.close()
return row_count
# 실행
daily_etl_pipeline('2025-12-15')
12.2 데이터 품질 검증
import duckdb
def validate_data_quality(parquet_path):
"""데이터 품질 검증 쿼리 모음"""
con = duckdb.connect()
# 기본 통계
stats = con.execute(f"""
SELECT
COUNT(*) AS total_rows,
COUNT(DISTINCT user_id) AS unique_users,
MIN(event_time) AS earliest_event,
MAX(event_time) AS latest_event,
COUNT(*) FILTER (WHERE user_id IS NULL) AS null_user_count,
COUNT(*) FILTER (WHERE amount < 0) AS negative_amount_count,
COUNT(*) FILTER (WHERE event_type NOT IN
('click', 'view', 'purchase', 'search')) AS unknown_event_count
FROM read_parquet('{parquet_path}')
""").fetchdf()
print("=== 기본 통계 ===")
print(stats.to_string(index=False))
# 중복 검사
duplicates = con.execute(f"""
SELECT
event_id,
COUNT(*) AS dup_count
FROM read_parquet('{parquet_path}')
GROUP BY event_id
HAVING COUNT(*) > 1
ORDER BY dup_count DESC
LIMIT 10
""").fetchdf()
if len(duplicates) > 0:
print(f"\n[경고] 중복 이벤트 발견: {len(duplicates)}건")
print(duplicates)
else:
print("\n[통과] 중복 이벤트 없음")
# 시간 연속성 검사 (빈 시간대 탐지)
gaps = con.execute(f"""
WITH hourly AS (
SELECT DATE_TRUNC('hour', event_time) AS hour, COUNT(*) AS cnt
FROM read_parquet('{parquet_path}')
GROUP BY DATE_TRUNC('hour', event_time)
),
expected_hours AS (
SELECT UNNEST(generate_series(
(SELECT MIN(hour) FROM hourly),
(SELECT MAX(hour) FROM hourly),
INTERVAL 1 HOUR
)) AS hour
)
SELECT e.hour AS missing_hour
FROM expected_hours e
LEFT JOIN hourly h ON e.hour = h.hour
WHERE h.hour IS NULL
ORDER BY e.hour
""").fetchdf()
if len(gaps) > 0:
print(f"\n[경고] 데이터 누락 시간대: {len(gaps)}건")
print(gaps.head(10))
else:
print("\n[통과] 시간 연속성 정상")
con.close()
validate_data_quality('events/year=2025/month=12/*.parquet')
12.3 BI 대시보드용 집계 뷰
import duckdb
def create_dashboard_views(db_path):
"""BI 대시보드용 사전 집계 뷰 생성"""
con = duckdb.connect(db_path)
# 일별 KPI 뷰
con.execute("""
CREATE OR REPLACE VIEW v_daily_kpi AS
SELECT
DATE_TRUNC('day', order_time) AS date,
COUNT(DISTINCT customer_id) AS dau,
COUNT(*) AS total_orders,
SUM(amount)::DECIMAL(15,2) AS gmv,
AVG(amount)::DECIMAL(10,2) AS aov,
COUNT(*) FILTER (WHERE is_first_order) AS new_customer_orders,
COUNT(*) FILTER (WHERE is_refunded) AS refund_count,
(COUNT(*) FILTER (WHERE is_refunded)::FLOAT /
NULLIF(COUNT(*), 0) * 100)::DECIMAL(5,2) AS refund_rate
FROM orders
GROUP BY DATE_TRUNC('day', order_time)
ORDER BY date
""")
# 코호트 분석 뷰
con.execute("""
CREATE OR REPLACE VIEW v_cohort_retention AS
WITH first_purchase AS (
SELECT
customer_id,
DATE_TRUNC('month', MIN(order_time)) AS cohort_month
FROM orders
GROUP BY customer_id
),
monthly_activity AS (
SELECT
customer_id,
DATE_TRUNC('month', order_time) AS activity_month
FROM orders
GROUP BY customer_id, DATE_TRUNC('month', order_time)
)
SELECT
fp.cohort_month,
DATEDIFF('month', fp.cohort_month, ma.activity_month) AS months_since_first,
COUNT(DISTINCT ma.customer_id) AS active_users,
COUNT(DISTINCT ma.customer_id)::FLOAT /
NULLIF(COUNT(DISTINCT fp.customer_id) FILTER (
WHERE DATEDIFF('month', fp.cohort_month, ma.activity_month) = 0
), 0) AS retention_rate
FROM first_purchase fp
JOIN monthly_activity ma ON fp.customer_id = ma.customer_id
GROUP BY fp.cohort_month,
DATEDIFF('month', fp.cohort_month, ma.activity_month)
ORDER BY fp.cohort_month, months_since_first
""")
# 퍼널 분석 뷰
con.execute("""
CREATE OR REPLACE VIEW v_conversion_funnel AS
WITH step1 AS (
SELECT DISTINCT user_id, DATE_TRUNC('day', event_time) AS day
FROM events WHERE event_type = 'page_view'
),
step2 AS (
SELECT DISTINCT user_id, DATE_TRUNC('day', event_time) AS day
FROM events WHERE event_type = 'add_to_cart'
),
step3 AS (
SELECT DISTINCT user_id, DATE_TRUNC('day', event_time) AS day
FROM events WHERE event_type = 'checkout'
),
step4 AS (
SELECT DISTINCT user_id, DATE_TRUNC('day', event_time) AS day
FROM events WHERE event_type = 'purchase'
)
SELECT
s1.day,
COUNT(DISTINCT s1.user_id) AS page_view_users,
COUNT(DISTINCT s2.user_id) AS add_to_cart_users,
COUNT(DISTINCT s3.user_id) AS checkout_users,
COUNT(DISTINCT s4.user_id) AS purchase_users,
(COUNT(DISTINCT s4.user_id)::FLOAT /
NULLIF(COUNT(DISTINCT s1.user_id), 0) * 100)::DECIMAL(5,2) AS overall_conversion_rate
FROM step1 s1
LEFT JOIN step2 s2 ON s1.user_id = s2.user_id AND s1.day = s2.day
LEFT JOIN step3 s3 ON s2.user_id = s3.user_id AND s2.day = s3.day
LEFT JOIN step4 s4 ON s3.user_id = s4.user_id AND s3.day = s4.day
GROUP BY s1.day
ORDER BY s1.day
""")
print("대시보드 뷰 생성 완료")
con.close()
create_dashboard_views('analytics.duckdb')
13. 운영 체크리스트
13.1 도입 전 체크리스트
- 데이터 규모가 단일 머신 메모리에 적합한가? (수 GB~수십 GB 권장)
- 동시 쓰기 요구사항이 없는가? (단일 쓰기만 지원)
- OLAP 워크로드인가? (집계, 스캔, 조인 중심)
- 고가용성(HA)이 필요하지 않은가? (단일 노드 한정)
- 데이터 포맷이 Parquet/CSV/JSON인가? (최적 지원)
13.2 운영 중 모니터링 항목
import duckdb
con = duckdb.connect('analytics.duckdb')
# 1. 메모리 사용량 모니터링
memory_info = con.execute("""
SELECT
tag,
(memory_usage_bytes / 1024 / 1024)::INTEGER AS usage_mb
FROM duckdb_memory()
WHERE memory_usage_bytes > 0
ORDER BY memory_usage_bytes DESC
""").fetchdf()
print("메모리 사용량:", memory_info.to_string(index=False))
# 2. 테이블 크기 확인
table_sizes = con.execute("""
SELECT
table_name,
estimated_size / 1024 / 1024 AS estimated_size_mb,
column_count,
index_count
FROM duckdb_tables()
ORDER BY estimated_size DESC
""").fetchdf()
print("테이블 크기:", table_sizes.to_string(index=False))
# 3. 실행 중인 쿼리 확인 (DuckDB 1.1+)
# DuckDB는 단일 프로세스이므로, 장시간 쿼리가 다른 작업을 차단할 수 있다
# enable_progress_bar로 진행 상황 확인 가능
# 4. 데이터베이스 파일 크기 확인
import os
db_size = os.path.getsize('analytics.duckdb') / (1024 * 1024)
print(f"DB 파일 크기: {db_size:.1f} MB")
con.close()
13.3 성능 최적화 체크리스트
-
SELECT *대신 필요한 컬럼만 명시했는가? -
memory_limit을 적절히 설정했는가? (시스템 메모리의 60-80%) -
temp_directory를 SSD 경로로 설정했는가? - Parquet 파일이 적절히 파티셔닝되어 있는가?
- 대규모 조인 시 작은 테이블을 먼저 필터링했는가?
-
EXPLAIN ANALYZE로 실행 계획을 확인했는가? - 근사 함수(
APPROX_COUNT_DISTINCT등)를 활용할 수 있는가? - 날짜 파티셔닝으로 불필요한 파일 스캔을 줄였는가?
14. 마치며
DuckDB는 "분석을 위해 반드시 분산 시스템이 필요한가?"라는 질문에 대한 실용적인 답이다. 벡터화 실행 엔진과 컬럼 기반 저장, 모젤 기반 병렬 처리의 조합으로, 단일 머신에서도 놀라운 분석 성능을 제공한다.
핵심 요약:
- 설치와 사용이 극도로 간단하다:
pip install duckdb한 줄이면 끝이다 - SQL로 모든 것을 쿼리한다: Parquet, CSV, JSON, 심지어 pandas DataFrame까지 SQL 하나로 분석할 수 있다
- 성능이 놀랍다: TPC-H 기준으로 SQLite보다 50-100배, pandas보다 3-10배 빠르다
- 제약을 이해해야 한다: 동시 쓰기 불가, 단일 노드 한정, OLTP 부적합
DuckDB는 만능 도구가 아니다. 하지만 로컬 분석, ETL 파이프라인, 데이터 품질 검증, 빠른 프로토타이핑에서는 현재 가장 효율적인 선택지다. 데이터 엔지니어와 분석가의 도구 상자에 반드시 들어있어야 할 도구라고 할 수 있다.
참고 자료
- DuckDB 공식 문서 - 설치, 기능, 익스텐션에 대한 포괄적 문서
- DuckDB: an Embeddable Analytical Database (SIGMOD 2019) - Raasveldt, Muehleisen의 원 논문, 벡터화 실행 엔진과 아키텍처 상세 설명
- DuckDB GitHub 저장소 - 소스 코드, 벤치마크, 이슈 트래커
- MotherDuck 블로그 - DuckDB 클라우드 서비스, 활용 사례, 성능 분석
- DuckDB 익스텐션 문서 - httpfs, spatial, postgres_scanner 등 익스텐션 상세 가이드