도입: 왜 지금 DuckDB가 화제인가
요즘 데이터 엔지니어링 채널이나 Hacker News를 보면 DuckDB 얘기가 끊이질 않는다. 처음엔 "또 새 DB냐" 하고 넘겼는데, 실제로 굴려보면 왜 다들 떠드는지 금방 이해된다. 노트북에서 6GB짜리 Parquet 파일을 1초 안에 풀스캔하는 걸 보면, 솔직히 클러스터 깔던 시절이 좀 허무해진다.
DuckDB는 한 줄로 정의하면 인프로세스(in-process) 분석용 SQL 데이터베이스다. 두 단어가 핵심이다.
- 분석용(Analytical, OLAP): 단일 레코드를 PK로 찾는 게 아니라, 수백만 행을 스캔해서 필터·집계·조인하는 워크로드에 최적화돼 있다. Postgres/MySQL 같은 OLTP DB와 정반대 방향이다.
- 인프로세스(In-process): 서버가 없다. 접속하는 게 아니라 라이브러리로 불러온다. NumPy나 Polars를 import하듯이
import duckdb하면 끝이다.
채택이 빠른 이유는 단순하다. 진짜 쓰기 쉽다. 의존성 없는 20MB 미만 단일 바이너리고, pip install duckdb 한 방이면 설치 끝. Parquet/CSV/JSON 디렉터리를 마치 이미 SQL 테이블인 것처럼 열어준다. MotherDuck(클라우드 웨어하우스), Hex/Omni/Evidence(BI 인앱 엔진), Fivetran(데이터 레이크 라이터의 머지·컴팩션) 같은 곳에서 실제 프로덕트로 굴리고 있다.
이 글에서는 단순 사용법이 아니라 "그래서 왜 빠른가"를 내부 구조 관점에서 본다. 원문(Greybeam의 DuckDB Internals Part 1)을 따라가되, 실무에서 만나는 함정까지 같이 정리했다.
핵심: DuckDB는 왜 빠른가 — 동작 원리
1) 서버가 없다는 것의 진짜 의미 (직렬화 비용 제거)
일반적인 분석 DB(Snowflake, BigQuery, Redshift)는 전부 서버다. 커넥션 열고, SQL을 TCP로 보내고, 결과를 받는다. 이 과정에서 결과의 모든 값이 직렬화 → 네트워크 전송 → 역직렬화를 거친다.
이게 왜 문제냐. 2017년 Raasveldt와 Mühleisen의 "Don't Hold My Data Hostage" 논문에서 측정한 바로는, ODBC/JDBC 같은 클라이언트 프로토콜 자체가 쿼리 전체에서 가장 느린 단계인 경우가 흔했다. DB가 답을 계산하는 시간보다 결과를 꺼내 오는 시간이 더 길었다는 거다.
두 가지 비용이 원인이다.
- 대역폭: 기가비트 이더넷이 약 125MB/s가 한계라, 큰 결과셋은 전송 시간이 계산 시간을 넘긴다.
- 값당 오버헤드: ODBC/JDBC는 행 하나, 값 하나씩 돌려준다. 1억 행 결과면 필드마다 함수 호출이 수억 번 발생하고, 각 호출마다 메모리 복사·타입 체크·문자열 할당이 따라붙는다.
DuckDB는 클라이언트와 같은 프로세스에 살기 때문에 이 두 병목을 통째로 우회한다. Python에서 pandas 데이터프레임을 쿼리할 때 replacement scan이라는 기능을 쓰는데, 데이터프레임을 내부 테이블로 복사하지 않고 쿼리 실행 시점에 데이터프레임을 직접 읽는 함수로 테이블 참조를 치환한다. 운이 좋으면 Python 프로세스가 이미 들고 있는 버퍼를 그대로 읽는다. 이게 zero-copy다.
실제로 돌려보자.
import duckdb
import pandas as pd
df = pd.DataFrame({"id": range(1_000_000), "v": range(1_000_000)})
# df를 복사하지 않고 직접 쿼리 (replacement scan)
result = duckdb.sql("SELECT sum(v) FROM df WHERE id % 2 = 0").fetchall()
print(result)
[(249999500000,)]
여기서 FROM df의 df는 미리 만든 테이블이 아니라 그냥 Python 변수다. DuckDB가 알아서 잡아간다.
다만 주의할 점. 진짜 zero-copy인지는 데이터프레임의 물리적 레이아웃·컬럼 타입·null 표현·문자열 저장 방식에 따라 다르다. 타입이나 레이아웃이 안 맞으면 일부 컬럼은 변환 버퍼를 새로 할당한다. 가장 깔끔한 건 Arrow다. Arrow는 애초에 시스템 간 데이터 공유를 위한 컬럼형·타입 지정 메모리 포맷이라, 결과를 Arrow로 받거나 Arrow 기반 데이터를 쿼리하면 행 단위 변환 오버헤드를 대부분 피할 수 있다.
2) SQL이 들어오면 무슨 일이 벌어지나: parse → bind → plan → optimize
SQL이 엔진에 도착하면 정해진 단계를 거친다.
- 파싱(Parse): SQL 문자열을 AST(추상 구문 트리)로 바꾼다. DuckDB는 Postgres 파서를 포크해서 쓴다. 그래서 SQL 방언이 Postgres랑 익숙하게 느껴지는 거다.
- 바인딩(Bind): AST의 모든 이름을 카탈로그에 매칭한다.
lineitem은 특정 스키마를 가진 테이블이 되고,l_quantity는 특정 타입의 컬럼이 된다. 타입 체크도 여기서 일어난다. 컬럼 미해결, 모호한 참조, 타입 불일치 에러가 이 단계에서 터진다. - 최적화(Optimize): 작고 집중된 변환 패스들의 연속이다.
DuckDB의 좋은 점은 옵티마이저를 직접 들여다보고 끌 수 있다는 거다.
D SELECT name FROM duckdb_optimizers();
┌────────────────────────────┐
│ name │
│ varchar │
├────────────────────────────┤
│ expression_rewriter │
│ filter_pushdown │
│ join_order │
│ row_group_pruner │
│ join_filter_pushdown │
│ statistics_propagation │
│ ... │
└────────────────────────────┘
33 rows
특정 패스만 끄고 동작을 관찰할 수도 있다.
SET disabled_optimizers = 'filter_pushdown, join_order';
실무에서 중요한 옵티마이저 몇 개만 짚자.
- Filter pushdown: WHERE 술어를 스캔 가까이로 밀어서 최대한 일찍 데이터를 쳐낸다. DuckDB는 일단 필터를 위로 끌어올려 합치고 재배열한 다음, 다시 최대한 아래로 밀어내린다.
- Dynamic join-filter pushdown: 해시 조인에서 build side를 먼저 다 읽어야 probe side가 시작된다. DuckDB는 이 순서를 이용해서, build side가 메모리에 올라온 뒤 조인 키의 min/max를 계산하고 그 범위를 probe side 스캔에 런타임 필터로 밀어넣는다. build side에 100~200 사이 값만 있었다면, probe 스캔은 zonemap을 보고 그 범위 밖 row group을 아예 안 읽는다. distinct 값이 50개 미만이면 min-max 대신 IN 리스트로 바꿔서 더 정밀하게 건너뛴다.
- Join order optimization: 옵티마이저가 내리는 가장 영향력 큰 결정이다. 테이블 6개 조인이면 가능한 트리 모양이 30,240가지고, 최선과 최악의 차이가 런타임으로 수십~수백 배 날 수 있다. DuckDB는 쿼리를 그래프로 모델링한다(테이블=노드, 조인 술어=엣지).
3) 컬럼 단위 압축 저장 + zonemap
왜 컬럼형이 분석에 유리한가. SELECT sum(l_quantity) 같은 쿼리는 한 컬럼만 필요하다. 행 단위 저장이면 필요 없는 컬럼까지 메모리에 끌고 오지만, 컬럼 저장이면 딱 그 컬럼만 읽는다. 게다가 같은 컬럼은 같은 타입·비슷한 값이라 압축률도 훨씬 좋다.
여기에 zonemap이 붙는다. 데이터를 row group 단위로 나누고, 각 그룹의 컬럼별 min/max를 메타데이터로 들고 있다. WHERE l_shipdate > '2024-01-01' 같은 필터가 들어오면, max가 그 날짜보다 작은 row group은 아예 읽지도 않고 건너뛴다. 앞서 본 dynamic join-filter pushdown이 이 zonemap을 이용해 동작하는 거다.
실무 관점: 도입 시 고려사항, 트레이드오프, 흔한 함정
언제 쓰는가
- 로그/이벤트 분석 애드혹 쿼리: S3나 로컬에 쌓인 Parquet/CSV를 클러스터 없이 바로 질의. ETL 중간 검증용으로 특히 좋다.
- ETL 파이프라인의 변환 단계: pandas로 메모리 터뜨리던 집계·조인을 SQL 한 줄로 처리. Fivetran이 데이터 레이크 라이터 내부에서 머지·컴팩션에 쓰는 게 같은 맥락이다.
- CI 테스트 러너 / 임베디드 분석: 서버 띄울 필요 없으니 테스트 환경이 가볍다.
실제 로그 분석 예시. S3에 쌓인 Parquet을 바로 집계한다.
-- httpfs 익스텐션으로 S3 직접 쿼리
INSTALL httpfs;
LOAD httpfs;
SELECT
status_code,
count(*) AS cnt
FROM read_parquet('s3://my-bucket/logs/2024-*/*.parquet')
WHERE event_date >= '2024-01-01'
GROUP BY status_code
ORDER BY cnt DESC;
┌─────────────┬──────────┐
│ status_code │ cnt │
│ int32 │ int64 │
├─────────────┼──────────┤
│ 200 │ 18342211 │
│ 404 │ 210334 │
│ 500 │ 12044 │
└─────────────┴──────────┘
트레이드오프와 한계
- OLTP가 아니다: 단건 조회·고빈도 동시 쓰기 워크로드에는 부적합하다. PK로 한 줄 찾는 건 Postgres가 할 일이다.
- 단일 노드: 강력한 단일 노드 엔진이지만, 한 머신 메모리/디스크를 넘어서는 진짜 빅데이터는 분산 엔진 영역이다. (다만 메모리보다 큰 데이터도 디스크 스필로 처리는 한다.)
- 동시성 모델: 한 파일에 대해 한 프로세스가 쓰기 위주. 여러 프로세스가 같은 DB 파일에 동시에 붙는 시나리오는 설계상 제약이 있으니 공식 문서 확인이 필요하다.
흔한 함정 (에러 메시지 포함)
함정 1) DB 파일을 다른 프로세스가 잡고 있을 때. 로컬에서 DuckDB CLI로 파일 열어놓고, 동시에 Python에서 같은 파일을 쓰기 모드로 열면 이걸 만난다.
duckdb.IOException: IO Error: Could not set lock on file
"/data/analytics.duckdb": Conflicting lock is held in
/usr/bin/python3 (PID 12345). See also
https://duckdb.org/docs/connect/concurrency
해결은 단순하다. 읽기만 할 거면 read_only=True로 열어라. 쓰기는 한 번에 한 프로세스만.
con = duckdb.connect("/data/analytics.duckdb", read_only=True)
함정 2) 메모리보다 큰 집계를 돌릴 때 OOM. 거대한 GROUP BY나 정렬을 메모리 안에서 다 처리하려다 터진다.
duckdb.OutOfMemoryException: Out of Memory Error: failed to
allocate data of size ... (X GB/Y GB used)
memory_limit를 명시적으로 잡고, 디스크 스필이 가능하도록 temp_directory를 지정하면 메모리를 넘는 쿼리도 디스크로 흘려가며 처리한다.
SET memory_limit = '8GB';
SET temp_directory = '/data/duckdb_tmp';
함정 3) 타입 추론 어긋남. CSV를 읽을 때 자동 타입 추론이 첫 N행만 보고 결정하는 경우가 있어, 뒤쪽에 다른 타입이 나오면 캐스팅 에러가 난다.
Conversion Error: Could not convert string 'N/A' to INT64
이럴 땐 read_csv에서 types를 직접 지정하거나 sample_size를 키워라.
SELECT * FROM read_csv('data.csv',
types={'amount': 'VARCHAR'},
sample_size=-1); -- -1이면 전체 스캔으로 추론
대안과의 비교
- Polars: 같은 인프로세스 컬럼형 처리지만 DataFrame API 중심. SQL이 익숙하면 DuckDB, 함수형 체이닝이 편하면 Polars. 둘이 Arrow로 잘 섞인다.
- ClickHouse: 서버형 분산 OLAP. 대규모 동시 쿼리·항상 켜져 있는 분석 서비스라면 이쪽. 임베디드·애드혹이면 DuckDB가 가볍다.
- Postgres: OLTP가 메인이면 그냥 Postgres. DuckDB는 OLTP 대체재가 아니다.
정리
한 줄 요약: DuckDB는 서버 없이 같은 프로세스에서 도는 컬럼형 분석 엔진으로, 직렬화 비용 제거 + 컬럼 압축 + zonemap 기반 pruning + 똑똑한 옵티마이저 덕분에 단일 노드에서 클러스터급 분석 속도를 낸다.
누가 언제 쓰나:
- 노트북/CI/ETL에서 Parquet·CSV를 클러스터 없이 빠르게 집계하고 싶은 데이터·백엔드 엔지니어 → 강력 추천.
- pandas로 메모리 터뜨리며 집계하던 변환 단계를 SQL로 정리하고 싶을 때 → 딱이다.
- 단건 조회·고빈도 쓰기·다중 프로세스 동시 쓰기가 핵심이면 → Postgres나 서버형 DB로 가라.
이번 글은 SQL이 들어와서 엔진이 실행 준비를 마치는 지점까지, 그리고 그 쿼리가 읽을 스토리지 레이어까지 다뤘다. 실제 쿼리 실행(벡터화 실행, morsel 기반 병렬성)은 원문 기준 Part 2에서 이어진다고 하니, 그쪽도 같이 보면 그림이 완성된다.
참고 자료
'Tech_News' 카테고리의 다른 글
| ECS 오토스케일링이 드디어 빨라졌다: 고해상도(20초) 메트릭 실전 적용기 (0) | 2026.06.20 |
|---|---|
| curl 없는 컨테이너에서 살아남기: Bash /dev/tcp로 HTTP 요청 날리기 (0) | 2026.06.19 |
| JWT로 로그인 세션 유지하지 마라 — 5년차 인프라 엔지니어가 정리한 진짜 이유와 전환 가이드 (0) | 2026.06.18 |
| AI 에이전트가 메일을 읽는 시대, SPF·DKIM·DMARC를 다시 점검해야 하는 이유 (0) | 2026.06.16 |
| AUR 패키지 408개 감염 사태로 다시 보는 공급망 보안: maintainer 사칭부터 eBPF 루트킷까지 (0) | 2026.06.15 |
