Skip to content
Published on

DuckDB 분석 엔진 실전 가이드: 로컬 OLAP부터 데이터 레이크 통합까지

Authors
  • Name
    Twitter
DuckDB Analytics

1. 들어가며

데이터 분석 환경이 급격히 변하고 있다. 과거에는 분석 쿼리 하나를 실행하기 위해 Spark 클러스터를 띄우거나, BigQuery에 데이터를 올리거나, Redshift 인스턴스를 프로비저닝해야 했다. 하지만 노트북 한 대의 RAM이 32GB를 넘고, SSD 속도가 3GB/s에 달하는 시대에 "분석을 위해 반드시 분산 시스템이 필요한가?"라는 질문이 제기되기 시작했다.

DuckDB는 이 질문에 대한 가장 설득력 있는 답이다. 네덜란드 CWI(Centrum Wiskunde and Informatica)에서 Mark Raasveldt와 Hannes Muehleisen이 시작한 이 프로젝트는, SQLite의 임베디드 철학을 OLAP 세계에 적용한 인프로세스(in-process) 분석 데이터베이스다. 별도의 서버 프로세스 없이, pip install duckdb 한 줄로 수억 행의 Parquet 파일을 서브초 만에 집계할 수 있다.

DuckDB를 선택해야 하는 시나리오는 다음과 같다.

  • 로컬 머신에서 수 GB~수십 GB 규모의 분석 쿼리를 빠르게 돌리고 싶은 경우
  • Parquet, CSV, JSON 파일을 ETL 파이프라인 없이 바로 SQL로 쿼리하고 싶은 경우
  • Pandas DataFrame의 성능 한계에 부딪혀 대안을 찾는 경우
  • S3 데이터 레이크의 파일을 서버 없이 직접 분석하고 싶은 경우
  • CI/CD 파이프라인에서 데이터 품질 검증용 분석 쿼리가 필요한 경우

반면 DuckDB가 적합하지 않은 경우도 명확하다. 동시 다발적인 쓰기가 필요한 OLTP 워크로드, 수백 명이 동시에 접속하는 서비스 백엔드, 페타바이트 이상의 분산 처리가 필요한 경우에는 PostgreSQL, ClickHouse, Spark 같은 도구가 더 적합하다.

2. DuckDB 아키텍처 이해

DuckDB의 놀라운 성능은 세 가지 핵심 설계 원칙의 시너지에서 나온다. 컬럼 지향 스토리지, 벡터화 실행 엔진, 그리고 모젤 기반 병렬 처리(Morsel-Driven Parallelism)다.

인프로세스(In-Process) 아키텍처

DuckDB는 SQLite처럼 별도의 서버 데몬 없이 애플리케이션 프로세스 내부에서 직접 실행된다. 네트워크 통신 오버헤드가 전혀 없으며, 런타임 의존성도 없다. Python, R, Java, Node.js 등 다양한 언어에서 라이브러리 형태로 로드된다.

컬럼 지향 스토리지

전통적인 행 지향(row-oriented) 데이터베이스는 하나의 행에 속한 모든 컬럼 값을 연속된 블록에 저장한다. 분석 쿼리가 전체 컬럼 중 일부만 필요로 할 때 나머지 데이터도 함께 읽어야 하므로 I/O 낭비가 크다.

DuckDB는 컬럼 단위로 데이터를 저장하여, 쿼리에 필요한 컬럼만 선택적으로 스캔한다. 동일 타입의 값이 연속 배치되므로 압축률도 극적으로 높아진다.

지향 (SQLite/PostgreSQL):
┌──────┬─────────┬─────────┬──────────┐
│ id   │ name    │ country │ revenue  │  --1의 모든 컬럼
├──────┼─────────┼─────────┼──────────┤
│ id   │ name    │ country │ revenue  │  --2의 모든 컬럼
└──────┴─────────┴─────────┴──────────┘
SELECT country, SUM(revenue) 시에도
    id, name 컬럼 데이터를 불필요하게 읽음

컬럼 지향 (DuckDB):
┌────────────────────────┐
│ id:      1, 2, 3, ...-- 컬럼 블록 1 (스킵)
├────────────────────────┤
│ name:    A, B, C, ...-- 컬럼 블록 2 (스킵)
├────────────────────────┤
│ country: KR, US, ...-- 컬럼 블록 3 (스캔!)
├────────────────────────┤
│ revenue: 100, 200, ...-- 컬럼 블록 4 (스캔!)
└────────────────────────┘
  ↑ 필요한 컬럼만 디스크에서 읽음
    CPU 캐시 효율 극대화

3. 벡터화 실행 엔진

DuckDB의 성능을 결정짓는 핵심 메커니즘은 벡터화 실행 엔진(Vectorized Execution Engine)이다. 전통적인 Volcano(iterator) 모델과의 차이를 이해하면 DuckDB가 왜 빠른지 명확히 알 수 있다.

