Pandas의 성능 한계와 Polars 및 DuckDB가 중간 규모 데이터 워크로드에 더 나은 대안이 될 수 있는 이유를 살펴봅니다.
정확히 읽으셨습니다. 국제 외교에 이용되는 귀엽고 복슬복슬한 동물이 아니라 Python DataFrame 라이브러리인 Pandas는 멸종해야 합니다.
왜일까요? Pandas의 비효율성은 워크로드가 추가 복잡성을 정당화하기도 전에 분산 쿼리 시스템을 도입하도록 강요합니다. 저는 대부분의 워크로드가 그런 시스템을 정당화할 일은 결코 없으며, 그저 마케팅이 잘된 “만능 해결책”일 뿐이라고 주장합니다.
제가 무슨 말을 하는지 이해하려면 먼저 Pandas의 일반적인 도입 경로를 이해해야 합니다.
아래 도표는 다루는 데이터 크기를 기준으로 특정 DataFrame 라이브러리 도입을 일반적으로 고려하게 되는 시점을 대략적으로 보여 줍니다. 왼쪽에서 오른쪽으로 따라가면 데이터 분석 도구의 전형적인 도입 경로와, 특정 데이터 크기 이후 Pandas 사용자가 맞닥뜨리는 절벽도 볼 수 있습니다.
사람들은 보통 Excel로 시작해 GB 범위쯤에서 Pandas로 넘어갑니다. Pandas는 수십 GB 범위까지는 잘 작동하지만, 이후 메모리 문제나 느린 연산에 부딪히거나 Pandas의 난해한 API에 좌절하기 시작합니다. 이 시점의 전통적인 답은 Big Data ™️를 위해 설계된 Spark, DataBricks, Snowflake 또는 Dask 같은 “진짜” 도구, 즉 비싼 도구로 넘어가는 것입니다.

데이터 크기별 dataframe 라이브러리 도입에 관한 대략적 안내
문제는 “Pandas 절벽”과 분산 시스템이 진정으로 필요해지는 규모 사이에 점점 커지는 간극이 있다는 점입니다. 이 간극은 대략 100GB 부근에 있으며, 현대적인 고성능 단일 머신 도구로 효과적으로 메울 수 있습니다. 제가 주로 말하는 도구는 Polars와 DuckDB입니다.
왜 이 약 100GB 임계값을 그토록 중요하게 볼까요? 그 답은 현실에 얼마나 많은 “Big Data”가 존재하는지 이해하는 데 있습니다.
2024년 Amazon은 “Why TPC is not enough: An analysis of the Amazon Redshift fleet”라는 제목의 논문을 발표했습니다. 이 논문의 목표는 Amazon 자체 분산 분석 데이터베이스인 Redshift의 텔레메트리 데이터와 업계 표준 데이터베이스 벤치마크에서 사용되는 쿼리 패턴을 비교하는 것이었습니다. 분석의 일부로 Amazon은 쿼리 실행 시간과 테이블 크기에 관한 플릿 통계를 공개했습니다.

Amazon Redshift 플릿의 구간별 쿼리 실행 시간

