┌─────────┬─────────┬───────┬───────┐
│ 지점 │ 품목 │ 수량 │ 금액 │
│ varchar │ varchar │ int64 │ int64 │
├─────────┼─────────┼───────┼───────┤
│ 서울 │ 커피 │ 10 │ 45000 │
│ 부산 │ 커피 │ 7 │ 31500 │
│ 대구 │ 커피 │ 5 │ 22500 │
└─────────┴─────────┴───────┴───────┘
18 DuckDB
이 장에서는 pandas로 분석을 하다가 데이터가 커지거나 표가 여러 개로 흩어졌을 때, 무거운 계산만 SQL 엔진인 DuckDB에 맡기고 그 결과를 다시 pandas로 받아 마무리하는 방법을 익힙니다. pandas를 버리고 새 도구로 갈아타는 것이 아니라, 지금까지 배운 분석 흐름 중간에 SQL 한 조각을 끼워 넣는 것이 목표입니다. SQL을 처음 보더라도
SELECT,WHERE,GROUP BY세 가지만 알면 이 장의 대부분을 따라올 수 있습니다.
18.1 pandas만으로 버거워지는 순간
지금까지 배운 pandas만으로도 수백만 행 정도는 충분히 다룰 수 있습니다. 그런데 실무에서 분석을 하다 보면 다음 세 가지 상황에서 코드가 갑자기 길어지거나 노트북이 멈춥니다.
파일이 메모리보다 큽니다.
pd.read_parquet()을 실행하면 몇 분을 기다리다가 커널이 죽습니다. 정작 필요한 것은 열 두어 개와 요약된 몇십 행인데, 전체를 메모리에 올리고 나서 걸러내기 때문입니다.표가 여러 개로 흩어져 있습니다. 15의 Instacart 데이터처럼 주문, 상품, 부서, 통로가 각각 다른 파일에 있으면
merge()를 네 번 이어 붙여야 하고, 그때마다 중간 결과가 통째로 메모리에 만들어집니다.집계 로직이 복잡합니다. “그룹별로 이동평균을 구한 다음, 그룹마다 가장 최근 행만 남겨라” 같은 요구를 pandas로 쓰면
groupby().rolling().reset_index(0, drop=True)같은 관용구가 등장하는데, 몇 주 뒤에 다시 읽으면 무슨 뜻인지 알기 어렵습니다.
DuckDB는 이 세 가지를 SQL 한 덩어리로 처리해 주는 분석용 데이터베이스입니다. 그리고 계산 결과를 바로 pandas DataFrame으로 돌려줍니다. 그래서 다음과 같은 역할 분담이 자연스럽게 만들어집니다.
DuckDB: 큰 파일을 읽고, 거르고(filter), 붙이고(join), 요약합니다(group by). 결과는 대개 작습니다.
pandas: 작아진 결과를 받아 파생 변수를 만들고, 통계를 내고, 그래프를 그리고, 모델에 넣습니다.
18.2 DuckDB는 어떤 도구인가
DuckDB는 pip install duckdb 한 줄이면 끝나는 라이브러리입니다. 서버를 띄우거나 접속 정보를 설정할 필요가 없고, import duckdb를 하면 Python 프로세스 안에서 데이터베이스 엔진이 함께 동작합니다. 이런 방식을 인프로세스(in-process) 데이터베이스라고 부릅니다.
이름이 비슷한 SQLite와 비교하면 성격이 분명해집니다. SQLite는 “주문 한 건을 저장하고, 회원 한 명을 조회하는” 트랜잭션 처리(OLTP)에 맞춰져 있고, DuckDB는 “3천만 건을 한꺼번에 훑어 부서별 평균을 내는” 분석 처리(OLAP)에 맞춰져 있습니다. 이 차이는 저장 방식에서 나옵니다. DuckDB는 데이터를 행이 아니라 열 단위로 모아서 저장하고 계산합니다. 부서별 평균을 구할 때 department와 reordered 두 열만 읽으면 되므로, 나머지 열은 디스크에서 꺼내지도 않습니다. 4에서 살펴본 Parquet과 Arrow의 열 지향 구조와 같은 아이디어입니다.
이 장에서 쓰는 SQL은 다음 다섯 조각이 거의 전부입니다.
SELECT 열— 어떤 열을 볼지FROM 대상— 어디에서 가져올지WHERE 조건— 어떤 행만 볼지GROUP BY 열— 무엇을 기준으로 묶을지ORDER BY 열— 어떤 순서로 볼지
다음 절의 대응표를 보면, 이미 알고 있는 pandas 코드와 하나씩 짝이 맞는다는 것을 알 수 있습니다.
18.3 첫 쿼리: DataFrame을 SQL로 조회하기
가장 먼저 익힐 것은 딱 한 줄입니다. 손에 들고 있는 DataFrame의 변수 이름을 FROM 뒤에 그대로 적으면 됩니다.
sales는 그냥 Python 변수인데 SQL의 FROM 절에 테이블 이름처럼 등장했습니다. DuckDB는 SQL을 해석하다가 모르는 이름을 만나면 현재 Python 스코프에서 같은 이름의 변수를 찾아 테이블처럼 사용합니다. 등록하거나 변환하는 절차가 필요 없습니다.
결과를 화면에 보기만 할 때는 위처럼 그대로 두면 되고, 분석을 이어가려면 .df()를 붙여 DataFrame으로 받습니다.
| 지점 | 총수량 | 총금액 | 평균단가 | |
|---|---|---|---|---|
| 0 | 서울 | 14.0 | 57000.0 | 4071.428571 |
| 1 | 부산 | 10.0 | 40500.0 | 4050.000000 |
| 2 | 대구 | 5.0 | 22500.0 | 4500.000000 |
SQL로 요약하고, 받아온 DataFrame에 pandas로 파생 열을 붙였습니다. 이 장에서 반복해서 보게 될 기본 형태입니다.
| 코드 | 돌려주는 것 | 언제 쓰나 |
|---|---|---|
duckdb.sql(q) |
관계(relation) 객체 | 결과를 눈으로 확인만 할 때 |
duckdb.sql(q).df() |
pandas DataFrame | 분석을 이어갈 때 (가장 많이 씀) |
duckdb.sql(q).fetchone() |
튜플 한 개 | 개수나 합계 하나만 필요할 때 |
duckdb.sql(q)만 쓰면 쿼리 결과가 곧바로 계산되지 않고, 화면에 보여줄 만큼만 가져옵니다. 큰 데이터에서 결과를 먼저 훑어볼 때 편리합니다.
18.4 pandas 문법과 SQL 문법 짝 맞추기
SQL이 낯설게 느껴지는 이유는 대부분 “무엇에 해당하는지” 몰라서입니다. 이미 알고 있는 pandas 코드와 짝을 지어 두면 훨씬 빨리 익숙해집니다.
| 하는 일 | pandas | DuckDB SQL |
|---|---|---|
| 열 고르기 | df[["a", "b"]] |
SELECT a, b |
| 행 거르기 | df[df["a"] > 10], df.query("a > 10") |
WHERE a > 10 |
| 정렬 | df.sort_values("a", ascending=False) |
ORDER BY a DESC |
| 앞에서 n개 | df.head(5) |
LIMIT 5 |
| 그룹 집계 | df.groupby("g")["v"].mean() |
SELECT g, AVG(v) ... GROUP BY g |
| 집계 결과에 조건 | 집계 후 다시 필터 | HAVING COUNT(*) > 100 |
| 파생 열 | df.assign(x=...) |
SELECT ..., 식 AS x |
| 열 이름 변경 | df.rename(columns=...) |
열 AS 새이름 |
| 병합 | a.merge(b, on="key") |
FROM a JOIN b ON a.key = b.key |
| 세로로 붙이기 | pd.concat([a, b]) |
SELECT ... UNION ALL SELECT ... |
| 중복 제거 | df.drop_duplicates() |
SELECT DISTINCT ... |
| 고유값 개수 | df["c"].nunique() |
COUNT(DISTINCT c) |
| 결측 개수 | df["c"].isna().sum() |
COUNT(*) - COUNT(c) |
| 그룹 내 순위 | df.groupby("g")["v"].rank() |
rank() OVER (PARTITION BY g ORDER BY v) |
| 이동평균 | df["v"].rolling(7).mean() |
AVG(v) OVER (ORDER BY t ROWS 6 PRECEDING) |
| 요약 통계 | df.describe() |
SUMMARIZE df |
| melt / pivot | melt(), pivot_table() |
UNPIVOT, PIVOT |
같은 분석을 두 방식으로 나란히 써 보면 대응 관계가 분명해집니다. 타이타닉 데이터에서 “요금이 20을 넘는 승객을 등급과 성별로 묶어 인원수와 평균 나이를 구하는” 분석입니다.
아래는 pandas를 사용한 방식입니다.
| Pclass | Sex | n | avg_age | |
|---|---|---|---|---|
| 0 | 1 | female | 94 | 34.611763 |
| 1 | 1 | male | 116 | 41.437317 |
| 2 | 2 | female | 41 | 25.775000 |
| 3 | 2 | male | 39 | 28.585384 |
| 4 | 3 | female | 37 | 22.076923 |
| 5 | 3 | male | 49 | 15.861111 |
아래는 SQL을 사용한 방식입니다.
| Pclass | Sex | n | avg_age | |
|---|---|---|---|---|
| 0 | 1 | female | 94 | 34.611765 |
| 1 | 1 | male | 116 | 41.437320 |
| 2 | 2 | female | 41 | 25.775000 |
| 3 | 2 | male | 39 | 28.585385 |
| 4 | 3 | female | 37 | 22.076923 |
| 5 | 3 | male | 49 | 15.861111 |
두 결과가 같은지 확인해보겠습니다.
두 방식의 결과가 같습니다.
WHERE가 df[...] 필터에, GROUP BY가 groupby()에, COUNT(*)와 AVG()가 agg()의 집계 함수에 그대로 대응합니다. 비교할 때 check_dtype=False를 준 이유는 값은 같아도 정수의 폭 같은 dtype이 엔진마다 다르기 때문입니다(뒤의 「무엇을 언제 쓸까」에서 다시 설명합니다). 여기까지는 어느 쪽을 써도 좋습니다. 데이터가 이미 메모리에 있고 크기도 부담 없다면 pandas가 더 편합니다. DuckDB가 필요해지는 것은 다음 절부터입니다.
SQL은 SELECT부터 적지만, 실제로는 FROM → WHERE → GROUP BY → HAVING → SELECT → ORDER BY → LIMIT 순서로 실행됩니다. pandas로 옮기면 “읽고 → 거르고 → 묶고 → 묶은 결과를 거르고 → 필요한 열을 만들고 → 정렬하고 → 잘라내는” 순서입니다.
이 순서를 알아두면 두 가지가 이해됩니다. 첫째, WHERE는 그룹으로 묶기 전에 적용되므로 COUNT(*) > 100 같은 집계 조건을 쓸 수 없고, 그때는 HAVING을 씁니다. 둘째, SELECT에서 만든 별칭(AS)은 WHERE에서 쓸 수 없지만 ORDER BY에서는 쓸 수 있습니다.
18.5 파일을 열지 않고 그대로 분석하기
DuckDB를 쓰는 가장 실용적인 이유는 여기에 있습니다. FROM 절에 파일 경로를 문자열로 적으면, 파일을 메모리에 올리지 않고 바로 쿼리할 수 있습니다.
| department_id | n_products | |
|---|---|---|
| 0 | 11 | 6563 |
| 1 | 19 | 6264 |
| 2 | 13 | 5371 |
| 3 | 7 | 4365 |
| 4 | 1 | 4007 |
pd.read_parquet()을 부르지 않았다는 점이 핵심입니다. DuckDB는 Parquet 파일의 머리말(메타데이터)을 먼저 읽어 어떤 열이 어디에 있는지 파악한 뒤, 쿼리에 필요한 열과 행 그룹만 골라서 읽습니다. 4에서 배운 컬럼 투영(projection)과 조건 푸시다운(predicate pushdown)이 SQL을 쓰는 것만으로 자동 적용되는 셈입니다.
CSV도 마찬가지이며, 파일 확장자를 보고 알아서 읽습니다.
| symbol | n | 시작 | 끝 | |
|---|---|---|---|---|
| 0 | AAPL | 11375 | 1980-12-12 | 2026-01-30 |
| 1 | GOOGL | 5397 | 2004-08-19 | 2026-01-30 |
| 2 | MSFT | 10049 | 1986-03-13 | 2026-01-30 |
18.5.1 전체 모습 훑어보기: SUMMARIZE
새 파일을 받았을 때 가장 먼저 하는 일은 df.describe()나 df.info()로 전체를 훑는 것입니다. DuckDB에는 SUMMARIZE가 있습니다. 역시 파일을 적재하지 않고 실행됩니다.
| column_name | column_type | ... | count | null_percentage | |
|---|---|---|---|---|---|
| 0 | PassengerId | INTEGER | ... | 891 | 0.0 |
| 1 | Survived | TINYINT | ... | 891 | 0.0 |
| 2 | Pclass | TINYINT | ... | 891 | 0.0 |
| ... | ... | ... | ... | ... | ... |
| 9 | Fare | DOUBLE | ... | 891 | 0.0 |
| 10 | Cabin | VARCHAR | ... | 891 | 0.0 |
| 11 | Embarked | VARCHAR | ... | 891 | 0.0 |
12 rows × 6 columns
숫자 열만 보여주는 describe()와 달리 문자열 열까지 포함하고, 결측 비율(null_percentage)을 함께 보여줍니다. 파일이 커서 열어보기 부담스러울 때 첫 탐색 도구로 유용합니다.
18.5.2 여러 파일을 한 테이블처럼
경로에 와일드카드(*)를 쓰면 여러 파일을 한 테이블처럼 다룰 수 있습니다. 월별로 나뉜 로그 파일 수십 개를 pd.concat([pd.read_csv(f) for f in files])로 이어 붙이던 작업이 한 줄이 됩니다.
저장소의 titanic_partitioned/는 Survived=0, Survived=1 두 디렉터리로 나뉘어 있습니다. 이렇게 디렉터리 이름에 값을 넣어 나누는 방식을 Hive 파티션이라고 하는데, hive_partitioning=true를 주면 디렉터리 이름에 들어 있는 Survived 값을 열로 되살려 줍니다.
| Survived | n | avg_fare | |
|---|---|---|---|
| 0 | 0 | 549 | 22.117887 |
| 1 | 1 | 342 | 48.395408 |
여기에 WHERE Survived = 1을 붙이면 DuckDB는 Survived=0 디렉터리를 아예 열지 않습니다. 필요 없는 파일을 건너뛰는 이 동작을 파티션 가지치기(partition pruning)라고 합니다. 데이터가 수백 개 파일로 나뉘어 있고 그중 일부만 필요할 때 효과가 큽니다.
18.6 DuckDB로 줄이고 pandas로 마무리하기
이제 이 장의 핵심 패턴을 하나의 분석으로 이어 보겠습니다. Instacart 데이터에서 부서별 재주문률을 구하고 그래프로 그리는 작업입니다. 주문 상품(3,243만 행), 상품, 부서 세 파일이 따로 있으므로 pandas만으로 하면 read_parquet() 세 번과 merge() 두 번이 필요하고, 중간 결과가 메모리에 크게 만들어집니다.
SQL로는 파일을 읽는 단계 없이 한 번에 끝납니다.
D = "../data/instacart-market-basket-analysis-parquet"
dept = duckdb.sql(
f"""
SELECT d.department,
COUNT(*) AS n_items,
AVG(op.reordered) AS reorder_rate
FROM '{D}/order_products__prior.parquet' op
JOIN '{D}/products.parquet' p ON op.product_id = p.product_id
JOIN '{D}/departments.parquet' d ON p.department_id = d.department_id
GROUP BY d.department
HAVING COUNT(*) >= 100000
ORDER BY reorder_rate DESC
"""
).df()
print(f"결과 크기: {dept.shape}")
dept.head()결과 크기: (17, 3)
| department | n_items | reorder_rate | |
|---|---|---|---|
| 0 | dairy eggs | 5414016 | 0.669969 |
| 1 | beverages | 2690129 | 0.653460 |
| 2 | produce | 9479291 | 0.649913 |
| 3 | bakery | 1176787 | 0.628141 |
| 4 | deli | 1051249 | 0.607719 |
3,243만 행짜리 파일이 포함된 3중 조인과 집계를 거쳤지만, 손에 남은 것은 스무 줄이 채 되지 않는 작은 DataFrame입니다. 여기서부터는 익숙한 pandas 작업입니다.
| department | n_items | reorder_rate | 비중 | 재주문률 | |
|---|---|---|---|---|---|
| 0 | dairy eggs | 5414016 | 0.669969 | 0.168154 | 67.0 |
| 1 | beverages | 2690129 | 0.653460 | 0.083553 | 65.3 |
| 2 | produce | 9479291 | 0.649913 | 0.294418 | 65.0 |
| 3 | bakery | 1176787 | 0.628141 | 0.036550 | 62.8 |
| 4 | deli | 1051249 | 0.607719 | 0.032651 | 60.8 |
import matplotlib.pyplot as plt
from platform import system
if system() == "Windows":
plt.rcParams["font.family"] = "Malgun Gothic"
elif system() == "Darwin":
plt.rcParams["font.family"] = "Apple SD Gothic Neo"
else:
plt.rcParams["font.family"] = "NanumGothic"
plt.rcParams["axes.unicode_minus"] = False
top10 = dept.head(10).sort_values("재주문률")
fig, ax = plt.subplots(figsize=(7, 5))
ax.barh(
top10["department"], top10["재주문률"], color="#2171b5"
)
ax.set_xlabel("재주문률 (%)")
ax.set_title("재주문률 상위 10개 부서")
ax.grid(True, axis="x", alpha=0.3)
for y, v in zip(top10["department"], top10["재주문률"]):
ax.text(v, y, f" {v}", va="center")
plt.tight_layout()
plt.show()
이 흐름이 이 장에서 가장 중요합니다. 큰 데이터를 다루는 부분은 SQL 한 덩어리로 끝내고, 그래프와 파생 변수 계산처럼 눈으로 확인하며 다듬는 작업은 pandas로 합니다. 반대로 하려고 하면(모두 pandas로 읽어서 처리하거나, 그래프까지 SQL로 만들려고 하면) 어느 쪽이든 불편해집니다.
기준은 간단합니다. 행 수를 줄이는 작업은 SQL로, 줄어든 결과를 다듬는 작업은 pandas로. 필터, 조인, 그룹 집계는 결과를 작게 만들므로 SQL이 맡고, 비율 계산·반올림·라벨 정리·시각화는 이미 작아진 표에 대한 작업이므로 pandas가 맡습니다.
18.7 pandas보다 SQL이 편한 세 가지 연산
앞 절까지는 “pandas로도 되지만 데이터가 커서” DuckDB를 썼습니다. 이번 절은 성격이 다릅니다. 데이터가 작아도 SQL로 쓰는 편이 읽기 쉬운 연산들입니다.
18.7.1 그룹별 이동평균과 그룹별 상위 N개
16에서 종목별 이동평균을 구할 때 groupby().rolling() 관용구를 썼습니다. SQL에서는 윈도우 함수가 같은 일을 합니다.
| symbol | date | Close | ma7 | |
|---|---|---|---|---|
| 0 | AAPL | 2024-06-03 | 194.029999 | 194.029999 |
| 1 | AAPL | 2024-06-04 | 194.350006 | 194.190002 |
| 2 | AAPL | 2024-06-05 | 195.869995 | 194.750000 |
| 3 | AAPL | 2024-06-06 | 194.479996 | 194.682499 |
| 4 | AAPL | 2024-06-07 | 196.889999 | 195.123999 |
읽는 방법은 이렇습니다.
PARTITION BY symbol→groupby("symbol"): 종목별로 따로 계산합니다.ORDER BY date→ 시간 순으로 정렬한 상태에서ROWS 6 PRECEDING→rolling(7): 앞의 6개 행과 자기 자신, 즉 7개 행을 봅니다.
groupby()와 달리 행이 줄어들지 않는다는 점이 중요합니다. 원본 각 행 옆에 계산 결과가 한 열로 붙습니다. pandas의 transform()과 같은 동작입니다.
여기에 “종목별 가장 최근 행만” 같은 조건을 붙이려면 QUALIFY를 씁니다. 윈도우 함수 결과에 조건을 거는 절로, WHERE가 윈도우 계산 전에 적용되어 쓸 수 없는 자리를 채워 줍니다.
| symbol | date | Close | ma7 | |
|---|---|---|---|---|
| 0 | AAPL | 2026-01-30 | 259.480011 | 254.895715 |
| 1 | GOOGL | 2026-01-30 | 338.000000 | 334.077144 |
| 2 | MSFT | 2026-01-30 | 430.290009 | 459.052861 |
“그룹별 상위 N개”는 pandas에서 sort_values().groupby().head(n)이나 nlargest로 쓰는데, 조건이 조금만 복잡해져도 코드가 늘어납니다. QUALIFY는 그 자리를 한 줄로 대신합니다.
18.7.2 가장 가까운 과거 시점과 잇기: ASOF JOIN
시계열 두 개를 합칠 때 키가 정확히 일치하지 않는 경우가 많습니다. 주가는 거래일에만 있고 금리는 공표일에만 있어서 “이 거래일 기준으로 가장 최근에 발표된 금리”를 붙이고 싶은 상황입니다. 일반 조인으로는 표현하기 번거로운 이 연산을 ASOF JOIN이 직접 지원합니다.
asof_sql = duckdb.sql(
"""
SELECT s.date AS trade_date, s.Close AS aapl_close, b.Close AS y10
FROM (SELECT date::DATE AS date, Close
FROM '../data/stocks_aapl_googl_msft.csv'
WHERE symbol = 'AAPL' AND date >= '2024-01-01') s
ASOF JOIN (SELECT date::DATE AS date, Close
FROM '../data/fred_us_bond_yield.csv'
WHERE symbol = 'US10YT' AND Close IS NOT NULL) b
ON s.date >= b.date
ORDER BY s.date
"""
).df()
asof_sql.head()| trade_date | aapl_close | y10 | |
|---|---|---|---|
| 0 | 2024-01-02 | 185.639999 | 3.946 |
| 1 | 2024-01-03 | 184.250000 | 3.907 |
| 2 | 2024-01-04 | 181.910004 | 3.991 |
| 3 | 2024-01-05 | 181.179993 | 4.042 |
| 4 | 2024-01-08 | 185.559998 | 4.002 |
ON s.date >= b.date가 핵심입니다. “조건을 만족하는 것 중 가장 가까운 하나”만 붙인다는 뜻이며, 미래 값을 끌어오지 않으므로 시계열 분석에서 look-ahead 편향을 막아 줍니다.
pandas에도 같은 기능이 merge_asof()로 있습니다.
stock = pd.read_csv(
"../data/stocks_aapl_googl_msft.csv",
parse_dates=["date"],
)
stock = (
stock[
(stock["symbol"] == "AAPL")
& (stock["date"] >= "2024-01-01")
][["date", "Close"]]
.rename(
columns={
"date": "trade_date",
"Close": "aapl_close",
}
)
.sort_values("trade_date")
)
bond = pd.read_csv(
"../data/fred_us_bond_yield.csv", parse_dates=["date"]
)
bond = (
bond[
(bond["symbol"] == "US10YT") & bond["Close"].notna()
][["date", "Close"]]
.rename(columns={"Close": "y10"})
.sort_values("date")
)
asof_pd = pd.merge_asof(
stock,
bond,
left_on="trade_date",
right_on="date",
direction="backward",
)
asof_pd[["trade_date", "aapl_close", "y10"]].head()| trade_date | aapl_close | y10 | |
|---|---|---|---|
| 0 | 2024-01-02 | 185.639999 | 3.946 |
| 1 | 2024-01-03 | 184.250000 | 3.907 |
| 2 | 2024-01-04 | 181.910004 | 3.991 |
| 3 | 2024-01-05 | 181.179993 | 4.042 |
| 4 | 2024-01-08 | 185.559998 | 4.002 |
direction="backward"가 SQL의 ON s.date >= b.date에 해당합니다. 두 방식은 같은 행을 골랐습니다.
행 수: 522 vs 522, 값 일치: True
==로 비교하지 마세요
위에서 equals()나 == 대신 np.allclose()를 쓴 이유가 있습니다. 4.652 같은 십진 소수는 이진 부동소수점으로 정확히 표현되지 않고, CSV를 읽는 파서마다 마지막 비트 처리가 미세하게 다를 수 있습니다. 서로 다른 경로로 계산한 실수 결과를 대조할 때는 np.allclose()나 pd.testing.assert_frame_equal(..., check_exact=False)처럼 허용 오차를 두는 비교를 쓰는 것이 안전합니다. DuckDB에 국한된 이야기가 아니라 실수 비교 전반에 해당합니다.
18.7.3 merge_asof()는 정렬을 요구합니다
pandas의 merge_asof()는 양쪽 프레임이 조인 키로 미리 정렬되어 있어야 하며, 그렇지 않으면 오류가 납니다. 위 코드에 sort_values()를 붙인 이유입니다. SQL의 ASOF JOIN은 정렬을 신경 쓸 필요가 없습니다.
18.7.4 melt와 pivot: UNPIVOT과 PIVOT
이 책의 중심 주제인 재구조화도 SQL에 대응 구문이 있습니다. UNPIVOT이 melt()에, PIVOT이 pivot_table()에 해당합니다. 8에서 다룬 weather 데이터로 확인해 봅시다. d1~d31 열에 날짜가 들어 있는 전형적인 “열 헤더가 값” 유형입니다.
weather = pd.read_csv("../data/weather.csv")
tidy_sql = duckdb.sql(
"""
SELECT id, year, month, day, element, value
FROM (UNPIVOT weather
ON COLUMNS('d[0-9]+')
INTO NAME day VALUE value)
WHERE value IS NOT NULL
ORDER BY year, month, day
"""
).df()
print(f"long 형식 행 수: {len(tidy_sql)}")
tidy_sql.head()long 형식 행 수: 66
| id | year | ... | element | value | |
|---|---|---|---|---|---|
| 0 | MX17004 | 2010 | ... | tmax | 27.8 |
| 1 | MX17004 | 2010 | ... | tmin | 14.5 |
| 2 | MX17004 | 2010 | ... | tmax | 29.7 |
| 3 | MX17004 | 2010 | ... | tmin | 13.4 |
| 4 | MX17004 | 2010 | ... | tmax | 27.3 |
5 rows × 6 columns
COLUMNS('d[0-9]+')는 정규식으로 대상 열을 고르는 문법입니다. melt()에서 id_vars를 일일이 나열하는 대신 “녹일 열”을 패턴으로 지정할 수 있습니다.
이어서 element(tmax/tmin)를 열로 돌리면 tidy 형태가 완성됩니다. 8에서 melt 후 pivot을 적용한 것과 같은 흐름입니다.
| id | year | ... | tmax | tmin | |
|---|---|---|---|---|---|
| 0 | MX17004 | 2010 | ... | 27.8 | 14.5 |
| 1 | MX17004 | 2010 | ... | 27.3 | 14.4 |
| 2 | MX17004 | 2010 | ... | 29.7 | 13.4 |
| 3 | MX17004 | 2010 | ... | 24.1 | 14.4 |
| 4 | MX17004 | 2010 | ... | 29.9 | 10.7 |
5 rows × 6 columns
weather 정도 크기라면 pandas의 melt()와 pivot()이 훨씬 편합니다. UNPIVOT이 필요해지는 경우는 melt 결과가 메모리에 안 들어갈 때입니다. 열이 수천 개인 wide 데이터를 melt하면 행 수가 열 개수만큼 곱해지는데, DuckDB는 이 중간 결과를 통째로 만들지 않고 곧바로 집계로 넘길 수 있습니다.
18.8 정리한 결과를 저장하고 다시 쓰기
분석을 하다 보면 “전처리까지 끝난 상태”를 저장해 두고 다음에 이어서 하고 싶어집니다. CSV로 흩뿌리는 대신 DuckDB를 쓰면 두 가지 선택지가 있습니다.
첫째, Parquet 파일로 내보내기입니다. COPY ... TO를 쓰면 쿼리 결과를 바로 저장할 수 있고, PARTITION_BY를 주면 앞에서 본 파티션 구조를 직접 만들 수 있습니다.
['Pclass=1', 'Pclass=2', 'Pclass=3']
둘째, 데이터베이스 파일에 테이블로 저장하기입니다. duckdb.connect()에 파일 경로를 주면 디스크에 저장되는 데이터베이스가 만들어지고, CREATE TABLE ... AS SELECT(줄여서 CTAS)로 정제 결과를 테이블로 굳혀 둘 수 있습니다.
work = (
duckdb.connect()
) # 인메모리 DB (파일 경로를 주면 디스크에 저장)
work.execute(
"""
CREATE TABLE titanic_clean AS
SELECT PassengerId, Pclass, Sex, Age, Fare, Survived
FROM read_parquet('../data/titanic.parquet')
WHERE Age IS NOT NULL
"""
)
work.sql(
"SELECT Pclass, COUNT(*) AS n, AVG(Age) AS avg_age "
"FROM titanic_clean GROUP BY Pclass ORDER BY Pclass"
).df()| Pclass | n | avg_age | |
|---|---|---|---|
| 0 | 1 | 186 | 38.233441 |
| 1 | 2 | 173 | 29.877630 |
| 2 | 3 | 355 | 25.140620 |
duckdb.connect("analysis.duckdb")로 열었다면 이 테이블은 파일로 남아 다음 세션에서 그대로 조회됩니다. 여러 단계로 이어지는 분석에서 중간 산출물을 하나의 파일로 관리하는 방법입니다.
이미 만들어진 데이터베이스 파일을 읽을 수도 있습니다. 2에서 예고했던 “DuckDB로 연습해보기”를 여기서 해봅니다. 저장소 루트에 작은 도서관 데이터베이스가 들어 있습니다.
['Authors', 'Books', 'Borrowers', 'Borrowings', 'overdue_borrowings']
| genre | n | avg_year | |
|---|---|---|---|
| 0 | Fantasy | 2 | 1967.0 |
| 1 | Novel | 1 | 1837.0 |
| 2 | Classic | 1 | 1813.0 |
| 3 | Mystery | 1 | 1934.0 |
read_only=True로 열면 실수로 내용을 바꿀 위험이 없고, 여러 프로세스가 같은 파일을 동시에 읽을 수 있습니다. 관계형 데이터 모델을 SQL로 연습하고, 결과를 pandas로 받아 분석·시각화하는 조합은 학습용으로도 좋은 방법입니다.
18.9 자주 만나는 함정
pandas만 쓰다가 DuckDB를 섞으면 처음에 걸려 넘어지는 지점이 몇 군데 있습니다. 미리 알아두면 대부분 피할 수 있습니다.
18.9.1 변수를 찾는 범위와 이름 충돌
FROM 변수명이 편리한 대신 두 가지를 기억해야 합니다. 첫째, DuckDB가 찾는 것은 호출한 자리의 지역 변수와 전역 변수입니다. 함수 안에서 duckdb.sql()을 호출하면 다른 함수의 지역 변수는 보이지 않으므로, 밖에서 만든 DataFrame은 인자로 넘겨야 합니다. 둘째, Python 변수 이름이 실제 테이블이나 뷰 이름과 겹치면 어느 쪽이 선택될지 코드만 보고 알기 어렵습니다.
의도를 분명히 하려면 연결 객체에 이름을 직접 등록합니다.
18.9.2 열 이름에 공백이나 대문자가 있을 때
SQL에서 식별자는 큰따옴표로 감쌉니다. 열 이름에 공백이나 특수문자가 있으면 반드시 "..."로 묶어야 합니다. 값에 쓰는 작은따옴표('서울')와 역할이 다르므로 헷갈리지 않도록 합니다.
| 주문 수량 | 단가 | |
|---|---|---|
| 0 | 3 | 333.333333 |
| 1 | 5 | 400.000000 |
또 하나, DuckDB는 따옴표 없는 식별자의 대소문자를 구분하지 않습니다. 타이타닉 데이터의 Fare, Sex 열을 fare, sex로 써도 그대로 동작합니다. 대소문자를 엄격히 구분하는 pandas와 다른 점입니다.
| Fare | Sex | |
|---|---|---|
| 0 | 7.2500 | male |
| 1 | 71.2833 | female |
| 2 | 7.9250 | female |
다만 결과 DataFrame의 열 이름은 원본 대소문자를 따라 Fare, Sex로 돌아옵니다. SQL에서 소문자로 적었다고 해서 pandas에서도 df["fare"]로 접근하면 KeyError가 납니다.
18.9.3 결측(NULL)과 집계 함수
pandas의 mean()이 결측을 빼고 계산하듯, SQL의 집계 함수도 NULL을 무시합니다. 반면 COUNT(*)는 행 수를 세고 COUNT(열)은 그 열의 결측이 아닌 값의 개수를 셉니다. 이 차이를 이용하면 결측 개수를 바로 구할 수 있습니다.
pandas 결과: 177 29.699118
한편 결측 비교에는 주의가 필요합니다. SQL에서 WHERE Age = NULL은 아무 행도 찾지 못하며, WHERE Age IS NULL이라고 써야 합니다.
18.9.4 행 순서는 보장되지 않습니다
pandas는 연산을 해도 대체로 원래 순서가 유지되지만, SQL은 ORDER BY를 쓰지 않으면 행 순서를 보장하지 않습니다. 실행 계획이나 스레드 수에 따라 순서가 달라질 수 있습니다. 결과를 그래프로 그리거나 다른 표와 비교할 계획이라면 ORDER BY를 명시하거나, 받아온 뒤 pandas에서 sort_values()로 정렬하는 습관을 들이는 것이 좋습니다.
18.9.5 f-string 대신 매개변수 바인딩
쿼리에 변수를 끼워 넣을 때 f-string으로 문자열을 조립하고 싶은 유혹이 있습니다. 하지만 값이 그대로 SQL 문법에 섞여 들어가므로, 문자열에 따옴표가 들어 있거나 외부 입력을 받는 순간 쿼리가 깨지거나 위험해집니다. execute()의 두 번째 인자로 값을 넘기면 DuckDB가 값으로만 취급합니다.
| n | avg_age | |
|---|---|---|
| 0 | 139 | 35.629291 |
물음표(?) 자리에 값이 순서대로 들어갑니다. 앞 절들에서 파일 경로를 f-string으로 넣은 부분도, 경로가 사용자 입력에서 온다면 이렇게 바꾸는 것이 맞습니다.
18.9.6 왕복하면 dtype이 그대로 돌아올까
pandas에서 보낸 데이터를 DuckDB가 계산해 다시 돌려줄 때 dtype이 유지되는지는 확인해 둘 만합니다. 주요 dtype을 한 프레임에 담아 왕복시켜 봅시다.
mixed = pd.DataFrame(
{
"s": pd.Series(
["a", None], dtype="str"
), # 3.x 기본 문자열
"i": pd.Series(
[1, None], dtype="Int64"
), # 결측 가능 정수
"t": pd.to_datetime(["2024-01-01", "2024-01-02"]),
"c": pd.Series(["x", "y"], dtype="category"),
}
)
back = duckdb.sql("SELECT * FROM mixed").df()
pd.DataFrame(
{
"원본 dtype": mixed.dtypes.astype(str),
"왕복 후 dtype": back.dtypes.astype(str),
}
)| 원본 dtype | 왕복 후 dtype | |
|---|---|---|
| s | str | str |
| i | Int64 | Int64 |
| t | datetime64[us] | datetime64[us] |
| c | category | category |
문자열, 결측 가능 정수, 시각, 범주형 모두 원래 타입으로 돌아옵니다. pandas 2.x 시절 자료에는 “왕복하면 Int64가 float64가 되어 결측 정보가 깨진다”는 경고가 자주 등장하지만, 현재 버전 조합(pandas 3.x, DuckDB 1.5)에서는 그렇지 않습니다. TIMESTAMP가 datetime64[us]로 돌아오는 것도 pandas 3.x의 기본 시간 해상도가 마이크로초로 바뀐 덕분입니다(16 참고). 다만 버전에 따라 달라질 수 있는 영역이므로, 중요한 파이프라인이라면 위처럼 한 번 찍어보고 넘어가는 편이 안전합니다.
18.10 무엇을 언제 쓸까
“어떤 도구가 빠른가”보다 중요한 질문은 “지금 내 데이터와 작업에 무엇이 맞는가”입니다. 판단에 도움이 되도록 같은 분석을 세 가지 방식으로 재어 보겠습니다.
측정 대상은 Instacart의 order_products__prior.parquet입니다. 3,243만 행, 92MB로 노트북에서 다루기에 부담스럽지만 불가능하지는 않은 “애매한 규모”라 비교에 적합합니다. 질의는 다음과 같습니다.
“재주문(
reordered = 1)한 건만 골라, 장바구니에 담은 순서(add_to_cart_order)별로 건수를 세어라.”
세 방식 모두 워밍업으로 한 번 실행해 파일 캐시를 채운 뒤, 3회 측정해 중앙값을 취합니다. 1회 측정은 디스크 캐시나 다른 프로세스의 영향을 크게 받아 신뢰하기 어렵습니다.
import time
import statistics
PRIOR = "../data/instacart-market-basket-analysis-parquet/order_products__prior.parquet"
def measure(fn, repeat=3):
"""워밍업 1회 후 repeat회 측정하여 (중앙값 초, 결과)를 돌려준다."""
result = fn() # 워밍업 (측정에서 제외)
times = []
for _ in range(repeat):
start = time.perf_counter()
result = fn()
times.append(time.perf_counter() - start)
return statistics.median(times), result첫째, 파일을 통째로 읽어 pandas에서 처리하는 가장 익숙한 방식입니다.
def run_pandas():
df = pd.read_parquet(PRIOR)
return (
df[df["reordered"] == 1]
.groupby("add_to_cart_order")
.size()
.reset_index(name="n")
)
t_pandas, result_pandas = measure(run_pandas)
df_full = pd.read_parquet(PRIOR)
mem_mb = df_full.memory_usage(deep=True).sum() / 1e6
print(
f"pandas(전체 적재) : {t_pandas:.3f}초, 메모리 {mem_mb:,.0f} MB"
)
del df_fullpandas(전체 적재) : 0.240초, 메모리 1,038 MB
둘째, 같은 pandas라도 필요한 열만, 필요한 행만 읽는 방식입니다. 4에서 배운 컬럼 투영과 조건 푸시다운을 read_parquet() 인자로 지정합니다.
def run_pandas_optimized():
df = pd.read_parquet(
PRIOR,
columns=[
"add_to_cart_order",
"reordered",
], # 컬럼 투영
filters=[("reordered", "==", 1)], # 조건 푸시다운
)
return (
df.groupby("add_to_cart_order")
.size()
.reset_index(name="n")
)
t_pandas_opt, result_pandas_opt = measure(
run_pandas_optimized
)
df_opt = pd.read_parquet(
PRIOR,
columns=["add_to_cart_order", "reordered"],
filters=[("reordered", "==", 1)],
)
print(
f"pandas(투영+푸시다운) : {t_pandas_opt:.3f}초, "
f"메모리 {df_opt.memory_usage(deep=True).sum() / 1e6:,.0f} MB"
)
del df_optpandas(투영+푸시다운) : 0.135초, 메모리 306 MB
메모리 점유가 눈에 띄게 줄어듭니다. 4개 열 중 2개만 읽고, 조건에 맞는 행만 올렸기 때문입니다.
셋째, DuckDB에 파일 경로를 넘기고 요약된 결과만 받는 방식입니다.
DuckDB : 0.039초
속도를 비교하기 전에 세 결과가 정말 같은지 확인합니다. 계산이 다르다면 성능 수치는 의미가 없습니다.
def normalize(df):
return (
df[["add_to_cart_order", "n"]]
.astype(
{"add_to_cart_order": "int64", "n": "int64"}
)
.sort_values("add_to_cart_order")
.reset_index(drop=True)
)
a, b, c = (
normalize(result_pandas),
normalize(result_pandas_opt),
normalize(result_duckdb),
)
pd.testing.assert_frame_equal(a, b)
pd.testing.assert_frame_equal(a, c)
print(
f"세 방식의 결과가 모두 일치합니다 (그룹 {len(a)}개, 합계 {a['n'].sum():,}건)."
)세 방식의 결과가 모두 일치합니다 (그룹 134개, 합계 19,126,536건).
normalize()에서 dtype을 맞춘 이유가 있습니다. 같은 값을 세더라도 엔진마다 결과 dtype이 다릅니다. DuckDB의 COUNT(*)는 BIGINT로 돌아오고, pandas의 size()는 플랫폼에 따라 다른 정수 폭을 씁니다. assert_frame_equal은 기본적으로 dtype까지 비교하므로 이를 맞추지 않으면 값이 같아도 실패합니다.
| 방식 | 시간(초) | 상대 배속 | |
|---|---|---|---|
| 0 | pandas (전체 적재) | 0.239954 | 1.00 |
| 1 | pandas (투영+푸시다운) | 0.134677 | 1.78 |
| 2 | DuckDB | 0.038733 | 6.20 |
fig, ax = plt.subplots(figsize=(7, 5))
ax.bar(
bench["방식"],
bench["시간(초)"],
color=["#c0c0c0", "#9ecae1", "#2171b5"],
)
ax.tick_params(axis="x", labelrotation=10)
ax.set_ylabel("소요 시간 (초, 3회 중앙값)")
ax.set_title("3,243만 행 조건부 집계 소요 시간")
ax.grid(True, axis="y", alpha=0.3)
for i, v in enumerate(bench["시간(초)"]):
ax.text(i, v, f"{v:.3f}s", ha="center", va="bottom")
plt.tight_layout()
plt.show()
위 수치는 특정 장비에서, 특정 데이터로, 특정 질의를 실행한 결과입니다. 디스크 속도, CPU 코어 수, 파일 캐시 상태, 조건에 걸리는 행의 비율에 따라 순위까지 바뀔 수 있습니다. 문서를 다시 렌더링하면 숫자도 달라집니다.
여기서 읽어야 할 것은 순위가 아니라 경향입니다. 첫째, 파일이 이미 운영체제의 캐시에 올라와 있는 이 정도 규모에서는 세 방식의 시간 차이가 실행할 때마다 흔들립니다. 반면 메모리 점유는 거의 흔들리지 않습니다. 데이터가 커질수록 시간보다 메모리가 먼저 한계에 부딪히므로, 필요한 열과 행만 읽는 습관이 중요합니다. 둘째, 같은 pandas라도 읽는 방식에 따라 결과가 달라집니다. “pandas는 느리다”가 아니라 “전체를 적재하는 방식이 부담스럽다”가 정확한 진술입니다.
18.10.1 선택 기준
측정 결과와 지금까지의 예제를 종합하면 다음과 같이 정리할 수 있습니다.
pandas: 데이터가 메모리에 여유롭게 들어가고, 파생 변수 생성·통계 분석·시각화·머신러닝처럼 Python 생태계와 맞물린 작업이 필요할 때 여전히 기본 도구입니다. 다만 읽는 방식이 성능을 좌우합니다.
read_parquet(columns=, filters=)를 습관화하면 “pandas가 느리다”고 느끼는 상황의 상당 부분이 사라집니다.PyArrow(4): 배치 단위로 읽는 과정을 직접 제어해야 하거나, 메모리 사용량을 엄격히 제한해야 하는 경우에 씁니다. 제어권을 쥐는 대신 코드가 길어집니다.
DuckDB: 파일이 크고, 표가 여럿이고, 조인·집계·윈도우 함수처럼 SQL이 원래 잘하는 연산이 필요할 때 씁니다. 큰 데이터를 SQL로 줄이고 작은 결과만 pandas로 받는 구성이 이 장의 핵심 패턴입니다. 반대로 SQL로 표현하기 어려운 로직(사용자 정의 함수, 반복적인 모델 적합)은 pandas 쪽에 남겨 둡니다.
세 도구는 경쟁 관계가 아닙니다. DuckDB가 데이터를 줄이고 → Arrow가 옮기고 → pandas가 마무리하는 흐름이 가장 자연스럽습니다.
18.11 심화: 더 큰 데이터와 성능 들여다보기
여기부터는 처음 읽을 때 건너뛰어도 됩니다. 데이터가 메모리를 넘어서거나 쿼리가 느려서 원인을 찾아야 할 때 다시 펼쳐 보면 됩니다.
18.11.1 결과를 Arrow 백엔드로 받기
.df()는 결과를 NumPy 백엔드 pandas로 변환합니다. 4에서 다룬 Arrow 백엔드를 유지하고 싶다면 .to_arrow_table()로 Arrow 테이블을 받은 뒤 types_mapper=pd.ArrowDtype을 지정합니다.
path = "../data/instacart-market-basket-analysis-parquet/products.parquet"
df_arrow = (
duckdb.sql(f"SELECT * FROM '{path}'")
.to_arrow_table()
.to_pandas(types_mapper=pd.ArrowDtype)
)
df_numpy = duckdb.sql(f"SELECT * FROM '{path}'").df()
print(".to_arrow_table() 경로:", dict(df_arrow.dtypes))
print(".df() 경로 :", dict(df_numpy.dtypes)).to_arrow_table() 경로: {'product_id': int64[pyarrow], 'product_name': string[pyarrow], 'aisle_id': int64[pyarrow], 'department_id': int64[pyarrow]}
.df() 경로 : {'product_id': dtype('int64'), 'product_name': <StringDtype(na_value=nan)>, 'aisle_id': dtype('int64'), 'department_id': dtype('int64')}
Arrow 경로의 결과는 pd.read_parquet(..., dtype_backend="pyarrow")와 dtype이 완전히 같습니다.
dtype 일치: True
책의 앞부분에서 “Arrow 노선”을 따라왔다면 Arrow 경로가 일관된 선택이고, matplotlib이나 scikit-learn처럼 NumPy 배열을 기대하는 쪽으로 곧장 넘길 것이라면 .df()가 편합니다. 어느 쪽이든 한 파이프라인 안에서는 하나로 통일하는 편이 dtype 때문에 생기는 잔버그를 줄여 줍니다.
18.11.2 메모리 상한 정하기
pandas는 메모리가 부족하면 MemoryError를 내고 멈춥니다. DuckDB는 사용량이 상한을 넘으면 중간 결과를 디스크의 임시 파일로 내려쓰면서 쿼리를 끝까지 진행합니다(아웃오브코어 실행). 상한과 임시 파일 위치는 연결을 만들 때 지정하는 것이 안전합니다. 전역 PRAGMA로 바꾸면 어느 연결에 적용되는지 코드에서 드러나지 않기 때문입니다.
적용된 설정: 1.8 GiB
2GB로 지정했는데 1.8 GiB로 보이는 것은 DuckDB가 값을 2진 단위(GiB)로 환산해 표시하기 때문입니다.
temp_directory로 지정한 경로에는 쿼리 도중 임시 파일이 생성됩니다. 프로젝트에서 쓴다면 .gitignore에 해당 경로를 추가해 두세요.
18.11.3 결과가 클 때: 배치 단위로 받기
계산 결과 자체가 커서 한 번에 DataFrame으로 받기 부담스러울 때는 .to_arrow_reader()로 배치 단위 스트리밍을 씁니다. 4에서 본 to_batches()의 SQL 버전입니다.
reader = con_limited.execute(
f"SELECT order_id, product_id, reordered FROM '{PRIOR}'"
).to_arrow_reader(
1_000_000
) # 배치당 100만 행
total, n_batches = 0, 0
for batch in reader:
part = batch.to_pandas() # 한 배치만 메모리에 올라온다
total += (part["reordered"] == 1).sum()
n_batches += 1
if n_batches == 3: # 예시를 위해 3배치만 확인
break
print(
f"{n_batches}개 배치 처리, 재주문 건수 누적: {total:,}"
)3개 배치 처리, 재주문 건수 누적: 1,768,195
3,243만 행을 한꺼번에 적재하지 않고도 누적 집계를 이어갈 수 있습니다. 15의 Instacart 병합 실습에서 메모리를 크게 쓰던 문제를 이런 방식으로 줄일 수 있습니다.
18.11.4 쿼리가 느릴 때: EXPLAIN ANALYZE
쿼리가 예상보다 느리면 어느 단계에서 시간을 쓰는지 확인합니다. EXPLAIN은 실행하지 않고 계획만 보여주고, EXPLAIN ANALYZE는 실제로 실행하면서 단계별 소요 시간과 처리한 행 수를 함께 보여줍니다.
┌─────────────────────────────────────┐
│┌───────────────────────────────────┐│
││ Query Profiling Information ││
│└───────────────────────────────────┘│
└─────────────────────────────────────┘
EXPLAIN ANALYZE SELECT add_to_cart_order, COUNT(*) AS n FROM '../data/instacart-market-basket-analysis-parquet/order_products__prior.parquet' WHERE reordered = 1 GROUP BY add_to_cart_order
┌────────────────────────────────────────────────┐
│┌──────────────────────────────────────────────┐│
││ Total Time: 0.0397s ││
│└──────────────────────────────────────────────┘│
└────────────────────────────────────────────────┘
┌───────────────────────────┐
│ QUERY │
└─────────────┬─────────────┘
┌─────────────┴─────────────┐
│ EXPLAIN_ANALYZE │
│ ──────────────────── │
│ │
│ 0 rows │
│ 0.00s │
└─────────────┬─────────────┘
┌─────────────┴─────────────┐
│ PROJECTION │
│ ──────────────────── │
│__internal_decompress_integ│
│ ral_bigint(#0, 1) │
│ #1 │
│ │
│ │
│ │
│ 134 rows │
│ 0.00s │
└─────────────┬─────────────┘
┌─────────────┴─────────────┐
│ PERFECT_HASH_GROUP_BY │
│ ──────────────────── │
│ Groups: #0 │
│ │
│ Aggregates: │
│ count_star() │
│ │
│ │
│ │
│ 134 rows │
│ 0.05s │
└─────────────┬─────────────┘
┌─────────────┴─────────────┐
│ PROJECTION │
│ ──────────────────── │
│ add_to_cart_order │
│ │
│ │
│ │
│ 19,126,536 rows │
│ 0.00s │
└─────────────┬─────────────┘
┌─────────────┴─────────────┐
│ PROJECTION │
│ ──────────────────── │
│__internal_compress_integra│
│ l_utinyint(#0, 1) │
│ │
│ │
│ │
│ 19,126,536 rows │
│ 0.01s │
└─────────────┬─────────────┘
┌─────────────┴─────────────┐
│ TABLE_SCAN │
│ ──────────────────── │
│ Function: │
│ PARQUET_SCAN │
│ │
│ Projections: │
│ add_to_cart_order │
│ │
│ Filters: reordered=1 │
│ Total Files Read: 1 │
│ │
│ Filename(s): │
│ ../data/instacart-market │
│ -basket-analysis-parquet │
│ /order_products__prior │
│ .parquet │
│ │
│ │
│ │
│ 19,126,536 rows │
│ 0.29s │
└───────────────────────────┘
트리는 아래에서 위로 읽습니다. 맨 아래 TABLE_SCAN이 파일을 읽는 단계이고, 그 위로 올라가면서 그룹으로 묶는 HASH_GROUP_BY(그룹 수가 적으면 PERFECT_HASH_GROUP_BY)를 거쳐 결과가 나옵니다. 각 상자에는 그 단계가 내보낸 행 수와 소요 시간이 함께 적힙니다.
눈여겨볼 곳은 스캔 상자입니다. Projections: add_to_cart_order는 필요한 열만 읽었다는 뜻이고, Filters: reordered=1은 WHERE 조건이 파일을 읽는 단계까지 내려갔다는 뜻입니다. 그래서 별도의 필터 단계 없이 3,243만 행 중 조건을 만족하는 행만 위로 올라갑니다. 반대로 조건을 걸었는데도 스캔 단계에서 행 수가 거의 줄지 않고 위쪽에 FILTER 상자가 따로 보인다면, 조건을 읽기 단계로 내려보내지 못하고 있다는 신호입니다.