https://2stndard.tistory.com/notice/203
[발간예정][EPL과 유튜브로 배우는 DuckDB] 실습 코드와 데이터
EPL과 유튜브 데이터로 배우는 DuckDB에서 사용되는 실습 데이터와 코드를 제공합니다. EPL_DATA&samplefile.zip : 책에서 사용하는 영국 프리미어리그 데이터 셋과 샘플로 사용하는 파일espn.duckdb.zip : 책
2stndard.tistory.com
200백만 행에 쿼리를 실행했습니다.
결과는 40ms 만에 돌아옵니다.
캐시를 미리 데워놓은 것도 아니고, 사전 집계 테이블을 만든 것도 아니며, 나중에 청구서를 보고 불안해지는 BigQuery를 사용한 것도 아닙니다. 그저 노트북에서 실행 중인 DuckDB일 뿐입니다.
도대체 내부에서는 무슨 일이 일어나고 있을까요?
짧게 답하면, DuckDB는 데이터의 대부분을 아주 철저하게 읽지 않습니다.
이번 글에서는 이 마법이 어떻게 동작하는지 살펴봅니다. 핵심은 행 그룹(Row Groups) 과 통계 기반 프루닝(Statistics-Based Pruning) 입니다.
핵심 아이디어: “필요한 것만 읽어라(Only touch what you must)”
현실적으로 생각해 보면, 대부분의 분석 쿼리는 전체 데이터셋을 모두 사용할 필요가 없습니다.
보통은 필터를 적용합니다:
SELECT
customer_id,
SUM(amount) AS total_spent
FROM orders
WHERE order_date >= DATE '2024-01-01'
AND country = 'IN'
GROUP BY customer_id;
논리적으로 보면, 여러분은 거대한 주문(orders) 테이블 전체를 스캔하는 것처럼 보입니다. 하지만 물리적으로는 DuckDB가 가능한 한 데이터를 읽지 않기 위해 매우 적극적으로 동작합니다.
내부적으로 DuckDB는 데이터를 행 그룹(Row Groups, 또는 Data Chunks) 단위로 저장합니다. 그리고 각 그룹마다 컬럼 통계(Column Statistics)를 함께 유지합니다.
- 최소값과 최대값(Min / Max)
- 고유값 개수(NDV, Number of Distinct Values)
- NULL 개수
- 경우에 따라 히스토그램이나 추가 메타데이터
이 작은 요약 정보만으로도 DuckDB는 다음과 같이 판단할 수 있습니다.
“이 10만 행 블록은 country = 'IN' 조건을 절대 만족할 수 없으니 읽지 말자.”
이렇게 읽지 않아도 되는 데이터를 통째로 건너뛰는 것을 프루닝(Pruning) 이라고 합니다.
그리고 바로 이 프루닝이 DuckDB가 그렇게 빠르게 느껴지는 핵심 이유입니다.
DuckDB는 데이터를 어떻게 저장하는가: 행 그룹(Row Groups)과 컬럼 청크(Column Chunks)
내부적으로 DuckDB는 벡터화(Vectorized)된 컬럼 기반(Columnar) 엔진입니다.
즉, 데이터를 한 행씩(Row-by-Row) 처리하는 것이 아니라 배치 단위(벡터 단위) 로 처리합니다.
테이블이 내부적으로 저장되는 구조를 단순화해서 생각해 보면 다음과 같습니다:
Table: orders
[ Row Group 1 ] 100k rows
├── order_id (column chunk)
├── order_date (column chunk)
├── country (column chunk)
└── amount (column chunk)
[ Row Group 2 ] 100k rows
├── order_id
├── order_date
├── country
└── amount
[ Row Group 3 ] 100k rows
...
각 행 그룹(Row Group) 은 자신만의 컬럼 청크(Column Chunk) 를 저장하며, 각 청크에는 해당 데이터 구간의 통계 정보(Statistics) 가 함께 저장됩니다:
Row Group 2 stats:
order_date:
min = 2023-06-01
max = 2023-12-31
country:
min = 'DE'
max = 'US'
distinct = 12
amount:
min = 0.01
max = 12999.99
다음과 같은 조건(Predicate)을 적용하면:
WHERE order_date >= DATE '2024-01-01'
DuckDB는 단순히 이렇게 질문합니다.
이 행 그룹(Row Group)의 max(order_date) 값이 이 조건을 만족할 가능성이 있는가?
만약
max(order_date) < '2024-01-01'이라면, 이 행 그룹은 애초에 조건을 만족할 수 없습니다.
즉, 해당 행 그룹은 처음부터 제외됩니다(dead on arrival).
따라서 이 그룹의 컬럼 청크를 스캔할 필요도 없습니다.
- 디스크 읽기(I/O) 없음
- CPU 처리 없음
그냥 통째로 건너뜁니다.
통계 기반 프루닝(Statistics-Driven Pruning) 실제 동작 방식
예를 들어 여러분의 orders 테이블이 order_date 기준으로 논리적으로 정렬되어 있다고 가정해 보겠습니다.
그러면 행 그룹(Row Group)은 다음과 같은 형태를 가질 수 있습니다:
RG1: order_date 2019-01-01 → 2019-12-31
RG2: order_date 2020-01-01 → 2020-12-31
RG3: order_date 2021-01-01 → 2021-12-31
RG4: order_date 2022-01-01 → 2022-12-31
RG5: order_date 2023-01-01 → 2023-12-31
RG6: order_date 2024-01-01 → 2024-11-01
다음과 같은 질의라면,
SELECT COUNT(*)
FROM orders
WHERE order_date >= DATE '2023-07-01';
DuckDB는 개념적으로 다음과 같은 방식으로 동작합니다.
RG1: 최대 날짜 2019–12–31 < 2023–07–01 → 건너뜀
RG2: 최대 날짜 2020–12–31 < 2023–07–01 → 건너뜀
RG3: 최대 날짜 2021–12–31 < 2023–07–01 → 건너뜀
RG4: 최대 날짜 2022–12–31 < 2023–07–01 → 건너뜀
RG5: 조건 구간과 겹침 → 스캔
RG6: 조건 구간과 겹침 → 스캔
단순한 필터 조건 하나만으로도 테이블의 약 66%를 읽지 않고 건너뛴 것입니다.
Parquet 파일이나 디스크에 저장된 대용량 DuckDB 테이블에서는 이것이 성능 차이를 극적으로 만들어 냅니다.
단순히 “그럭저럭 빠르네(meh)” 수준이 아니라,
“왜 이렇게 빠르지?(wow)” 라는 느낌을 만드는 핵심 원리입니다.
DuckDB에서 프루닝 확인하기 (정말 동작하는지 확인하는 방법)
여기까지 보면 이런 생각이 들 수 있습니다.
“좋은데… 실제로 DuckDB가 프루닝을 하고 있는지는 어떻게 확인하지?”
DuckDB는 이를 확인할 수 있도록 프로파일링(Profiling) 과 실행 계획 설명(Explain) 도구를 제공합니다:
PRAGMA enable_profiling = json;
PRAGMA profiling_output = 'profile.json';
SELECT
customer_id,
SUM(amount) AS total_spent
FROM orders
WHERE order_date >= DATE '2024-01-01'
GROUP BY customer_id;
그 다음 생성된 profile.json 파일을 확인하거나, 더 간단하게 EXPLAIN ANALYZE 를 사용할 수 있습니다:
EXPLAIN ANALYZE
SELECT
customer_id,
SUM(amount) AS total_spent
FROM orders
WHERE order_date >= DATE '2024-01-01'
GROUP BY customer_id;
실행 계획에서는 다음 항목들을 확인하면 됩니다.
- 스캔 연산자
예: SEQ_SCAN, PARQUET_SCAN - 테이블 전체 행 수 대비 실제로 읽은 행 수
- 경우에 따라 행 그룹(row groups)이나 스트라이프(stripes)가 건너뛰어졌다는 명시적인 표시
Parquet 파일을 직접 읽고 있다면:
SELECT *
FROM 's3://bucket/warehouse/orders.parquet'
WHERE order_date >= DATE '2024-01-01';
DuckDB는 Parquet의 행 그룹 통계(Row Group Statistics) 도 프루닝에 활용하려고 시도합니다.
아이디어는 동일합니다.
저장 형식이 DuckDB 내부 테이블이냐 Parquet 파일이냐만 다를 뿐입니다.
프루닝이 진가를 발휘하는 순간
프루닝은 다음과 같은 상황에서 특히 큰 효과를 냅니다.
1. 필터 조건의 선택성이 높을 때 (Selective Filters)
order_date, region, tenant_id 같은 컬럼으로 자주 필터링하고, 해당 컬럼들이 데이터 저장 구조와 어느 정도 연관되어 있다면 DuckDB는 파일의 상당 부분을 읽지 않고 건너뛸 수 있습니다.
2. 데이터가 클러스터링되거나 정렬되어 있을 때
데이터가 완전히 무작위로 분포되어 있다면 각 행 그룹(Row Group)은 전체 테이블의 축소판처럼 보입니다.
그 결과 통계 정보가 구별력을 잃게 되고 프루닝 효과가 줄어듭니다.
반대로 order_date 나 customer_id 기준으로 데이터를 클러스터링해 두면, 행 그룹은 특정 특성을 가진 블록처럼 변합니다:
RG1: mostly 2019
RG2: mostly 2020
…
RGN: mostly 2024
이제 단순한 날짜 필터 하나만으로도 저장된 데이터의 큰 구간을 통째로 제외할 수 있습니다.
3. Parquet 또는 대용량 디스크 기반 데이터를 조회할 때
메모리에 올라와 있는 테이블은 이미 충분히 빠릅니다. 하지만 대용량 Parquet 파일을 조회할 때는 디스크 I/O를 피하는 것이 전부라고 해도 과언이 아닙니다.
행 그룹 프루닝(Row Group Pruning)은 결국 이런 의미입니다.
“저 기가바이트 단위의 데이터는 아예 건드리지 말자.”
프루닝이 잘 되는 테이블 설계하기
원본 데이터가 어떤 형태로 들어오는지 항상 통제할 수 있는 것은 아닙니다. 하지만 통제할 수 있다면, 몇 가지 전략만으로도 DuckDB를 눈에 띄게 빠르게 만들 수 있습니다.
1. 가장 자주 필터링하는 컬럼 기준으로 클러스터링하거나 정렬하기
다음과 같은 쿼리를 자주 실행한다면:
WHERE event_date BETWEEN ... AND ...
그렇다면 데이터를 event_date 기준으로 정렬해서 저장하는 것은 미래의 나에게 주는 무료 성능 선물과 같습니다.
예를 들어 DuckDB 파이프라인에서는 다음과 같이 할 수 있습니다:
CREATE TABLE events_sorted AS
SELECT *
FROM events
ORDER BY event_date;
또는 상위 단계에서 Parquet 파일을 생성한다면, 예를 들어 Spark나 Polars 등을 사용할 때도 동일한 키를 기준으로 파티셔닝하고 정렬해 두는 것이 좋습니다.
2. 행 그룹(Row Groups) 크기를 적절하게 유지하기
DuckDB는 기본적으로 합리적인 설정값을 선택하지만, 원리는 단순합니다.
- 너무 작은 행 그룹 → 메타데이터 오버헤드가 증가하고, 한 번 건너뛰었을 때 얻는 이점이 작아집니다.
- 너무 큰 행 그룹 → 통계 정보가 희석됩니다(한 그룹 안에 데이터 다양성이 너무 많아짐).
즉, 이상적인 상태는 다음과 같습니다.
각 행 그룹이 오버헤드를 충분히 상쇄할 만큼 크면서도, 프루닝이 가능하도록 일정한 특성을 가진 데이터 묶음이 되는 것
다시 말해 행 그룹은 단순히 크게 만드는 것이 아니라, 통계 정보가 의미 있게 작동할 정도로 응집력 있게 구성되는 것이 중요합니다.
3. 불필요한 타입 혼란 피하기
타임스탬프를 문자열로 저장한 뒤 쿼리할 때마다 즉석에서 파싱한다면, 통계 정보의 유용성이 떨어지고 조건절이 프루닝에 잘 활용되지 못할 수 있습니다.
올바른 데이터 타입을 사용하세요:
ALTER TABLE events
ALTER COLUMN event_ts TYPE TIMESTAMP;
데이터 타입이 깔끔할수록 DuckDB의 통계 정보도 더 똑똑하게 동작합니다.
직관적으로 이해하기: 책에서 필요한 장만 골라 읽는 것과 같다
지금까지 설명한 내용이 조금 추상적으로 느껴진다면, 다음 비유를 생각해 봅시다.
1,000페이지짜리 역사책이 있는데 여러분은 제2차 세계대전 내용만 알고 싶다고 가정해 보겠습니다.
어떤 방식을 선택하시겠습니까?
- 1페이지부터 끝까지 전부 읽는다.
- “1939–1945” 라고 적힌 장(Chapter)으로 바로 이동해서 읽기 시작한다.
DuckDB의 동작을 여기에 대응시키면:
- 행 그룹(Row Groups) → 책의 장(Chapter)
- 통계 정보(Statistics) → 각 장의 제목과 요약
- 프루닝(Pruning) → 필요한 장만 펼치고 나머지는 넘기는 행동
DuckDB는 모든 내용을 읽고 나서 판단하지 않습니다.
먼저 통계 정보를 보고,
“이 구간에는 내가 찾는 내용이 없네.”
라고 판단하면 그 구간 자체를 열어보지 않습니다.
통계와 프루닝이 없다면, 쿼리는 매번 1페이지부터 1,000페이지까지 전부 읽어야 합니다.
마무리: DuckDB가 데이터와 싸우지 않고 함께 일하게 만들기
DuckDB의 속도는 마법이 아닙니다. 핵심은 단 하나입니다.
필요 없는 데이터는 철저하게 읽지 않는다.
이를 위해 DuckDB는 다음과 같이 동작합니다.
- 테이블을 행 그룹(Row Groups) 으로 나눈다.
- 각 그룹에 컬럼 통계(최소값, 최대값, NDV 등) 를 저장한다.
- 필터가 들어오면 DuckDB는 이 통계를 이용해 전체 행 그룹을 프루닝(Pruning) 한다.
- 프루닝을 통해 디스크 I/O, CPU 연산, 메모리 대역폭 사용을 모두 줄인다.
만약 쿼리 시간을 수 초에서 수 밀리초 수준으로 줄이고 싶다면, 다음 원칙을 기억하세요.
- 가장 자주 사용하는 필터 컬럼 기준으로 클러스터링하거나 정렬하기
- 적절한 데이터 타입과 안정적인 스키마 사용하기
- 대용량 분석 데이터는 DuckDB가 효율적으로 스캔할 수 있는 Parquet 또는 DuckDB 테이블 형식으로 저장하기
- EXPLAIN ANALYZE 와 프로파일링을 사용해 실제로 프루닝이 발생하는지 검증하기
DuckDB가 빠른 이유는 연산을 더 열심히 해서가 아닙니다.
오히려 연산 자체를 하지 않는 데 매우 능하기 때문입니다.
읽지 않아도 되는 데이터를 읽지 않고, 계산하지 않아도 되는 데이터를 계산하지 않는 것.
그것이 DuckDB가 노트북에서도 수억 행 분석을 빠르게 수행할 수 있는 핵심 원리입니다.
이번 설명이 도움이 되었다면 다음 성능 디버깅 때 다시 참고해 보세요. 앞으로는 벡터화 실행(Vectorized Execution) 과 조인 전략(Join Strategies) 에 대해서도 더 깊게 다룰 예정입니다.
'EPL과 유튜브 데이터로 배우는 DuckDB' 카테고리의 다른 글
| DuckDB가 판을 바꾼다: 다중 쓰기가 가능한 프로토콜, Quack 만나보기 (0) | 2026.05.30 |
|---|---|
| 고급 SQL 분석(Analytics)을 마스터하는 9가지 방법 (0) | 2026.05.28 |
| 컬럼형 저장방식의 작동 원리 (0) | 2026.05.23 |
| 데이터 엔지니어를 위한 DuckDB (0) | 2026.05.22 |
| 작은 거인들: 제한된 리소스 환경에서 Postgres, MySQL, ClickHouse, DuckDB 벤치마킹하기 (0) | 2026.05.20 |
댓글