Volcano 모델 vs 벡터화 모델

전통적인 Volcano 모델에서는 쿼리 트리의 각 연산자가 한 번에 한 행씩 상위 연산자에게 전달한다. 행 하나를 처리할 때마다 가상 함수 호출과 분기 예측 실패가 발생하여, 실제 연산보다 함수 호출 오버헤드가 더 커지는 문제가 있다.

벡터화 모델은 한 번에 1,024~2,048개의 값(벡터)을 묶어서 처리한다. 함수 호출 오버헤드가 벡터 크기만큼 분산(amortize)되고, CPU의 SIMD 명령어와 캐시 라인을 최대한 활용할 수 있다.

┌─────────────────────────────────────────────────────┐
DuckDB 벡터화 실행 엔진                  │
├─────────────────────────────────────────────────────┤
│                                                     │
│  ┌─────────────┐                                    │
│  │  SQL ParserSQL 텍스트 → AST│  └──────┬──────┘                                    │
│         ▼                                           │
│  ┌─────────────┐                                    │
│  │  Binder     │  테이블/컬럼 이름 해석              │
│  └──────┬──────┘                                    │
│         ▼                                           │
│  ┌─────────────────┐                                │
│  │ Query Optimizer │  조인 순서, 필터 푸시다운        │
  (Cost-based)   │  서브쿼리 플래트닝              │
│  └──────┬──────────┘                                │
│         ▼                                           │
│  ┌─────────────────────────────────────┐            │
│  │    Physical Plan (Pipeline-based)   │            │
│  │  ┌──────┐  ┌──────┐  ┌──────────┐  │            │
│  │  │Scan  │→ │Filter│→ │HashAggr  │  │            │
│  │  (Vec)(Vec)  (Vec)   │  │            │
│  │  └──────┘  └──────┘  └──────────┘  │            │
│  │     ↑ 벡터(1024) 단위 데이터 전달  │            │
│  └─────────────────────────────────────┘            │
│         ▼                                           │
│  ┌─────────────────────────────────────┐            │
│  │  Morsel-Driven Parallelism          │            │
│  │  ┌────────┐ ┌────────┐ ┌────────┐  │            │
│  │  │Thread 1│ │Thread 2│ │Thread N│  │            │
│  │  │Morsel A│ │Morsel B│ │Morsel C│  │            │
│  │  └────────┘ └────────┘ └────────┘  │            │
│  │  각 스레드가 독립적 데이터 청크 처리  │            │
│  └─────────────────────────────────────┘            │
│         ▼                                           │
│  ┌─────────────┐                                    │
│  │   Result    │  벡터 → DataFrame / Arrow Table│  └─────────────┘                                    │
└─────────────────────────────────────────────────────┘

모젤 기반 병렬 처리(Morsel-Driven Parallelism)

DuckDB는 테이블 데이터를 "모젤(Morsel)"이라는 청크(약 10만 행 단위)로 분할한다. 각 워커 스레드가 독립적으로 하나의 모젤을 가져가 처리하고, 처리가 끝나면 다음 모젤을 가져간다. 이 work-stealing 방식은 스레드 간 부하를 자연스럽게 균형화하며, 기존 파티션 기반 병렬 처리보다 훨씬 유연하다.

4. 설치와 환경 구성

DuckDB의 가장 큰 장점 중 하나는 설치의 간편함이다. 외부 의존성이 전혀 없으며, 플랫폼별로 바이너리가 제공된다.

CLI 설치

# macOS (Homebrew)
brew install duckdb

# Linux (apt)
sudo apt-get install duckdb
# 또는 직접 바이너리 다운로드
wget https://github.com/duckdb/duckdb/releases/latest/download/duckdb_cli-linux-amd64.zip
unzip duckdb_cli-linux-amd64.zip
chmod +x duckdb
sudo mv duckdb /usr/local/bin/

# 버전 확인
duckdb --version

Python 패키지 설치

# pip
pip install duckdb

# conda
conda install -c conda-forge python-duckdb

# 특정 버전 설치 (예: 1.2.x)
pip install duckdb==1.2.1

기본 사용법 확인

-- DuckDB CLI 실행
-- 인메모리 모드 (데이터는 세션 종료 시 삭제)
duckdb

-- 파일 기반 영구 저장 모드
duckdb my_analytics.db

-- 기본 쿼리 테스트
SELECT version();
-- v1.2.1

-- 시스템 정보 확인
SELECT * FROM duckdb_settings()
WHERE name IN ('threads', 'memory_limit', 'temp_directory');

5. SQL 분석 쿼리 실전

DuckDB는 PostgreSQL 호환 SQL을 지원하며, 분석에 특화된 다양한 SQL 확장 기능을 제공한다.

파일 직접 쿼리

DuckDB의 가장 강력한 기능 중 하나는 CREATE TABLE 없이 파일을 바로 쿼리할 수 있다는 것이다.

