Skip to content
Published on

DuckDB 인메모리 분석 엔진 실전 가이드: OLAP 워크로드를 로컬에서 처리하기

Authors
  • Name
    Twitter

DuckDB 인메모리 분석 엔진

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 종합 비교표

항목DuckDBSQLiteClickHousePolarsPandas
설계 목적임베디드 OLAP임베디드 OLTP분산 OLAP 서버DataFrame 분석DataFrame 분석
저장 방식컬럼 기반행 기반컬럼 기반컬럼 기반컬럼 기반
실행 모델벡터화행 단위벡터화벡터화행/블록 혼합
쿼리 언어SQL (PostgreSQL 호환)SQL (독자 방언)SQL (독자 방언)Python API/SQLPython API
동시성단일 쓰기/다중 읽기단일 쓰기/다중 읽기다중 쓰기/읽기해당 없음해당 없음
서버 필요불필요 (인프로세스)불필요 (인프로세스)필요 (서버 프로세스)불필요 (라이브러리)불필요 (라이브러리)
10GB CSV 집계약 3초약 60초 이상약 1초약 5초약 30초 (OOM 위험)
메모리 효율높음 (아웃오브코어)보통매우 높음높음낮음
확장성단일 노드단일 노드수평 확장 (클러스터)단일 노드단일 노드
설치 난이도매우 쉬움내장중간 (서버 설정)쉬움매우 쉬움
Parquet 지원네이티브미지원네이티브네이티브pyarrow 필요
적합한 데이터 규모MB~수십 GBKB~수 GBGB~PBMB~수십 GBMB~수 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.428.512.3단순 집계, 전체 스캔
Q3 (배송 우선순위)0.845.2OOM조인 + 집계
Q6 (매출 변동)0.115.85.2필터 + 집계
Q9 (제품 수익성)2.1120+OOM복잡한 다중 조인
Q18 (대량 주문 고객)1.590+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 주요 익스텐션 목록

익스텐션용도설치 명령
httpfsS3/HTTP 원격 파일 접근INSTALL httpfs
jsonJSON 처리 강화기본 내장
parquetParquet 읽기/쓰기기본 내장
icu국제화/정렬/로케일INSTALL icu
fts전문 검색(Full-Text Search)INSTALL fts
spatial공간 데이터 처리 (PostGIS 유사)INSTALL spatial
postgres_scannerPostgreSQL 테이블 직접 쿼리INSTALL postgres_scanner
mysql_scannerMySQL 테이블 직접 쿼리INSTALL mysql_scanner
sqlite_scannerSQLite 파일 직접 쿼리INSTALL sqlite_scanner
excelExcel 파일 읽기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는 "분석을 위해 반드시 분산 시스템이 필요한가?"라는 질문에 대한 실용적인 답이다. 벡터화 실행 엔진과 컬럼 기반 저장, 모젤 기반 병렬 처리의 조합으로, 단일 머신에서도 놀라운 분석 성능을 제공한다.

핵심 요약:

  1. 설치와 사용이 극도로 간단하다: pip install duckdb 한 줄이면 끝이다
  2. SQL로 모든 것을 쿼리한다: Parquet, CSV, JSON, 심지어 pandas DataFrame까지 SQL 하나로 분석할 수 있다
  3. 성능이 놀랍다: TPC-H 기준으로 SQLite보다 50-100배, pandas보다 3-10배 빠르다
  4. 제약을 이해해야 한다: 동시 쓰기 불가, 단일 노드 한정, OLTP 부적합

DuckDB는 만능 도구가 아니다. 하지만 로컬 분석, ETL 파이프라인, 데이터 품질 검증, 빠른 프로토타이핑에서는 현재 가장 효율적인 선택지다. 데이터 엔지니어와 분석가의 도구 상자에 반드시 들어있어야 할 도구라고 할 수 있다.

참고 자료