DuckDB로 로컬 분석하기: Parquet·CSV 직접 쿼리, pandas 통합, 대용량 메모리 관리
이 글의 핵심
DuckDB는 "OLAP용 SQLite"라 불리는 임베디드 분석 DB로, 서버 없이 Parquet·CSV를 SQL로 바로 쿼리하고 pandas DataFrame을 테이블처럼 씁니다. 다만 한 DB 파일에는 한 프로세스만 쓸 수 있고 연결을 스레드 사이에 공유하면 안 되는 등 SQLite와 비슷한 제약이 있습니다. 설치와 핵심 SQL, Python·Node 통합, S3 원격 쿼리, 메모리 관리와 파일 잠금 오류 같은 운영 함정을 예제로 정리합니다.
이 글의 핵심
DuckDB는 “OLAP용 SQLite”로 불리는 임베디드 분석 DB입니다. 2019년 CWI Amsterdam에서 시작해 2024년 v1.0을 내놓았고, 1.x 버전대에서 columnar 저장 + vectorized 실행 + SQL 표준 지원을 모두 갖춘 도구로 자리 잡았습니다. v1.0 이후 DB 파일 저장 형식이 안정화되어, 이전 버전처럼 업그레이드할 때마다 DB 파일을 내보내고 다시 가져올 필요가 크게 줄었습니다.
핵심 매력:
- 의존성 0, 단일 바이너리 —
pip install duckdb하나로 끝 - Parquet/CSV/JSON을 SQL로 직접 쿼리 — 전처리 없이
- pandas DataFrame과 양방향 상호운용
- 메모리보다 큰 데이터도 out-of-core 처리
- S3/GCS/HTTP 원격 파일 스트리밍 쿼리
“임베디드”라는 말이 이 도구의 성격을 가장 잘 설명합니다. PostgreSQL처럼 별도 서버 프로세스에 접속하는 것이 아니라, DuckDB 엔진이 여러분의 프로세스 안에서 라이브러리로 실행됩니다. 네트워크 왕복도 직렬화 비용도 없어서 pandas DataFrame이나 Arrow 테이블을 복사 없이 읽을 수 있고, 설치할 서버나 관리할 계정도 없습니다. 대신 서버형 DB가 해 주던 일, 즉 여러 클라이언트의 동시 접근 조정, 권한 관리, 네트워크 너머의 접근은 제공하지 않습니다. 아래의 사용 예와 제약은 모두 이 설계에서 나옵니다.
설치
CLI
# macOS
brew install duckdb
# Linux / WSL
curl -L https://github.com/duckdb/duckdb/releases/download/v1.1.3/duckdb_cli-linux-amd64.zip -o duckdb.zip
unzip duckdb.zip
sudo mv duckdb /usr/local/bin/
# Windows
scoop install duckdb
duckdb # 인메모리 세션 시작
duckdb my.db # 파일 저장
인메모리 세션은 종료하면 모든 테이블이 사라지므로 탐색용으로만 쓰고, 결과를 남기려면 파일 DB를 열거나 Parquet으로 내보내야 합니다. 파일 DB에는 한 가지 중요한 제약이 있습니다. 한 DB 파일을 쓰기 모드로 열 수 있는 프로세스는 하나뿐이라서, CLI로 my.db를 열어 둔 채 Python 스크립트에서 같은 파일을 열면 IO Error: Could not set lock on file "my.db": Conflicting lock is held 같은 오류가 납니다. 여러 프로세스가 동시에 읽기만 한다면 duckdb.connect('my.db', read_only=True)로 열면 되고, 여러 프로세스가 쓰기까지 해야 한다면 DuckDB 파일 하나를 공유하기보다 각자 Parquet 파일을 쓰고 DuckDB는 그 파일들을 읽는 구조가 맞습니다.
Python
pip install duckdb pandas pyarrow
Node.js
npm install @duckdb/node-api # v1.1+ 공식 neo-API
기타 바인딩
R, Rust, Go, Java, Swift, C/C++ 공식 바인딩이 있으며 WebAssembly(@duckdb/duckdb-wasm)로 브라우저에서도 실행됩니다.
첫 쿼리: 5분 만에 이해
-- CSV를 다운로드 없이 바로 쿼리
SELECT *
FROM 'https://raw.githubusercontent.com/datasets/population/master/data/population.csv'
LIMIT 5;
-- Parquet를 직접 쿼리 (S3도 가능)
SELECT country, year, value
FROM 's3://my-bucket/population/*.parquet'
WHERE year >= 2020
ORDER BY value DESC
LIMIT 10;
-- 로컬 폴더의 모든 CSV를 하나의 테이블로
SELECT * FROM read_csv('data/*.csv', union_by_name=true);
-- JSON도 자연스럽게 (필드가 컬럼으로 자동 추론됨)
SELECT name, age
FROM read_json('users.jsonl');
파일을 바로 쿼리할 때 DuckDB는 스키마를 추론합니다. CSV라면 앞부분의 행을 샘플링해 구분자, 헤더 유무, 각 컬럼의 타입을 정하는데, 앞쪽에는 숫자만 있다가 뒤쪽에 N/A 같은 문자열이 섞인 컬럼은 샘플링 이후에 형변환 오류로 실패하기도 합니다. 이런 파일은 read_csv('data.csv', sample_size=-1)로 전체를 보고 추론하게 하거나, columns={'price': 'VARCHAR'}처럼 문제 컬럼의 타입을 직접 지정하는 편이 안전합니다. 추론 결과는 DESCRIBE SELECT * FROM 'data.csv'로 미리 확인할 수 있습니다. Parquet은 파일에 스키마가 들어 있어 이런 문제가 없다는 것이 분석용 파일 형식으로 Parquet이 선호되는 이유 중 하나입니다. HTTP URL을 쿼리할 때는 httpfs 확장이 필요한데, 대부분의 환경에서 처음 쓸 때 자동으로 설치·로드됩니다.
별도 ETL 없이 소스 파일을 SQL로 바로 질의하는 것이 DuckDB의 핵심 경험입니다.
SQL 기능: Postgres와 매우 유사
DuckDB는 Postgres 호환 SQL 방언을 지향합니다. 윈도우 함수, CTE, LATERAL JOIN, UNNEST, ARRAY/STRUCT, 정규표현식, 시계열 함수, 피벗 등 대부분 지원됩니다.
-- CTE + 윈도우 함수
WITH daily_sales AS (
SELECT
date_trunc('day', created_at) AS day,
SUM(amount) AS total
FROM orders
GROUP BY 1
)
SELECT
day,
total,
AVG(total) OVER (ORDER BY day ROWS BETWEEN 6 PRECEDING AND CURRENT ROW) AS moving_avg_7d,
LAG(total, 7) OVER (ORDER BY day) AS same_day_last_week
FROM daily_sales
ORDER BY day DESC;
-- PIVOT (SQL 표준 구현체 중 드묾)
PIVOT sales
ON category
USING SUM(amount)
GROUP BY year, month;
-- ARRAY / STRUCT / MAP (복합 타입)
SELECT
user_id,
list_filter(tags, t -> t.weight > 0.5) AS strong_tags,
struct_extract(profile, 'email') AS email
FROM users;
“Postgres와 매우 유사”하다는 것은 문법의 대부분이 비슷하다는 뜻이지 완전히 같다는 뜻은 아닙니다. DuckDB는 GROUP BY ALL(SELECT의 비집계 컬럼으로 자동 그룹화), SELECT * EXCLUDE (col), FROM 절로 시작하는 쿼리처럼 분석 작업을 편하게 만드는 확장 문법을 여럿 추가했고, 반대로 Postgres의 일부 함수나 타입 캐스팅 규칙은 다르게 동작합니다. Postgres에서 옮긴 쿼리가 에러 없이 다른 결과를 내는 흔한 경우는 정수 나눗셈입니다. Postgres에서 5 / 2는 2이지만, DuckDB에서 /는 소수 결과(2.5)를 내고 정수 나눗셈은 // 연산자를 씁니다. 옮긴 리포트 쿼리의 숫자가 미묘하게 다르다면 이런 차이를 먼저 확인하세요.
pandas와 통합
import duckdb
import pandas as pd
df = pd.read_csv('large_file.csv')
# DataFrame을 바로 FROM 절에서 사용
result = duckdb.sql("""
SELECT country, AVG(gdp_per_capita) AS avg_gdp
FROM df
WHERE year >= 2020
GROUP BY country
ORDER BY avg_gdp DESC
LIMIT 10
""").df()
print(result)
# 반대로 DuckDB 결과를 DataFrame으로
con = duckdb.connect('analytics.db')
df = con.sql("SELECT * FROM orders WHERE amount > 1000").df()
FROM df가 동작하는 방식을 알아 두면 헷갈리지 않습니다. DuckDB의 Python 모듈은 쿼리에 나온 이름이 테이블로 존재하지 않으면 호출한 쪽의 지역·전역 변수에서 같은 이름의 DataFrame(또는 Arrow 테이블, Polars DataFrame)을 찾아 읽습니다(replacement scan). 데이터를 DuckDB로 복사하지 않고 바로 스캔하므로 빠르지만, 함수 안에서 만든 DataFrame을 다른 함수에서 이름만으로 쿼리하면 찾지 못하고 Catalog Error: Table with name df does not exist!가 납니다. 명시적으로 하고 싶다면 con.register('orders', df)로 이름을 등록합니다. 위 예제에서 pd.read_csv로 먼저 읽는 것도 사실 필요 없는 단계입니다. duckdb.sql("SELECT ... FROM 'large_file.csv'")로 DuckDB가 직접 읽게 하면 파일 전체를 pandas 메모리에 올리지 않아도 됩니다.
pandas 대비 속도 이점: 메모리보다 큰 데이터, 복잡한 조인, 그룹 집계에서 DuckDB가 크게 빠른 경우가 흔합니다. pandas 연산은 대부분 단일 스레드이고 중간 결과마다 새 DataFrame을 메모리에 만들지만, DuckDB는 쿼리 전체를 한 번에 최적화하고, 필요한 컬럼만 읽으며, 데이터를 벡터(묶음) 단위로 여러 코어에서 병렬 처리하고, 메모리가 부족하면 디스크로 넘겨(spill) 처리를 이어 가기 때문입니다. 단순한 DataFrame 조작은 pandas가 편합니다.
Parquet 중심 워크플로우
# Parquet 읽기·쓰기
con.sql("""
COPY (SELECT * FROM events WHERE created_at >= '2026-01-01')
TO 'events_2026.parquet' (FORMAT 'parquet', COMPRESSION 'zstd')
""")
# 파티션된 쓰기
con.sql("""
COPY events
TO 'events/'
(FORMAT PARQUET, PARTITION_BY (year, month), OVERWRITE_OR_IGNORE)
""")
events/
├── year=2026/
│ ├── month=1/data_0.parquet
│ ├── month=2/data_0.parquet
│ └── month=3/data_0.parquet
파티션 경로 자체를 predicate로 활용해 “2026년 3월만 읽기”가 즉시 가능해집니다.
SELECT COUNT(*) FROM 'events/year=2026/month=3/*.parquet';
경로를 직접 지정하는 대신 SELECT * FROM read_parquet('events/*/*/*.parquet', hive_partitioning = true) WHERE year = 2026 AND month = 3처럼 쓰면, DuckDB가 디렉터리 이름의 year=, month=를 컬럼으로 만들고 WHERE 조건에 맞지 않는 디렉터리는 아예 열지 않습니다. 파티션 컬럼을 어떻게 고를지가 성능을 좌우하는데, 너무 잘게 나누면(예: 사용자 ID별) 작은 파일이 수만 개 생겨 파일을 여는 비용이 쿼리 시간을 지배하게 됩니다. 쿼리가 주로 거는 필터(날짜 등)를 기준으로, 파일 하나가 수십~수백 MB 정도가 되게 나누는 것이 일반적인 기준입니다. OVERWRITE_OR_IGNORE는 이름이 같은 파일만 덮어쓰고 나머지는 그대로 두므로, 같은 파티션을 다시 쓸 때 옛 파일이 섞여 남을 수 있다는 점도 주의하세요.
S3 / GCS 원격 파일
-- httpfs 확장 로드
INSTALL httpfs;
LOAD httpfs;
-- AWS 자격증명 설정
SET s3_region='ap-northeast-2';
SET s3_access_key_id='...';
SET s3_secret_access_key='...';
-- 또는 IAM role / 환경변수 / ~/.aws/credentials 자동 감지
CREATE SECRET aws_creds (
TYPE S3,
PROVIDER CREDENTIAL_CHAIN,
CHAIN 'env;config;sso'
);
-- 원격 쿼리
SELECT
date_trunc('day', created_at) AS day,
COUNT(*) AS events
FROM 's3://my-lake/events/year=2026/*.parquet'
WHERE event_type = 'purchase'
GROUP BY 1
ORDER BY 1;
필요한 컬럼·row group만 다운로드하므로, 넓은 테이블에서 몇 개 컬럼만 집계하는 쿼리라면 전송량이 전체 파일 크기보다 훨씬 작아집니다. data lake를 DW 도입 없이도 분석할 수 있게 만드는 핵심 기능입니다.
이것이 가능한 이유는 Parquet 파일 끝에 있는 메타데이터(footer)입니다. footer에는 각 row group(행 묶음)과 컬럼 청크의 위치, 그리고 컬럼별 최솟값·최댓값 통계가 들어 있어서, DuckDB는 먼저 footer만 HTTP Range 요청으로 읽고 WHERE event_type = 'purchase'에 해당할 수 없는 row group은 건너뛰며 필요한 컬럼의 바이트 범위만 가져옵니다. 반대로 말하면 파일을 어떻게 썼는지가 효과를 결정합니다. 데이터가 정렬되지 않아 모든 row group에 모든 값이 섞여 있으면 통계로 건너뛸 수 있는 부분이 없고, SELECT *는 결국 전체를 내려받습니다. 또 수천 개의 작은 파일을 S3에서 읽으면 파일마다 footer 요청이 필요해 지연이 커지므로, 자주 쿼리하는 데이터는 적당한 크기로 합치고 필터 컬럼 기준으로 정렬해 저장하는 편이 좋습니다. 자격 증명은 SET s3_access_key_id처럼 세션 설정에 평문으로 넣기보다 CREATE SECRET의 CREDENTIAL_CHAIN으로 기존 AWS 설정을 쓰는 방식이 안전합니다.
메모리 관리: 대용량 처리의 핵심
-- 사용 메모리 상한 (기본 시스템 메모리 80%)
SET memory_limit = '8GB';
-- 스풀링 디렉터리 (메모리 초과 시 디스크로)
SET temp_directory = '/tmp/duckdb';
-- 스레드 수
SET threads = 8;
-- 현재 설정 확인
SELECT current_setting('memory_limit'), current_setting('threads');
DuckDB는 streaming/out-of-core 실행을 지원해 메모리를 넘는 데이터도 처리합니다. 다만 일부 연산(큰 해시 조인, 정렬)은 디스크 스풀링이 발생하면 느려지므로 조인 순서·필터 순서를 고려해 plan을 최적화하세요.
EXPLAIN ANALYZE
SELECT ...
EXPLAIN ANALYZE로 실제 실행 계획과 각 연산의 시간·행 수를 확인할 수 있습니다.
memory_limit은 DuckDB의 버퍼 관리자가 관리하는 메모리의 상한이라, 프로세스 전체 메모리(RSS)가 이 값을 정확히 지키지는 않습니다. Python 객체나 pandas DataFrame, 결과를 .df()로 변환하면서 생기는 복사본은 DuckDB 바깥의 메모리입니다. 컨테이너 환경에서 메모리 제한이 4GB인데 DuckDB의 기본값(시스템 메모리의 80%)이 호스트 전체 메모리 기준으로 계산되면 컨테이너가 OOM으로 죽을 수 있으므로, 컨테이너에서는 memory_limit을 컨테이너 한도보다 여유 있게 작게 명시하는 것이 안전합니다. 결과가 큰 쿼리를 .df()로 한꺼번에 받는 것도 메모리 급증의 흔한 원인이므로, 큰 결과는 COPY ... TO로 파일에 쓰거나 fetchmany/Arrow 배치로 나눠 받는 편이 좋습니다.
실전 패턴 1: 로그 분석 대시보드
import duckdb
import streamlit as st
con = duckdb.connect('logs.db')
# Parquet 원본은 건드리지 않고 View로 조합
con.sql("""
CREATE OR REPLACE VIEW requests AS
SELECT * FROM 's3://my-logs/requests/*.parquet'
""")
@st.cache_data(ttl=300)
def top_endpoints(days: int):
return con.sql(f"""
SELECT endpoint, COUNT(*) AS hits, AVG(latency_ms) AS avg_latency
FROM requests
WHERE ts >= now() - INTERVAL '{days} days'
GROUP BY endpoint
ORDER BY hits DESC
LIMIT 20
""").df()
st.dataframe(top_endpoints(7))
이 예제에는 운영 전에 고칠 점이 두 가지 있습니다. 첫째, days를 f-string으로 SQL에 끼워 넣는 방식은 값이 사용자 입력에서 오면 SQL 인젝션 경로가 되므로 con.execute("... WHERE ts >= now() - to_days(?)", [days])처럼 매개변수로 넘겨야 합니다. 둘째, DuckDB 연결 객체는 여러 스레드가 동시에 쓰도록 설계되지 않았고, Streamlit은 사용자 세션마다 스크립트를 별도 스레드에서 실행합니다. 연결 하나를 전역으로 공유하면 동시 접속 시 오류나 이상 동작이 생길 수 있으므로, 스레드마다 con.cursor()로 커서를 만들어 쓰거나 st.cache_resource로 관리하는 연결에서 커서를 꺼내 쓰는 것이 공식 권장 방식입니다. S3 위의 뷰를 매번 조회하면 요청마다 원격 파일을 읽으므로, 자주 보는 집계는 주기적으로 로컬 테이블로 만들어 두는 캐시 계층을 두면 대시보드 응답이 훨씬 안정적입니다.
Streamlit/Dash/Panel + DuckDB는 DW 없이도 분석 대시보드를 만드는 데 강력한 조합입니다.
실전 패턴 2: ETL 중간 단계
# Airflow/Dagster 태스크 안에서
import duckdb
def transform_daily_sales(ds: str):
con = duckdb.connect(':memory:')
con.sql(f"""
CREATE TABLE daily_sales AS
SELECT
date_trunc('day', created_at) AS day,
product_category,
SUM(amount) AS revenue,
COUNT(DISTINCT user_id) AS unique_buyers
FROM read_parquet('s3://raw/orders/dt={ds}/*.parquet')
WHERE status = 'completed'
GROUP BY 1, 2
""")
con.sql("""
COPY daily_sales
TO 's3://curated/daily_sales/' (FORMAT PARQUET, PARTITION_BY (day))
""")
Python 메모리 점유 최소화(원본을 DataFrame으로 올리지 않고 집계 결과만 가져옴), 멀티코어 병렬 집계, SQL로 가독성 개선을 동시에 얻습니다.
ETL 태스크에서는 멱등성을 먼저 챙겨야 합니다. 스케줄러가 실패한 태스크를 재시도하면 같은 ds로 함수가 다시 실행되는데, PARTITION_BY로 쓰는 COPY는 기존 파일이 있는 디렉터리에 쓰려고 하면 오류를 내거나(기본 동작), 옵션에 따라 이전 실행의 파일과 새 파일이 섞여 남습니다. 날짜 단위로 결과 경로를 정하고 쓰기 전에 그 경로를 비우거나, OVERWRITE 옵션으로 해당 파티션을 통째로 교체하는 방식이 재실행에 안전합니다. 또 태스크마다 :memory: 연결을 새로 만들면 S3 자격 증명이나 확장 설정도 매번 다시 해야 하므로, 공통 설정을 담은 연결 생성 함수를 하나 두는 편이 실수를 줄입니다.
실전 패턴 3: Node.js 서버에서 분석 쿼리
import { DuckDBInstance } from '@duckdb/node-api';
const db = await DuckDBInstance.fromFile('analytics.db');
const conn = await db.connect();
export async function fetchTopProducts(days: number) {
const reader = await conn.runAndReadAll(
`SELECT product_id, SUM(quantity) AS sold
FROM sales
WHERE sold_at >= now() - to_days(?::INTEGER)
GROUP BY product_id
ORDER BY sold DESC
LIMIT 10`,
[days],
);
return reader.getRowObjects();
}
API의 무거운 분석 쿼리를 Postgres에 보내는 대신 DuckDB에 저장된 Parquet/로컬 DB에 보내면 Postgres OLTP 부하가 줄고 응답 시간도 개선됩니다.
INTERVAL ? DAYS처럼 매개변수를 구간 리터럴 안에 넣는 문법은 SQL 방언마다 지원 여부가 달라서, 위처럼 to_days(?) 함수로 구간을 만드는 방식이 확실합니다. Node.js 서버에서 쓸 때 기억할 점은 앞의 파일 잠금 제약입니다. PM2 클러스터 모드나 여러 컨테이너로 서버를 늘리면 프로세스마다 같은 analytics.db를 쓰기 모드로 열려고 해서 두 번째 프로세스부터 잠금 오류로 실패합니다. 이런 배포에서는 각 프로세스가 READ_ONLY로 열고, DB 파일이나 Parquet은 별도의 배치 작업이 만들어 교체하는 구조가 안전합니다. DuckDB의 쿼리는 여러 코어를 사용하므로, 요청이 몰릴 때 무거운 분석 쿼리 여러 개가 동시에 돌면 같은 서버의 다른 API 응답까지 느려질 수 있다는 점도 고려해 threads 설정이나 동시 실행 수를 제한해 두는 것이 좋습니다.
확장(extensions): 생태계
-- 자주 쓰는 확장
INSTALL spatial; LOAD spatial; -- 지리 연산 (PostGIS 호환)
INSTALL httpfs; LOAD httpfs; -- HTTP/S3/GCS
INSTALL json; LOAD json; -- JSON (기본 포함)
INSTALL iceberg; LOAD iceberg; -- Apache Iceberg 테이블
INSTALL delta; LOAD delta; -- Delta Lake
INSTALL postgres; LOAD postgres; -- Postgres 직접 연결
INSTALL mysql; LOAD mysql; -- MySQL
-- Postgres의 테이블을 DuckDB에서 직접 쿼리
ATTACH 'host=db.internal dbname=app' AS pg (TYPE POSTGRES, READ_ONLY);
SELECT COUNT(*) FROM pg.public.orders;
Iceberg/Delta Lake 읽기도 확장으로 지원되어, 테이블 포맷으로 관리되는 데이터 레이크를 DuckDB에서 직접 조회할 수 있습니다. 다만 쓰기 지원이나 일부 기능은 확장 버전에 따라 차이가 있으므로 쓰려는 기능을 문서에서 확인하는 것이 좋습니다.
확장은 INSTALL로 한 번 내려받아 두면 이후에는 LOAD만 하면 되고, 많은 핵심 확장은 관련 기능을 처음 쓸 때 자동으로 설치·로드됩니다. 인터넷이 막힌 서버나 CI에서는 자동 설치가 실패하므로, 이미지 빌드 단계에서 필요한 확장을 미리 INSTALL해 두어야 합니다. 확장은 DuckDB 버전에 맞춰 빌드되므로, DuckDB를 올린 뒤 옛 확장이 남아 있으면 로드 오류가 날 수 있다는 점도 알아 두세요. ATTACH로 Postgres에 붙는 기능은 편리하지만 쿼리가 Postgres 쪽에서 실행되는 부분도 있어, 운영 DB에 붙일 때는 이 문서의 예제처럼 READ_ONLY로 열고 무거운 스캔이 운영 부하가 되지 않는지 확인해야 합니다.
성능 팁
- 컬럼 프루닝:
SELECT *지양, 필요한 컬럼만 지정해 Parquet I/O 절감 - predicate 먼저:
WHERE의 날짜/ID 필터가 파티션·row group을 프루닝 PRAGMA threads=N: CPU 수 기반 조정, 과도한 스레드는 메모리 압박- ORDER BY는 필요할 때만 — 대용량 정렬은 메모리 부담
LIMIT과SAMPLE로 탐색 쿼리 가속EXPLAIN ANALYZE로 실제 실행 프로파일 확인
-- 샘플링으로 수백만 행에서 추정 통계
SELECT AVG(amount), STDDEV(amount)
FROM orders
USING SAMPLE 1%;
데이터 이동 없이 혼합 쿼리
-- 로컬 CSV + 원격 Parquet + Postgres 테이블을 한 쿼리로
ATTACH 'pg' AS pg (TYPE POSTGRES, READ_ONLY);
SELECT
c.segment,
COUNT(DISTINCT o.user_id) AS buyers,
SUM(o.amount) AS revenue
FROM 's3://lake/orders/2026/*.parquet' o
JOIN pg.public.customers c ON o.user_id = c.id
JOIN 'campaigns_2026.csv' camp ON camp.campaign_id = o.campaign_id
GROUP BY c.segment
ORDER BY revenue DESC;
ETL 없이 폴리글롯 쿼리가 가능하다는 점이 데이터 팀 워크플로를 크게 단순화합니다.
트러블슈팅
Out of Memory Error
SET memory_limit = '4GB';로 상한 조정SET temp_directory = '...'지정해 디스크 스풀링 허용- 큰 조인은 한쪽을 미리 필터링/샘플링
Parquet 파티션이 왜 다 읽힐까
파일 이름/디렉터리 규칙이 key=value 형태인지 확인. hive_partitioning=1 옵션으로 강제 힌트.
S3 자격증명이 안 잡힐 때
CREATE SECRET ... PROVIDER CREDENTIAL_CHAIN이 가장 단순. 환경변수·~/.aws/credentials·IAM role 순으로 자동 검색.
쿼리가 한번 느리고 다음엔 빠름
운영체제 파일 캐시 영향. 원격 Parquet는 DuckDB의 HTTP metadata cache가 구축된 후 가속됩니다.
Could not set lock on file 오류
다른 프로세스(CLI, 노트북 커널, 다른 워커)가 같은 DB 파일을 쓰기 모드로 열고 있다는 뜻입니다. 주피터 노트북에서 셀을 다시 실행하면서 이전 연결을 닫지 않았거나, 개발 서버의 자동 재시작으로 옛 프로세스가 아직 살아 있는 경우가 흔합니다. 읽기만 한다면 read_only=True로 열고, 쓰기가 필요하면 연결을 가진 프로세스를 하나로 정리하세요.
연결을 여러 스레드에서 공유했더니 오류가 납니다
DuckDB 연결은 스레드 간 공유를 전제로 하지 않습니다. 스레드마다 con.cursor()로 별도 커서를 만들어 쓰면 같은 DB를 안전하게 사용할 수 있습니다. 하나의 쿼리 안에서는 DuckDB가 알아서 여러 스레드로 병렬 처리하므로, 애플리케이션 쪽에서 쿼리를 쪼개 스레드로 돌릴 필요는 대부분 없습니다.
언제 쓰고 언제 쓰지 말아야 하나
쓰기 좋은 경우
- 로컬/노트북 분석 탐색
- ETL 중간 계산 (pandas 대체)
- 분석 대시보드의 뷰/캐시 계층
- 데이터 레이크(Parquet) 쿼리 엔진
- 배치 리포트 생성
- 테스트/개발 환경 분석 DB
안 맞는 경우
- 다중 사용자 동시 쓰기 OLTP → Postgres/MySQL
- 초저지연 단건 read 키-값 스토어 → Redis
- 실시간 스트리밍 집계 → ClickHouse, Materialize
- 페타바이트급 분산 분석 → Snowflake, BigQuery, Spark
DuckDB 도입 체크리스트
- Python/Node 프로젝트에서 pandas 무거운 집계 부분을 DuckDB로 교체해 벤치마크
- 기존 S3 Parquet 분석 워크플로우에 DuckDB CLI 도입해 ad-hoc 쿼리 가속
- 로그/이벤트 테이블을 Parquet로 덤프해 Postgres 부하 분리
- 대시보드(Streamlit/Metabase) 쿼리 엔진을 DuckDB로 교체 실험
- CI에서 데이터 검증(row count, 유니크 키, 스키마)을 DuckDB로 자동화
마무리
DuckDB는 별도 서버 없이 프로세스 안에서 돌아가는 분석 엔진이라, pandas를 쓰던 자리·ad-hoc SQL을 쓰던 자리·대시보드 백엔드에 설치 부담 없이 들어갑니다. “pandas가 메모리 부족으로 죽는다”거나 “Postgres의 분석 쿼리가 OLTP에 영향을 준다”는 문제가 있다면 좋은 후보이고, 반대로 여러 프로세스가 동시에 쓰는 운영 DB 역할은 맞지 않는다는 경계를 알고 쓰면 됩니다.
자주 묻는 질문 (FAQ)
Q. DuckDB에서 Out of Memory 오류가 나면 어떻게 하나요?
A. SET memory_limit으로 상한을 명시하고 SET temp_directory를 지정해 메모리를 넘는 부분이 디스크로 스풀링되도록 허용합니다. DuckDB는 메모리보다 큰 데이터도 out-of-core로 처리하지만, 큰 해시 조인이나 정렬이 디스크로 넘어가면 느려지므로 조인 전에 한쪽을 먼저 필터링하는 편이 좋습니다. EXPLAIN ANALYZE로 어느 연산에서 행 수가 불어나는지 확인한 뒤 필터 순서를 조정합니다.