-- CSV 파일 직접 쿼리
SELECT
    product_category,
    COUNT(*) AS order_count,
    ROUND(AVG(amount), 2) AS avg_amount,
    SUM(amount) AS total_revenue
FROM read_csv('sales_2025.csv', auto_detect=true)
WHERE order_date >= '2025-01-01'
GROUP BY product_category
ORDER BY total_revenue DESC
LIMIT 10;

-- 여러 CSV 파일을 글로브 패턴으로 한번에 쿼리
SELECT
    filename,
    COUNT(*) AS row_count
FROM read_csv('logs/access_*.csv', auto_detect=true, filename=true)
GROUP BY filename
ORDER BY row_count DESC;

-- JSON 파일 쿼리
SELECT
    json_extract_string(data, '$.user.name') AS user_name,
    json_extract(data, '$.events') AS events
FROM read_json('user_activity.json', auto_detect=true);

-- Parquet 파일 쿼리 (가장 빠른 포맷)
SELECT
    region,
    YEAR(event_timestamp) AS event_year,
    COUNT(*) AS event_count
FROM read_parquet('events/*.parquet')
GROUP BY region, event_year
ORDER BY event_count DESC;

Window 함수 활용

-- 매출 데이터에서 이동 평균과 순위 계산
WITH daily_sales AS (
    SELECT
        sale_date,
        product_id,
        SUM(amount) AS daily_amount
    FROM read_parquet('sales_data.parquet')
    GROUP BY sale_date, product_id
)
SELECT
    sale_date,
    product_id,
    daily_amount,
    -- 7일 이동 평균
    ROUND(AVG(daily_amount) OVER (
        PARTITION BY product_id
        ORDER BY sale_date
        ROWS BETWEEN 6 PRECEDING AND CURRENT ROW
    ), 2) AS moving_avg_7d,
    -- 제품별 매출 순위 (일별)
    RANK() OVER (
        PARTITION BY sale_date
        ORDER BY daily_amount DESC
    ) AS daily_rank,
    -- 전일 대비 증감률
    ROUND(
        (daily_amount - LAG(daily_amount) OVER (
            PARTITION BY product_id ORDER BY sale_date
        )) * 100.0 / NULLIF(LAG(daily_amount) OVER (
            PARTITION BY product_id ORDER BY sale_date
        ), 0), 2
    ) AS pct_change
FROM daily_sales
ORDER BY sale_date DESC, daily_rank
LIMIT 20;

PIVOT과 UNPIVOT

-- 월별 카테고리별 매출 피벗 테이블
PIVOT (
    SELECT
        MONTHNAME(order_date) AS month,
        category,
        SUM(revenue) AS total
    FROM read_parquet('orders.parquet')
    WHERE YEAR(order_date) = 2025
    GROUP BY month, category
)
ON category
USING SUM(total)
GROUP BY month
ORDER BY month;

6. Python 통합

DuckDB의 Python 통합은 단순한 DB 드라이버를 넘어, Pandas와 Polars DataFrame을 직접 SQL로 쿼리하는 독창적인 기능을 제공한다.

Pandas DataFrame 직접 쿼리

import duckdb
import pandas as pd

# Pandas DataFrame 생성
sales_df = pd.DataFrame({
    'product': ['A', 'B', 'C', 'A', 'B', 'C'] * 1000,
    'region': ['Seoul', 'Busan', 'Seoul', 'Busan', 'Seoul', 'Busan'] * 1000,
    'amount': [100, 200, 150, 300, 250, 180] * 1000,
    'date': pd.date_range('2025-01-01', periods=6000, freq='h')
})

# DataFrame을 바로 SQL로 쿼리 (변수명이 테이블명)
result = duckdb.sql("""
    SELECT
        product,
        region,
        COUNT(*) AS order_count,
        ROUND(AVG(amount), 2) AS avg_amount,
        SUM(amount) AS total_revenue,
        MIN(date) AS first_order,
        MAX(date) AS last_order
    FROM sales_df
    GROUP BY product, region
    ORDER BY total_revenue DESC
""").df()  # 결과를 다시 Pandas DataFrame으로 반환

print(result)

# 대용량 Parquet 파일과 DataFrame 조인
products_df = pd.DataFrame({
    'product': ['A', 'B', 'C'],
    'category': ['Electronics', 'Clothing', 'Food'],
    'margin_pct': [0.15, 0.40, 0.25]
})

enriched = duckdb.sql("""
    SELECT
        s.product,
        p.category,
        SUM(s.amount) AS revenue,
        ROUND(SUM(s.amount) * p.margin_pct, 2) AS estimated_profit
    FROM sales_df s
    JOIN products_df p ON s.product = p.product
    GROUP BY s.product, p.category, p.margin_pct
    ORDER BY estimated_profit DESC
""").df()

print(enriched)

영구 데이터베이스와 Relation API