Amazon Redshift 플릿의 구간별 테이블 크기
몇 가지 가정을 기꺼이 받아들인다면 Amazon 고객이 분석 데이터베이스를 사용하는 방식에 대해 흥미로운 결론을 낼 수 있습니다. 다음을 가정해 보겠습니다.
그러면 다음을 알 수 있습니다.
이 데이터셋을 더 깊이 살펴본 다른 분석에 관심이 있다면 MotherDuck의 Jordan Tigani가 작성한 심층 분석을 여기에서 볼 수 있습니다. 참고로 MotherDuck은 DuckDB 호스팅을 판매하는 SaaS 기업이므로, 어느 정도의 회의적 시각은 타당할 수 있습니다.
계산 설명
실행 시간 표의 처음 세 행을 합산하면 쿼리의 86.9%가 1초 미만에 실행된다는 것을 계산할 수 있습니다.
클러스터에 10대의 머신이 있고 각 머신이 8GB/s로 데이터를 받아들인다는 가정을 사용하면 다음을 계산합니다.
10 machines * 8GB * 1 second = 80GB of data
8GB/s라는 가정은 다소 오래된 이 벤치마크에 기반합니다.
“테이블의 94.68%가 100GB 미만을 포함한다”는 주장에 도달하기 위해 10^8 한도까지의 행을 합산하면 전체 행의 94.68%가 됩니다. 그런 다음 행당 1KB라는 가정을 적용해 다음을 계산합니다.
10^8 rows in a table * 1KB = 100GB
행당 1KB라는 가정이 지나치게 낙관적일 수도 있지만, 10KB를 가정해도 테이블 크기는 여전히 1TB에 도달합니다.
여러분에게는 Big Data가 없을 가능성이 높고, 앞으로도 없을 가능성이 큽니다. 여러분에게는 Medium Data 문제가 있으며, Medium Data 해법이 필요합니다.
앞서 암시했듯 제가 제안하는 대안은 DuckDB와 Polars입니다. 대략적으로 말하자면 Polars는 Pandas와 친숙한 느낌을 주는 Rust 기반 DataFrame 라이브러리이지만, 살펴볼 몇 가지 중요한 차이점이 있습니다. DuckDB는 메모리 내 분석 DB로, 본질적으로 분석용 SQLite입니다. 이 도구들의 감을 잡고 Pandas와 어떻게 다른지 알아보기 위해 예제를 살펴보겠습니다.
1 Billion Row Challenge는 기상 관측소 데이터를 포함한 10억 행 CSV의 최솟값, 평균값, 최댓값을 계산할 수 있는 가장 빠른 Java 프로그램을 작성하는 도전 과제였습니다. 대회에서 채택된 가장 빠른 구현은 1.5초에 실행되었습니다.
원래 도전 과제는 Debian 12를 실행하는 32코어 및 128GB RAM의 베어메탈 Hetzner AX161 서버를 사용했습니다. 그러나 글쓴이는 만성적인 미루기꾼이자 구두쇠이고 Hetzner에서는 큰 머신을 임대하려면 “평판”을 쌓아야 하므로, 이 테스트에는 Debian 12를 실행하는 AWS의 m7a.8xlarge를 대신 사용했습니다.
이 기본 구성은 AMD CPU에서 32코어와 128GB RAM이라는 점에서 원래 도전 과제와 같습니다. 다만 베어메탈 전용 하드웨어를 사용하지 않았으므로 재현성에 다소 영향을 줄 수 있습니다(죄송합니다).
서론은 이쯤 하고 구현을 살펴보겠습니다.
이 코드는 이전에 Pandas를 써 본 사람이라면 매우 익숙해 보일 것입니다. CSV에서 데이터를 읽고, 기상 관측소별로 그룹화한 다음, 최솟값·평균값·최댓값 집계를 계산합니다.
출력 직렬화는 어디 있나? 성능 테스트에서는 원래 도전 과제에 명시된 출력 직렬화를 생략합니다. 구현체들은 모두 출력 직렬화 _기능_을 포함하며, 이는 구현체의 단위 테스트에 사용되었습니다(예: Pandas 코드). 1 Billion Row Challenge의 출력 형식은 표준적이지 않으므로, 직렬화는 여러 라이브러리를 시험하는 데 관련성 있는 테스트처럼 느껴지지 않았습니다.
def do_1brc_pandas(file_path: str):
df = (
pd.read_csv(file_path, sep=";", names=["station", "measurement"])
.groupby("station")
.agg({"measurement": ["min", "mean", "max"]})
.round(2)
)
이 예제에서 기억해야 할 핵심은 Pandas가 이 계산의 각 단계를 순차적이고 즉시 실행한다는 점입니다. 전체 데이터셋을 읽고, 그룹화한 뒤 집계를 수행합니다.
Polars 코드는 Pandas와 비슷해 보이지만, 앞으로 보겠지만 실행 시에는 매우 다르게 _동작_합니다.
def do_1brc_polars(file_path: str):
df = (
pl.scan_csv(
file_path,
separator=";",
new_columns=["station", "measurement"],
has_header=False,
)
.group_by("station")
.agg(
pl.col("measurement").min().round(2).alias("min"),
pl.col("measurement").mean().round(2).alias("mean"),
pl.col("measurement").max().round(2).alias("max"),
)
.collect(new_streaming=True) # Stream the input data and perform computations in chunks
)
데이터는 청크 단위로 _스캔_되고, 그룹화 및 집계됩니다. 여기서 핵심 세부 사항은 scan_csv가 지연 평가되며 .collect 호출이 쿼리 파이프라인을 실행한다는 것입니다. 이것이 데이터베이스 용어처럼 들린다면 맞습니다. 이 지연 평가는 Polars가 데이터베이스와 유사한 최적화된 쿼리 그래프를 구성하고, 40년에 걸친 데이터베이스 최적화를 활용해 데이터를 청크 단위로 읽으며 필요에 따라 스레드 전반에 작업을 병렬화하도록 합니다.
데이터베이스와 마찬가지로 .collect 호출을 각각 .explain(streaming=True) 및 .explain(streaming=True, optimized=False) 호출로 바꾸면 최적화된 쿼리 계획과 최적화되지 않은 쿼리 계획을 시각화할 수 있습니다.
최적화된 쿼리 계획
이 쿼리 계획은 그다지 흥미롭지는 않습니다. CSV를 스캔하고, 2개 열 프로젝션을 수행한 다음 집계합니다. 필터링이 포함된 쿼리에서는 집계 전에 행을 필터링하는 조건절 푸시다운이 적용되는 것을 기대할 수 있습니다. 이는 모든 행을 메모리에 적재한 뒤 필터링하는 Pandas와 다릅니다.
AGGREGATE
[col("measurement").min().round().alias("min"), col("measurement").mean().round().alias("mean"), col("measurement").max().round().alias("max")] BY [col("station")] FROM
STREAMING:
simple π 2/2 ["measurement", "station"]
Csv SCAN [/Users/eddie/Documents/code/pandas-should-go-extinct/data/measurements.csv]
PROJECT 2/2 COLUMNS
DuckDB 코드는 평범한 SQL처럼 읽힙니다. 집계 함수를 적용한 열을 선택하고 그룹화 기준을 지정합니다.
def do_1brc_duckdb(file_path: str):
df = duckdb.read_csv(file_path, names=["station", "measurements"])
src = duckdb.sql("""
create table src as
select
station,
min(measurements) min,
max(measurements) max,
cast(avg(measurements) as decimal(8, 1)) avg
from df
group by station
"""
)
이 코드 샘플의 핵심 요점은 DuckDB가 데이터가 어떻게, 어디에 저장되어 있든 그 위에 SQL 인터페이스를 제공한다는 것입니다. 또한 메모리에 있는 Python 객체를 쿼리할 수도 있으며, 위 목록에서는 CSV를 읽어 df 객체를 생성하고 SQL로 쿼리합니다.
DuckDB는 Polars와 마찬가지로 쿼리 엔진이 적절하다고 판단하는 경우 지연되고 멀티스레드이며 청크 단위인 방식으로 적용되는 쿼리 계획을 구성하고 실행합니다. .explain() 호출을 덧붙이면 DuckDB가 쿼리에 대해 생성하는 쿼리 계획도 볼 수 있습니다.
최적화된 쿼리 계획
이번에도 쿼리 계획은 그다지 흥미롭지 않습니다. CSV를 스캔하고, 2개 열 프로젝션을 수행한 다음 집계합니다.
참고: 아래는 성능 프로파일링 결과에 사용되지 않은 제 M1 MBA에서 계획을 실행한 출력입니다.
┌─────────────────────────────────────┐
│┌───────────────────────────────────┐│
││ Query Profiling Information ││
│└───────────────────────────────────┘│
└─────────────────────────────────────┘
explain analyze create or replace table src as select station, min(measurements) min, max(measurements) max, cast(avg(measurements) as decimal(8, 1)) avg from df group by station
┌────────────────────────────────────────────────┐
│┌──────────────────────────────────────────────┐│
││ Total Time: 56.08s ││
│└──────────────────────────────────────────────┘│
└────────────────────────────────────────────────┘
┌───────────────────────────┐
│ QUERY │
└─────────────┬─────────────┘
┌─────────────┴─────────────┐
│ EXPLAIN_ANALYZE │
│ ──────────────────── │
│ 0 Rows │
│ (0.00s) │
└─────────────┬─────────────┘
┌─────────────┴─────────────┐
│ CREATE_TABLE_AS │
│ ──────────────────── │
│ 1 Rows │
│ (0.00s) │
└─────────────┬─────────────┘
┌─────────────┴─────────────┐
│ PROJECTION │
│ ──────────────────── │
│ station │
│ min │
│ max │
│ avg │
│ │
│ 8888 Rows │
│ (0.00s) │
└─────────────┬─────────────┘
┌─────────────┴─────────────┐
│ HASH_GROUP_BY │
│ Groups: #0 │
│ │
│ Aggregates: │
│ min(#1) │
│ max(#2) │
│ avg(#3) │
│ │
│ 8888 Rows │
│ (121.01s) │
└─────────────┬─────────────┘
┌─────────────┴─────────────┐
│ PROJECTION │
│ station │
│ measurements │
│ measurements │
│ measurements │
│ │
│ 1000000000 Rows │
│ (0.29s) │
└─────────────┬─────────────┘
┌─────────────┴─────────────┐
│ TABLE_SCAN │
│ ──────────────────── │
│ Function: │
│ READ_CSV_AUTO │
│ │
│ Projections: │
│ station │
│ measurements │
│ │
│ 1000000000 Rows │
│ (316.38s) │
└───────────────────────────┘
각 라이브러리별 독립 실행 스크립트를 작성하고, 디스크의 10억 행 CSV를 대상으로 실행하여 성능을 측정했습니다.
스크립트는 가볍게 직접 만든 벤치마크 도구에서 실행했습니다. 이 도구는 새 Python 인터프리터를 생성해 스크립트를 실행하고, 자식 프로세스가 종료될 때까지 psutil을 사용해 50ms 간격으로 메모리 및 CPU 지표를 폴링합니다. 각 벤치마크에 대해 두 번의 워밍업 반복을 실행한 뒤 스크립트를 서른 번 반복했습니다.
이 접근 방식이 결코 완벽하지는 않지만, 비교 목적에 맞는 정확도와 오버헤드 간 균형을 이루었습니다.
| 라이브러리 | 중앙 실행 시간 | 중앙 최대 CPU % | 중앙 최대 USS | 중앙 최대 스왑 |
|---|---|---|---|---|
| Pandas | 4분 28초 | 113.0% | 38.12 GB | 0 MB |
| Polars | 5.04초 | 3202.60% | 18.02 GB | 0 MB |
| DuckDB | 5.19초 | 3174.64% | 1.93 GB | 0 MB |
여기서 결과는 그 자체로 매우 분명합니다. Polars와 DuckDB는 Pandas보다 현저히 빠르며, 메모리도 각각 2배와 19배 적게 사용합니다. 맞춤 제작한 Java 구현에 근접한 수준입니다.
Polars 구현의 메모리 사용량은 여전히 약간 높아 보입니다. 모든 계산이 스트리밍되지 않았던 것으로 의심됩니다. DuckDB는 흠잡을 데 없었으며, 매우 적은 코드와 튜닝 없이도 경이적인 성능을 냈습니다.
하지만 더 나은 프로덕션 성능은 방정식의 일부일 뿐입니다. DuckDB와 Polars는 로컬 개발 루프를 가속하는 데에도 빛을 발합니다.
강력하지만 약간 오래된 노트북 하드웨어에서 성능 테스트를 반복해 보겠습니다. 이 테스트에는 8코어와 16GB RAM을 갖춘 Intel i5-1135G7의 Framework 13을 사용했습니다.
| 라이브러리 | 중앙 실행 시간 | 중앙 최대 CPU % | 중앙 최대 USS | 중앙 최대 스왑 |
|---|---|---|---|---|
| Pandas | 12분 15초 | 110.35% | 15.67 GB | 21.02 GB |
| Polars | 39초 | 765.75% | 15.22 GB | 35.85 MB |
| DuckDB | 47초 | 807.0% | 546.87 MB | 0 MB |
다시 한번 Polars와 DuckDB의 뛰어난 성능을 확인할 수 있습니다. 다만 Polars는 상당한 메모리를 사용하고 약간의 스왑도 사용했습니다. 반면 Pandas는 몹시 느리게 실행되고 메모리를 마구 소비합니다.
이 벤치마크는 전통적인 Pandas 워크플로 대비 몇 가지 핵심 이점을 보여 줍니다.
더 표준적인 TPC-H 벤치마크 결과에 관심이 있다면 여기에서 찾을 수 있습니다.
TPC-H 벤치마크 결과에 관한 참고 이 TPC-H 벤치마크는 이 영역에서 Polars / DuckDB와 인지도 경쟁을 하는 호스팅 Dask 서비스를 제공하는 기업 Coiled가 실행했습니다. 그렇다고 그 벤치마크가 틀렸다는 뜻은 아니지만, 어느 정도의 회의적 시각을 유지할 가치는 있습니다(저를 포함해서요!).
우리 모두 반짝이는 새 도구에 데어 본 적이 있고, 우리 중 일부는 지금 이 순간에도 그런 도구에 데이고 있습니다. 모든 것을 다시 작성하지 않고 이 도구들을 시험해 보는 열쇠는 Apache Arrow입니다. Arrow는 열 지향 데이터의 사실상 표준 메모리 내 표현 방식이 되어 가고 있으며, Pandas의 원래 제작자인 Wes McKinney의 작품입니다. Pandas는 2023년 4월 2.0 릴리스부터 Arrow를 지원했습니다.
Polars와 DuckDB는 모두 Arrow를 기본 지원합니다. 즉, 메모리를 복사하지 않고도 Polars, Pandas, DuckDB 사이에서 DataFrame을 옮길 수 있으므로 프레임워크 간 전환 비용이 거의 “무료”에 가깝습니다. 여기서 한 가지 주의할 점은 Pandas가 기본적으로 Arrow 기반 DataFrame을 만들지 않는다는 것입니다. DataFrame을 생성할 때 dtype_backend를 pyarrow로 지정해야 합니다.
NYC Taxi 데이터셋(2009년부터 현재까지의 운행 기록, 월별 Parquet 파일에 저장됨)을 분석하여 팬데믹 기간인 2019-2022년에 현금 결제가 덜 흔해졌는지 알아보겠습니다. 여기에는 약 3GB의 Parquet 데이터를 처리하는 작업이 포함됩니다.
이 예제에서는 DuckDB와 Pandas 모두에서 parquet 파일을 읽고 계산을 수행하는 함수를 구현하겠습니다. 이 함수들의 Polars 버전은 독자의 연습 문제로 남겨 두겠습니다 😉.
Pandas 코드:
COLUMNS = ["tpep_pickup_datetime", "payment_type"]
MIN_DATETIME = "2019-01-01"
MAX_DATETIME = "2023-01-01"
# Enum value for cash as the payment type
CASH = 2
def read_data_pandas(folder: Path) -> pd.DataFrame:
df = None
for entry in folder.iterdir():
if not entry.name.endswith("parquet"):
continue
temp_df = pd.read_parquet(entry, columns=COLUMNS, dtype_backend="pyarrow")
if df is not None:
df = pd.concat([df, temp_df])
else:
df = temp_df
# Clamp the data to the range we're interested in
df = df[df["tpep_pickup_datetime"] > pd.to_datetime(MIN_DATETIME)]
df = df[df["tpep_pickup_datetime"] < pd.to_datetime(MAX_DATETIME)]
df["month"] = df["tpep_pickup_datetime"].dt.month
df["year"] = df["tpep_pickup_datetime"].dt.year
df = df.drop(["tpep_pickup_datetime"], axis="columns")
return df
def calculate_cash_pandas(df: pd.DataFrame):
df = (
df.groupby(["year", "month", "payment_type"])
.agg({"payment_type": "count"})
.unstack(fill_value=0, level=2)["payment_type"]
.reset_index()
)
df["total_payments"] = df.iloc[:, 2:8].sum(axis=1)
df["cash_pct"] = (df[CASH] / df["total_payments"]) * 100
DuckDB 코드:
def read_data_duck(folder: str):
return duckdb.sql(f"""
select
datepart('year', tpep_pickup_datetime) year,
datepart('month', tpep_pickup_datetime) month,
payment_type
from '{folder}/*.parquet'
where
tpep_pickup_datetime > '{MIN_DATE}'
and tpep_pickup_datetime < '{MAX_DATE}'"""
)
def calculate_cash_duck(data):
return duckdb.sql(f"""
with total as (
select year, month, count(payment_type) payments from df
group by year, month
),
total_cash as (
select year, month, count(payment_type) cash from df
where payment_type={CASH}
group by year, month
)
select total.*, cash, (cash / total) * 100 cash_pct from total
join total_cash
on total.year=total_cash.year and total.month=total_cash.month
order by total.year, total.month
""").df() # Force evaluation by materialising to a df otherwise the execution is lazy
그런 다음 데이터 읽기 호출과 계산 실행 호출을 조합하여, 읽기·쓰기 또는 둘 다에 어느 도구를 선택할 때 얼마나 큰 효과를 얻는지 파악할 수 있습니다.
def pure_pandas(folder: Path):
data = read_data_pandas(folder)
df = calculate_cash_pandas(data)
def duck_reads_panda_thinks(folder: Path):
data = read_data_duck(folder).df()
df = calculate_cash_pandas(data)
def panda_reads_duck_thinks(folder: Path):
data = read_data_pandas(folder)
df = calculate_cash_duck(data)
def pure_duck(folder: Path):
data = read_data_duck(folder)
df = do_taxi_duck_compute(data)
이전과 동일한 벤치마크 스크립트와 동일한 노트북 사양을 사용하면 다음과 같은 결과를 얻습니다.
| 접근 방식 | 중앙 실행 시간 | 중앙 최대 CPU % | 중앙 최대 USS | 중앙 최대 스왑 |
|---|---|---|---|---|
| 순수 Pandas | 41.88초 | 146.10% | 14.52 GB | 1.92 GB |
| Duck 읽기, Panda 계산 | 28.39초 | 793.7% | 14.79 GB | 1.22 GB |
| Panda 읽기, Duck 계산 | 29.25초 | 765.4% | 12.39 GB | 0 MB |
| 순수 DuckDB | 21.70초 | 814.95% | 216.76 MB | 0 MB |
다시 비슷한 패턴을 볼 수 있습니다. DuckDB는 메모리를 조금만 사용하면서 머신의 CPU 코어를 완전히 활용할 수 있습니다. Pandas가 개입하는 곳에서는 실행 속도가 크게 느려지고 메모리 사용량이 가파르게 치솟습니다.
팬데믹 동안 현금 사용량이 줄었는지 궁금하다면, 실제로 크게 줄었습니다. 하지만 상관관계 !== 인과관계이므로 여기에서 의미 있는 결론을 끌어내지는 마세요.

2019년 1월부터 2023년 1월까지 NYC의 전체 택시 운행 중 현금 사용 비율 그래프
기술 업계에서 회의주의는 건강한 태도이므로, 제 말을 듣지 말아야 할 이유를 목록으로 정리했습니다.
이는 전적으로 워크로드, 경험, 선호에 달려 있습니다. 데이터 엔지니어는 SQL을 좋아하는 경향이 있고, 소프트웨어 엔지니어는 Polars를 좋아하는 경향이 있습니다. 둘 다 직접 써 보고 어느 쪽을 선호하는지 알아보세요.
정말로 _하지 말아야 할 유일한 일_은 Pandas의 성능이 나쁘다는 이유만으로 분산 쿼리 시스템과 그에 딸린 모든 복잡성을 무작정 선택하는 것입니다. 장기적으로 실제로 그런 시스템이 필요할 가능성은 매우 낮습니다.
성능은 그 어느 때보다 접근하기 쉬워졌습니다. 여러 도구를 살펴보세요!