import duckdb

# 영구 데이터베이스 연결
con = duckdb.connect('analytics.db')

# 테이블 생성 및 Parquet에서 데이터 로드
con.execute("""
    CREATE TABLE IF NOT EXISTS events AS
    SELECT * FROM read_parquet('raw_events/*.parquet');
""")

# Relation API (메서드 체이닝)
result = (
    con.table('events')
    .filter("event_type = 'purchase'")
    .aggregate("product_id, COUNT(*) AS cnt, SUM(amount) AS total")
    .order("total DESC")
    .limit(10)
    .df()
)

# 결과를 Parquet로 내보내기
con.execute("""
    COPY (
        SELECT product_id, COUNT(*) AS cnt, SUM(amount) AS total
        FROM events
        WHERE event_type = 'purchase'
        GROUP BY product_id
        ORDER BY total DESC
    ) TO 'top_products.parquet' (FORMAT PARQUET, COMPRESSION ZSTD);
""")

con.close()

Apache Arrow 연동

import duckdb
import pyarrow as pa
import pyarrow.parquet as pq

# Arrow Table을 직접 쿼리
arrow_table = pq.read_table('large_dataset.parquet')

result = duckdb.sql("""
    SELECT
        category,
        COUNT(*) AS cnt,
        APPROX_QUANTILE(value, 0.95) AS p95
    FROM arrow_table
    GROUP BY category
""").arrow()  # 결과를 Arrow Table로 반환 (제로카피)

# Arrow의 제로카피 전달로 메모리 효율 극대화
print(f"Result rows: {result.num_rows}")

7. 데이터 레이크 통합 (Parquet, S3, Delta Lake)

DuckDB가 데이터 엔지니어링 세계에서 폭발적으로 성장한 핵심 이유 중 하나는, 복잡한 인프라 없이 데이터 레이크를 직접 쿼리할 수 있다는 점이다.

S3 Parquet 파일 직접 쿼리

-- httpfs 확장 설치 및 로드
INSTALL httpfs;
LOAD httpfs;

-- AWS S3 인증 설정 (Secrets 방식 - 권장)
CREATE SECRET my_s3_secret (
    TYPE S3,
    KEY_ID 'AKIAIOSFODNN7EXAMPLE',
    SECRET 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY',
    REGION 'ap-northeast-2'
);

-- S3의 Parquet 파일 직접 쿼리
SELECT
    event_date,
    user_segment,
    COUNT(DISTINCT user_id) AS dau,
    COUNT(*) AS total_events,
    ROUND(AVG(session_duration_sec), 1) AS avg_session_sec
FROM read_parquet('s3://my-data-lake/events/year=2025/month=12/*.parquet')
GROUP BY event_date, user_segment
ORDER BY event_date DESC, dau DESC;

-- 하이브 파티셔닝된 데이터 쿼리 (파티션 프루닝 자동 적용)
SELECT *
FROM read_parquet(
    's3://my-data-lake/events/**/*.parquet',
    hive_partitioning=true
)
WHERE year = 2025 AND month = 12
  AND event_type = 'purchase';

-- 쿼리 결과를 S3에 Parquet로 다시 저장
COPY (
    SELECT
        date_trunc('hour', event_timestamp) AS hour,
        COUNT(*) AS event_count,
        COUNT(DISTINCT user_id) AS unique_users
    FROM read_parquet('s3://my-data-lake/raw_events/**/*.parquet',
                      hive_partitioning=true)
    WHERE year = 2025
    GROUP BY hour
)
TO 's3://my-data-lake/aggregated/hourly_summary.parquet'
(FORMAT PARQUET, COMPRESSION ZSTD);

Delta Lake 읽기

-- Delta Lake 확장 설치
INSTALL delta;
LOAD delta;

-- Delta Lake 테이블 읽기
SELECT *
FROM delta_scan('s3://my-data-lake/delta_tables/user_events/')
WHERE event_date >= '2025-12-01'
LIMIT 100;

GCS(Google Cloud Storage) 연동

-- GCS 인증 설정
CREATE SECRET gcs_secret (
    TYPE GCS,
    KEY_ID 'my-gcs-key-id',
    SECRET 'my-gcs-secret'
);

-- GCS Parquet 파일 쿼리
SELECT COUNT(*), SUM(revenue)
FROM read_parquet('gs://my-bucket/analytics/sales_*.parquet');

8. 분석 도구 비교

DuckDB의 위치를 정확히 이해하려면 경쟁 도구들과의 비교가 필수적이다.

비교 항목DuckDBSQLiteClickHouseBigQuery
아키텍처인프로세스, 컬럼 지향인프로세스, 행 지향클라이언트-서버, 컬럼 지향서버리스, 컬럼 지향
최적 워크로드로컬/임베디드 OLAP로컬/임베디드 OLTP분산 실시간 OLAP대규모 클라우드 분석
설치 난이도pip install 한 줄OS 기본 탑재서버 설치 필요설치 불필요 (SaaS)
동시 사용자단일~소수단일~소수수백~수천수천+
최대 데이터 규모수십 GB~수백 GB (싱글 노드)수 GBTB~PB (클러스터)PB+
쓰기 동시성단일 Writer단일 Writer (WAL 모드 시 Reader 병행)다중 Writer다중 Writer
Parquet 네이티브 지원내장미지원MATERIALIZED VIEW 통해 가능네이티브
S3 직접 쿼리httpfs 확장미지원s3 테이블 함수네이티브 (External Table)
비용 모델무료 (오픈소스, MIT)무료 (퍼블릭 도메인)무료 (오픈소스) / 클라우드 유료쿼리 과금 ($5/TB 스캔)
Python 통합DataFrame 직접 SQL 쿼리DB-API 기본clickhouse-drivergoogle-cloud-bigquery
실시간 수집부적합부적합최적화 (MergeTree)Streaming Insert
트랜잭션ACID (싱글 Writer)ACID제한적 ACID완전 ACID

사용 시나리오별 추천

  • 노트북에서 Parquet/CSV 분석 → DuckDB
  • 모바일 앱의 로컬 데이터 저장 → SQLite
  • 수십억 행 실시간 대시보드 → ClickHouse
  • 팀 전체가 공유하는 데이터 웨어하우스 → BigQuery
  • 개발/테스트 환경에서 빠른 프로토타이핑 → DuckDB
  • CI/CD 파이프라인의 데이터 품질 검증 → DuckDB

9. 확장 기능(Extensions) 활용

DuckDB는 핵심 기능을 가볍게 유지하면서, 확장(Extension)을 통해 필요한 기능을 선택적으로 추가하는 설계를 따른다.

주요 확장 목록

-- 설치된 확장 확인
SELECT * FROM duckdb_extensions() WHERE installed = true;

-- 핵심 확장 설치
INSTALL httpfs;    -- S3/HTTP 원격 파일 접근
INSTALL json;      -- JSON 파싱 (자동 로드)
INSTALL parquet;   -- Parquet 지원 (자동 로드)
INSTALL spatial;   -- 지리공간 데이터 처리 (ST_* 함수)
INSTALL icu;       -- 국제화 정렬/비교
INSTALL fts;       -- Full-Text Search (전문 검색)
INSTALL delta;     -- Delta Lake 테이블 읽기
INSTALL excel;     -- Excel 파일 읽기/쓰기
INSTALL sqlite;    -- SQLite 데이터베이스 직접 읽기
INSTALL postgres;  -- PostgreSQL 직접 쿼리 (FDW)
INSTALL mysql;     -- MySQL 직접 쿼리

-- 확장 로드
LOAD httpfs;
LOAD spatial;

Spatial 확장 실전 예제

INSTALL spatial;
LOAD spatial;

-- 서울 지역 매장 반경 1km 이내 주문 분석
SELECT
    store_name,
    COUNT(*) AS nearby_orders,
    SUM(order_amount) AS nearby_revenue
FROM orders o
JOIN stores s ON o.store_id = s.id
WHERE ST_DWithin(
    ST_Point(o.longitude, o.latitude),
    ST_Point(s.longitude, s.latitude),
    0.01  -- 약 1km (WGS84 근사)
)
GROUP BY store_name
ORDER BY nearby_revenue DESC;

PostgreSQL 직접 쿼리 (postgres 확장)

INSTALL postgres;
LOAD postgres;

-- PostgreSQL에 직접 연결하여 쿼리
ATTACH 'dbname=mydb user=analyst host=pg-server.internal' AS pg (TYPE POSTGRES);

-- PostgreSQL 테이블과 로컬 Parquet 파일 조인
SELECT
    p.customer_id,
    p.customer_name,
    l.total_purchases,
    l.last_purchase_date
FROM pg.public.customers p
JOIN (
    SELECT
        customer_id,
        COUNT(*) AS total_purchases,
        MAX(purchase_date) AS last_purchase_date
    FROM read_parquet('s3://data-lake/purchases/**/*.parquet')
    GROUP BY customer_id
) l ON p.customer_id = l.customer_id
WHERE l.total_purchases > 100
ORDER BY l.total_purchases DESC;

10. MotherDuck: 클라우드 DuckDB

MotherDuck는 DuckDB의 공동 창시자가 설립한 회사에서 운영하는 서버리스 클라우드 분석 서비스다. DuckDB의 강점을 유지하면서 클라우드의 확장성과 협업 기능을 추가한다.

핵심 특징

  • 하이브리드 쿼리 실행: 쿼리의 일부는 로컬(클라이언트)에서, 일부는 클라우드에서 실행하여 네트워크 전송을 최소화
  • 서버리스 인프라: Duckling이라는 인스턴스 크기(pulse, standard, jumbo, mega, giga)를 워크로드에 맞게 선택
  • 데이터 공유: 팀 멤버 간 데이터베이스를 URL로 공유
  • 영구 스토리지: 데이터를 클라우드에 안전하게 저장하고 자동 최적화
  • MCP 서버 연동: Warp, JetBrains IDE 등 다양한 개발 도구와 통합

MotherDuck 연결

import duckdb

# MotherDuck 토큰으로 연결
con = duckdb.connect('md:my_database?motherduck_token=eyJhb...')

# 로컬 데이터와 클라우드 데이터를 하이브리드 쿼리
con.execute("""
    SELECT
        cloud_table.user_id,
        local_file.purchase_amount
    FROM my_database.users AS cloud_table
    JOIN read_parquet('local_data/purchases.parquet') AS local_file
        ON cloud_table.user_id = local_file.user_id
    WHERE cloud_table.signup_date >= '2025-01-01'
""")

result = con.fetchdf()
print(result)

11. 성능 최적화 팁

DuckDB는 기본 설정에서도 우수한 성능을 제공하지만, 워크로드 특성에 맞게 튜닝하면 추가적인 성능 향상을 얻을 수 있다.

메모리와 스레드 설정

-- 현재 설정 확인
SELECT name, value, description
FROM duckdb_settings()
WHERE name IN ('memory_limit', 'threads', 'temp_directory');

-- 메모리 한도 설정 (기본: 시스템 RAM의 약 80%)
SET memory_limit = '16GB';

-- 스레드 수 조정 (기본: CPU 코어 수)
-- 다른 작업과 병행 시 줄여서 사용
SET threads = 4;

-- 디스크 스필 디렉토리 설정 (메모리 초과 시 사용)
SET temp_directory = '/fast-ssd/duckdb_temp';

쿼리 최적화 원칙

-- 1. EXPLAIN으로 쿼리 플랜 확인
EXPLAIN
SELECT region, SUM(amount) AS total
FROM read_parquet('large_sales.parquet')
WHERE sale_date >= '2025-01-01'
GROUP BY region;

-- 2. EXPLAIN ANALYZE로 실제 실행 프로파일링
EXPLAIN ANALYZE
SELECT region, SUM(amount) AS total
FROM read_parquet('large_sales.parquet')
WHERE sale_date >= '2025-01-01'
GROUP BY region;

-- 3. Parquet 필터 푸시다운 활용
--    WHERE 조건이 Parquet 파일의 Row Group 통계에 반영되어
--    불필요한 데이터를 읽지 않음
SELECT COUNT(*)
FROM read_parquet('partitioned_data/**/*.parquet',
                  hive_partitioning=true)
WHERE year = 2025 AND month = 12;
-- → year != 2025인 파일은 아예 열지 않음 (파티션 프루닝)

-- 4. 적절한 데이터 타입 사용
-- VARCHAR 대신 ENUM으로 카디널리티 낮은 컬럼 최적화
CREATE TYPE status_enum AS ENUM ('active', 'inactive', 'pending');

-- 5. 커넥션 재사용
-- 빈번한 connect/disconnect는 캐시 손실을 초래
-- 하나의 연결을 유지하며 쿼리 실행

Parquet 파일 최적화

-- 최적화된 Parquet 파일 생성
-- ZSTD 압축 + 적절한 Row Group 크기
COPY (
    SELECT * FROM large_table
    ORDER BY partition_key, timestamp  -- 정렬하여 압축률 극대화
) TO 'optimized_output.parquet'
(FORMAT PARQUET,
 COMPRESSION ZSTD,
 ROW_GROUP_SIZE 122880);  -- 약 120K 행/그룹

-- 파티셔닝된 Parquet 내보내기
COPY (SELECT * FROM events)
TO 'output_dir' (FORMAT PARQUET,
                  PARTITION_BY (year, month),
                  COMPRESSION ZSTD);

12. 트러블슈팅

흔한 오류와 해결법

메모리 부족 오류(Out of Memory)는 가장 빈번하게 발생하는 문제다.

-- 증상: "Out of Memory Error: could not allocate block of size ..."

-- 해결 1: 메모리 한도 늘리기
SET memory_limit = '32GB';

-- 해결 2: 디스크 스필 활성화
SET temp_directory = '/tmp/duckdb_spill';

-- 해결 3: 쿼리를 청크 단위로 분할
-- 전체 테이블 집계 대신 파티션별로 처리
CREATE TABLE monthly_agg AS
SELECT * FROM (
    SELECT month, SUM(amount) AS total
    FROM read_parquet('data/year=2025/month=01/*.parquet')
    GROUP BY month
    UNION ALL
    SELECT month, SUM(amount) AS total
    FROM read_parquet('data/year=2025/month=02/*.parquet')
    GROUP BY month
    -- ... 반복
);

S3 접속 오류도 자주 발생한다.

-- 증상: "HTTP Error: Unable to connect to URL"

-- 해결 1: 인증 정보 확인
SELECT * FROM duckdb_secrets();

-- 해결 2: 리전 명시
CREATE OR REPLACE SECRET (
    TYPE S3,
    REGION 'ap-northeast-2',
    KEY_ID 'AKIA...',
    SECRET '...'
);

-- 해결 3: 엔드포인트 커스텀 (MinIO, R2 등)
CREATE SECRET (
    TYPE S3,
    KEY_ID 'minioadmin',
    SECRET 'minioadmin',
    ENDPOINT 'localhost:9000',
    USE_SSL false,
    URL_STYLE 'path'
);

파일 잠금 충돌은 멀티프로세스 환경에서 발생한다.

-- 증상: "Could not set lock on file ... database is locked"

-- 원인: 다른 프로세스가 동일 DB 파일을 열고 있음
-- DuckDB는 단일 Writer만 허용

-- 해결 1: access_mode를 read_only로 설정 (읽기만 필요한 경우)
-- Python에서:
-- con = duckdb.connect('my.db', read_only=True)

-- 해결 2: 인메모리 모드에서 Parquet 직접 쿼리
-- DB 파일 잠금 자체를 회피
-- con = duckdb.connect()  -- 인메모리
-- con.sql("SELECT * FROM read_parquet('data.parquet')")

13. 프로덕션 운영 패턴

DuckDB를 프로덕션에서 안전하게 활용하기 위한 패턴들이다.

패턴 1: ETL 파이프라인의 변환 계층

import duckdb
from datetime import datetime

def daily_aggregation_job():
    """DuckDB를 ETL의 T(Transform) 단계로 활용"""
    con = duckdb.connect()

    today = datetime.now().strftime('%Y-%m-%d')

    # S3에서 원본 데이터 읽기 → 변환 → S3에 결과 저장
    con.execute(f"""
        INSTALL httpfs; LOAD httpfs;
        CREATE SECRET (TYPE S3, REGION 'ap-northeast-2');

        COPY (
            SELECT
                date_trunc('hour', event_ts) AS hour,
                event_type,
                COUNT(*) AS cnt,
                COUNT(DISTINCT user_id) AS unique_users,
                APPROX_COUNT_DISTINCT(session_id) AS approx_sessions,
                PERCENTILE_CONT(0.50) WITHIN GROUP
                    (ORDER BY duration_ms) AS p50_duration,
                PERCENTILE_CONT(0.99) WITHIN GROUP
                    (ORDER BY duration_ms) AS p99_duration
            FROM read_parquet(
                's3://raw-data/events/dt={today}/**/*.parquet'
            )
            GROUP BY hour, event_type
        )
        TO 's3://processed-data/hourly_agg/dt={today}/result.parquet'
        (FORMAT PARQUET, COMPRESSION ZSTD);
    """)

    con.close()
    print(f"Daily aggregation completed for {today}")

패턴 2: CI/CD 데이터 품질 검증

import duckdb
import sys

def validate_data_quality(parquet_path: str) -> bool:
    """CI/CD 파이프라인에서 데이터 품질을 자동 검증"""
    con = duckdb.connect()
    checks_passed = True

    # 검증 1: 행 수 최소 기준
    row_count = con.execute(f"""
        SELECT COUNT(*) FROM read_parquet('{parquet_path}')
    """).fetchone()[0]

    if row_count < 1000:
        print(f"FAIL: Row count {row_count} below minimum 1000")
        checks_passed = False

    # 검증 2: NULL 비율 체크
    null_check = con.execute(f"""
        SELECT
            ROUND(COUNT(*) FILTER (WHERE user_id IS NULL)
                  * 100.0 / COUNT(*), 2) AS null_pct
        FROM read_parquet('{parquet_path}')
    """).fetchone()[0]

    if null_check > 5.0:
        print(f"FAIL: user_id NULL ratio {null_check}% exceeds 5%")
        checks_passed = False

    # 검증 3: 값 범위 체크
    range_check = con.execute(f"""
        SELECT
            MIN(amount) AS min_amt,
            MAX(amount) AS max_amt
        FROM read_parquet('{parquet_path}')
    """).fetchone()

    if range_check[0] < 0:
        print(f"FAIL: Negative amount detected: {range_check[0]}")
        checks_passed = False

    con.close()
    return checks_passed

if __name__ == '__main__':
    path = sys.argv[1]
    if not validate_data_quality(path):
        sys.exit(1)
    print("All data quality checks passed")

패턴 3: 마이크로서비스 내 임베디드 분석

DuckDB를 API 서버 내부에 임베디드하여, 외부 분석 서비스 호출 없이 빠른 집계 응답을 제공할 수 있다.

from fastapi import FastAPI
import duckdb

app = FastAPI()

# 애플리케이션 시작 시 한번만 연결 (커넥션 재사용)
analytics_con = duckdb.connect('analytics_cache.db', read_only=True)

@app.get("/api/dashboard/summary")
async def dashboard_summary(date_from: str, date_to: str):
    result = analytics_con.execute("""
        SELECT
            product_category,
            COUNT(*) AS orders,
            ROUND(SUM(revenue), 2) AS total_revenue,
            ROUND(AVG(revenue), 2) AS avg_order_value
        FROM sales
        WHERE order_date BETWEEN ? AND ?
        GROUP BY product_category
        ORDER BY total_revenue DESC
    """, [date_from, date_to]).df()

    return result.to_dict(orient='records')

14. 실패 사례와 대응

DuckDB를 실무에 도입하면서 흔히 겪는 실패 사례와 그 대응 방법을 정리한다.

실패 1: OLTP 워크로드에 DuckDB 적용

증상: 사용자 인증, 주문 처리 등 행 단위 읽기/쓰기가 빈번한 서비스 백엔드에 DuckDB를 적용했더니, 단일 행 조회가 비정상적으로 느리고 동시 쓰기 시 잠금 충돌이 빈번하게 발생했다.

원인: DuckDB는 컬럼 지향 OLAP 엔진이다. 단일 행 포인트 쿼리에는 컬럼 스토리지의 오버헤드가 오히려 독이 된다. 또한 단일 Writer 제약 때문에 동시 쓰기는 근본적으로 불가하다.

대응: OLTP 워크로드는 PostgreSQL이나 MySQL을 사용하고, DuckDB는 분석 전용 레이어로 분리한다. OLTP DB의 데이터를 주기적으로 Parquet로 내보내고, DuckDB로 분석하는 이원화 구조를 채택한다.

실패 2: 메모리보다 큰 데이터셋을 무방비로 처리

증상: 32GB RAM 서버에서 100GB Parquet 파일을 GROUP BY 집계하자 프로세스가 OOM으로 kill되었다.

원인: DuckDB는 디스크 스필(spill to disk) 기능을 제공하지만, 기본 temp_directory가 설정되어 있지 않거나 임시 디스크 공간이 부족하면 메모리만으로 처리하려고 시도한다.

대응: temp_directory를 충분한 공간이 있는 SSD 경로로 반드시 설정한다. 또한 memory_limit를 시스템 RAM보다 낮게(약 70~80%) 설정하여 OS와 다른 프로세스의 메모리를 보호한다.

실패 3: 동시 접속 서비스에 단일 DB 파일 공유

증상: 여러 마이크로서비스가 동일한 DuckDB 파일에 동시에 접근하자 "database is locked" 오류가 빈발했다.

원인: DuckDB는 하나의 프로세스만 Writer로 접근할 수 있다. 여러 프로세스가 동시에 쓰기를 시도하면 잠금 충돌이 발생한다.

대응: 쓰기 작업은 단일 프로세스로 제한하고, 읽기 전용 접근은 read_only 모드를 사용한다. 또는 각 서비스가 인메모리 모드에서 Parquet 파일을 직접 쿼리하는 구조로 전환한다. 다중 사용자 동시 접속이 필수라면 MotherDuck이나 ClickHouse로 전환을 검토한다.

실패 4: 실시간 스트리밍 데이터 수집에 사용

증상: Kafka에서 초당 수만 건의 이벤트를 DuckDB에 실시간 INSERT하자 쓰기 성능이 급격히 저하되었다.

원인: DuckDB는 분석 쿼리(읽기)에 최적화되어 있으며, 행 단위 실시간 INSERT에는 적합하지 않다. 컬럼 스토리지는 배치 쓰기에서 효율적이지만 단건 INSERT에는 오버헤드가 크다.

대응: 실시간 수집은 Kafka + ClickHouse 또는 Kafka + S3(Parquet)로 처리하고, DuckDB는 적재 완료된 Parquet 파일을 분석하는 용도로만 사용한다.

15. DuckDB를 쓰면 안 되는 경우 정리

DuckDB가 부적합한 상황을 명확히 인식하는 것이 올바른 도구 선택의 첫걸음이다.

  • 고동시성 OLTP: 수백 명 이상의 동시 사용자가 행 단위로 읽고 쓰는 서비스 → PostgreSQL, MySQL
  • 실시간 스트리밍 수집: 초당 수만~수십만 건의 실시간 INSERT → ClickHouse, Apache Kafka + Flink
  • 페타바이트급 분산 처리: 단일 노드로 감당할 수 없는 규모 → Spark, Trino, BigQuery
  • 다중 Writer 필수: 여러 프로세스가 동시에 데이터를 갱신해야 하는 경우 → PostgreSQL
  • ACID 트랜잭션이 핵심인 결제/금융 시스템: → PostgreSQL, CockroachDB
  • 서브밀리초 포인트 쿼리가 필요한 캐시 레이어: → Redis, Memcached

16. 참고자료