LUVIT 폴라스로 시작하는 데이터 분석 | 이기준 - 교보문고
LUVIT 폴라스로 시작하는 데이터 분석 | 복잡한 분석을 더 빠르게, 더 간결하게 Pandas를 뛰어넘는 분석 도구 Polars대용량 데이터에서 팬더스(Pandas)의 성능과 표현력의 한계를 체감하는 데이터 분석
product.kyobobook.co.kr
Pandas는 여전히 지금까지 만들어진 가장 유용한 Python 라이브러리 중 하나입니다.
상사가 작은 CSV 파일 하나를 주면서 10분 안에 Python으로 처리해달라고 한다면, 아마 제 손은 머리가 움직이기도 전에 import pandas as pd를 입력하고 있을 것입니다.
하지만 데이터 과학, 특히 로컬 데이터 분석은 달라졌습니다.
오늘날의 “작은” 데이터셋은 500만 행일 수도 있습니다. 제품 데이터베이스에서 내보낸 CSV는 몇 GB가 될 수도 있습니다. 어제까지만 해도 즉시 실행되던 노트북이 오늘은 갑자기 커피 한 잔 마시고 와야 하는 기계가 될 수 있습니다. 이것이 AI 시대 데이터셋의 현실입니다.
그리고 바로 여기서 Polars와 DuckDB가 중요해집니다.
간단히 말해, pandas는 여전히 작고 익숙한 DataFrame 작업에 탁월합니다. Polars는 지연 쿼리 최적화를 갖춘 빠른 DataFrame 엔진을 원할 때 훌륭한 선택입니다. DuckDB는 로컬 분석이 자연스럽게 SQL 중심이고, 데이터가 CSV나 Parquet 파일에 그대로 머물 수 있을 때 훌륭한 선택입니다.
가장 좋은 점은 이것입니다.
우리는 하나를 영원히 선택할 필요가 없습니다.
로컬 데이터 작업에 맞는 올바른 도구를 선택하면 됩니다.
이 글에서는 같은 분석 작업과 여러분의 컴퓨터에서 직접 실행해볼 수 있는 벤치마크 스크립트를 사용해, 작은 pandas 워크플로를 Polars 및 DuckDB와 비교해보겠습니다.
가짜 “내 노트북이 900배 더 빠르다” 같은 마법은 없습니다.
그저 실용적인 코드만 다룹니다.
예제: 전형적인 로컬 데이터 분석 워크플로
이벤트 데이터셋이 하나 있다고 가정해 보겠습니다.
각 행은 하나의 상품 이벤트를 나타냅니다.
- event_date
- country
- channel
- user_id
- order_id
- revenue
우리의 작업은 매우 단순합니다.
- 파일을 읽는다.
- 최근 데이터만 필터링한다.
- 세 개 국가의 데이터만 남긴다.
- 매출이 0인 이벤트를 제거한다.
- 국가(country)와 채널(channel)별로 그룹화한다.
- 사용자 수, 주문 수, 매출을 계산한다.
- 매출 기준 상위 10개 그룹을 반환한다.
이것은 숫자 두 개를 더하는 수준의 장난감 예제가 아닙니다.
그렇다고 박사 학위 논문 수준의 문제도 아닙니다.
오히려 많은 데이터 분석가와 데이터 엔지니어가 매일 수행하는 종류의 분석에 가깝습니다.
먼저 필요한 패키지를 설치해 보겠습니다.
pip install pandas polars duckdb pyarrow numpy
그런 다음 재현 가능한 데이터 세트를 생성합니다:
from pathlib import Path
import numpy as np
import pandas as pd
DATA_DIR = Path("data")
DATA_DIR.mkdir(exist_ok=True)
row_count = 1_000_000
rng = np.random.default_rng(42)
df = pd.DataFrame(
{
"event_date": rng.choice(
pd.date_range("2024-01-01", "2026-01-31", freq="D"),
size=row_count,
),
"country": rng.choice(["US", "UK", "DE", "FR", "IN", "JP"], size=row_count),
"channel": rng.choice(["search", "social", "email", "direct"], size=row_count),
"user_id": rng.integers(1, 200_000, size=row_count),
"order_id": rng.integers(1, 900_000, size=row_count),
"revenue": rng.gamma(shape=2.0, scale=30.0, size=row_count).round(2),
}
)
df.loc[rng.random(row_count) < 0.15, "revenue"] = 0
df.to_csv(DATA_DIR / "events.csv", index=False)
노트북 성능이 좋다면 row_count를 10_000_000으로 변경하세요.
노트북에서 이상한 소리가 나기 시작하면 원래대로 되돌리세요.
pandas 버전: 친근하고 익숙한
pandas부터 시작해 봅시다.
import pandas as pd
df = pd.read_csv("data/events.csv", parse_dates=["event_date"])
result = (
df.loc[
(df["event_date"] >= "2025-01-01")
& (df["country"].isin(["US", "UK", "DE"]))
& (df["revenue"] > 0),
["country", "channel", "user_id", "order_id", "revenue"],
]
.groupby(["country", "channel"], as_index=False)
.agg(
users=("user_id", "nunique"),
orders=("order_id", "count"),
revenue=("revenue", "sum"),
)
.sort_values("revenue", ascending=False)
.head(10)
)
print(result)
country channel users orders revenue
5 UK email 17738 18600 1124339.42
9 US email 17777 18573 1123082.83
0 DE direct 17697 18542 1115744.40
8 US direct 17597 18373 1112646.83
7 UK social 17703 18536 1112367.11
6 UK search 17476 18284 1110991.85
11 US social 17695 18518 1109220.85
2 DE search 17584 18390 1106849.37
3 DE social 17484 18287 1099356.27
4 UK direct 17484 18338 1097785.44
이 코드는 읽기 쉽습니다.
많은 프로젝트에서는 이것만으로도 충분합니다.
Pandas는 거대한 생태계와 훌륭한 문서, 그리고 많은 Python 개발자에게 이미 익숙한 사고방식을 가지고 있습니다. 이것은 중요합니다. 도구는 속도만의 문제가 아닙니다. 팀이 얼마나 잘 알고 있는지도 중요합니다.
하지만 한 가지를 주목해 보세요.
Pandas는 먼저 CSV를 메모리로 읽어온 다음, 필터링하고 집계합니다.
10만 행이라면 누가 신경 쓰겠습니까?
2천만 행이라면, 아마 아주 많이 신경 쓰기 시작할 것입니다.
또한 워크플로가 긴 변환 체인이 될수록 Pandas 코드는 다소 덜 편하게 느껴질 수 있습니다. 여전히 강력하지만, 때로는 바퀴 없는 아름다운 오래된 여행가방을 들고 공항을 지나가는 느낌이 듭니다.
우아하지만, 팔은 피곤합니다.
Polars 버전: DataFrame 작업, 하지만 더 빠르게
Polars는 pandas를 알고 있다면 익숙하게 느껴질 수 있지만, 내부 설계는 상당히 다릅니다.
가장 중요한 개념 중 하나는 지연 실행(Lazy Execution) 입니다.
모든 단계를 즉시 실행하는 대신, Polars는 먼저 쿼리 계획(Query Plan)을 구성하고 최적화한 뒤, collect()를 호출했을 때 실제 실행을 수행할 수 있습니다.
이제 동일한 워크플로를 Polars로 다시 작성해 보겠습니다.
import polars as pl
result = (
pl.scan_csv("data/events.csv", try_parse_dates=True)
.filter(
(pl.col("event_date") >= pl.date(2025, 1, 1))
& (pl.col("country").is_in(["US", "UK", "DE"]))
& (pl.col("revenue") > 0)
)
.select(["country", "channel", "user_id", "order_id", "revenue"])
.group_by(["country", "channel"])
.agg(
pl.col("user_id").n_unique().alias("users"),
pl.col("order_id").count().alias("orders"),
pl.col("revenue").sum().alias("revenue"),
)
.sort("revenue", descending=True)
.limit(10)
.collect()
)
print(result)
shape: (10, 5)
┌─────────┬─────────┬───────┬────────┬───────────┐
│ country ┆ channel ┆ users ┆ orders ┆ revenue │
│ --- ┆ --- ┆ --- ┆ --- ┆ --- │
│ str ┆ str ┆ u32 ┆ u32 ┆ f64 │
╞═════════╪═════════╪═══════╪════════╪═══════════╡
│ UK ┆ email ┆ 17738 ┆ 18600 ┆ 1.1243e6 │
│ US ┆ email ┆ 17777 ┆ 18573 ┆ 1.1231e6 │
│ DE ┆ direct ┆ 17697 ┆ 18542 ┆ 1115744.4 │
│ US ┆ direct ┆ 17597 ┆ 18373 ┆ 1.1126e6 │
│ UK ┆ social ┆ 17703 ┆ 18536 ┆ 1.1124e6 │
│ UK ┆ search ┆ 17476 ┆ 18284 ┆ 1.1110e6 │
│ US ┆ social ┆ 17695 ┆ 18518 ┆ 1.1092e6 │
│ DE ┆ search ┆ 17584 ┆ 18390 ┆ 1.1068e6 │
│ DE ┆ social ┆ 17484 ┆ 18287 ┆ 1.0994e6 │
│ UK ┆ direct ┆ 17484 ┆ 18338 ┆ 1.0978e6 │
└─────────┴─────────┴───────┴────────┴───────────┘
하지만 여기에는 미묘한 차이가 있습니다.
scan_csv()는 일반적인 read_csv()처럼 전체 파일을 즉시 메모리로 읽어들이지 않습니다. 대신 지연(Lazy) 쿼리를 생성합니다.
그렇게 되면 Polars는 전체 실행 계획을 한눈에 볼 수 있습니다.
- 우리는 다섯 개의 컬럼만 필요합니다.
- 그룹화를 수행하기 전에 행을 필터링합니다.
- 마지막에는 상위 10개 행만 필요합니다.
이런 정보는 쿼리 엔진이 매우 좋아하는 종류의 정보입니다.
다시 말해, Polars는 단순히 “API가 다른 pandas”가 아닙니다. 오히려 Python DataFrame 인터페이스를 제공하는 작은 분석 엔진(Analytical Engine)에 더 가깝게 동작합니다.
바로 이런 이유 때문에 Polars는 로컬 데이터 분석 파이프라인에서 특히 강력한 성능을 발휘하는 경우가 많습니다.
제가 추천하는 기준은 다음과 같습니다.
만약 여러분의 pandas 코드가 필터링, 컬럼 선택, 그룹화, 조인, 정렬이 길게 이어지는 형태라면 Polars를 한 번 시도해 보세요.
대부분의 경우 마이그레이션은 크게 고통스럽지 않으며, 성능 향상은 실제로 체감할 수 있을 정도일 수 있습니다.
하지만 인터넷의 벤치마크 그래프가 인상적으로 보인다는 이유만으로 모든 pandas 노트북을 Polars로 다시 작성할 필요는 없습니다.
작고 즉석에서 수행하는 분석(ad-hoc analysis)이라면 pandas는 여전히 편안하고 생산적인 도구입니다.
그리고 편안함 역시 하나의 기능입니다.
DuckDB 버전: 데이터베이스 서버 없이 사용하는 SQL
DuckDB는 제가 로컬 데이터 분석에서 가장 좋아하는 도구 중 하나입니다.
왜일까요?
가끔은 최고의 DataFrame 코드가 DataFrame 코드를 전혀 작성하지 않는 것이기 때문입니다.
만약 여러분이 해결하려는 문제가 본질적으로 SQL에 더 자연스럽다면, 그냥 SQL을 작성하면 됩니다.
DuckDB는 로컬 파일을 직접 쿼리할 수 있게 해줍니다.
import duckdb
query = """
SELECT
country,
channel,
COUNT(DISTINCT user_id) AS users,
COUNT(order_id) AS orders,
SUM(revenue) AS revenue
FROM read_csv('data/events.csv', header = true)
WHERE CAST(event_date AS DATE) >= DATE '2025-01-01'
AND country IN ('US', 'UK', 'DE')
AND revenue > 0
GROUP BY country, channel
ORDER BY revenue DESC
LIMIT 10;
"""
result = duckdb.sql(query).df()
print(result)
처음 사용할 때는 거의 불법인 것 같은 느낌이 듭니다.
Postgres 서버도, Spark 클러스터도, “12개의 YAML 파일을 설정하는 동안 잠시만 기다려 주세요”라는 메시지도 없습니다.
그저 로컬 파일에서 SQL을 실행할 뿐입니다.
하지만 정말 잘 작동합니다. 결과물은 Polars나 Pandas만큼 정확합니다:
country channel users orders revenue
0 UK email 17738 18600 1124339.42
1 US email 17777 18573 1123082.83
2 DE direct 17697 18542 1115744.40
3 US direct 17597 18373 1112646.83
4 UK social 17703 18536 1112367.11
5 UK search 17476 18284 1110991.85
6 US social 17695 18518 1109220.85
7 DE search 17584 18390 1106849.37
8 DE social 17484 18287 1099356.27
9 UK direct 17484 18338 1097785.44
분석 작업에서 DuckDB는 특히 다음과 같은 경우에 매우 편리합니다.
- 팀 구성원들이 이미 SQL에 익숙한 경우
- 데이터가 CSV, Parquet 또는 Arrow 호환 포맷으로 저장되어 있는 경우
- 여러 개의 로컬 파일을 조인해야 하는 경우
- 모든 데이터를 먼저 pandas로 로드하는 과정을 피하고 싶은 경우
이런 이유 때문에 저는 로컬 데이터 분석에 DuckDB를 선호합니다.
DuckDB는 우리에게 강력한 분석용 데이터베이스 엔진을 제공하면서도, 별도의 무거운 데이터베이스 시스템을 운영할 필요는 없게 해줍니다.
공정하게 벤치마크하는 방법
데이터 도구 벤치마크는 잘못하기 쉽습니다.
pandas가 CSV를 읽는 것과 DuckDB가 Parquet을 읽는 것을 비교한다면, 더 이상 pandas와 DuckDB를 비교하는 것이 아닙니다. CSV와 Parquet을 비교하는 것입니다.
운영체제가 이미 파일을 캐시한 뒤 pandas 버전을 실행하고, Polars 버전을 먼저 실행한다면, 의도치 않게 디스크 캐시를 벤치마크하게 될 수도 있습니다.
5,000행만 테스트한다면 import 시간과 Python 오버헤드가 거의 모든 결과를 좌우할 수도 있습니다.
그래서 작은 벤치마크 도구를 작성해 보겠습니다.
from time import perf_counter
import duckdb
import pandas as pd
import polars as pl
COUNTRIES = ["US", "UK", "DE"]
def pandas_workflow():
df = pd.read_csv("data/events.csv", parse_dates=["event_date"])
return (
df.loc[
(df["event_date"] >= "2025-01-01")
& (df["country"].isin(COUNTRIES))
& (df["revenue"] > 0),
["country", "channel", "user_id", "order_id", "revenue"],
]
.groupby(["country", "channel"], as_index=False)
.agg(
users=("user_id", "nunique"),
orders=("order_id", "count"),
revenue=("revenue", "sum"),
)
.sort_values("revenue", ascending=False)
.head(10)
)
def polars_workflow():
return (
pl.scan_csv("data/events.csv", try_parse_dates=True)
.filter(
(pl.col("event_date") >= pl.date(2025, 1, 1))
& (pl.col("country").is_in(COUNTRIES))
& (pl.col("revenue") > 0)
)
.select(["country", "channel", "user_id", "order_id", "revenue"])
.group_by(["country", "channel"])
.agg(
pl.col("user_id").n_unique().alias("users"),
pl.col("order_id").count().alias("orders"),
pl.col("revenue").sum().alias("revenue"),
)
.sort("revenue", descending=True)
.limit(10)
.collect()
)
def duckdb_workflow():
return duckdb.sql(
"""
SELECT
country,
channel,
COUNT(DISTINCT user_id) AS users,
COUNT(order_id) AS orders,
SUM(revenue) AS revenue
FROM read_csv('data/events.csv', header = true)
WHERE CAST(event_date AS DATE) >= DATE '2025-01-01'
AND country IN ('US', 'UK', 'DE')
AND revenue > 0
GROUP BY country, channel
ORDER BY revenue DESC
LIMIT 10;
"""
).df()
def benchmark(label, func, repeat=3):
times = []
output = None
for _ in range(repeat):
start = perf_counter()
output = func()
times.append(perf_counter() - start)
print(f"{label:8} best={min(times):.3f}s avg={sum(times) / len(times):.3f}s")
return output
if __name__ == "__main__":
benchmark("pandas", pandas_workflow)
benchmark("polars", polars_workflow)
benchmark("duckdb", duckdb_workflow)
앞서 생성한 1,000,000개의 데이터 행에 대한 최종 결과는 다음과 같습니다:
pandas best=0.680s avg=0.726s
polars best=0.040s avg=0.043s
duckdb best=0.203s avg=0.213s
어느 것이 가장 빠를까요? 그리고 어느 것이 가장 느릴까요?
제가 추천하는 방법은 최소한 다음 세 가지 규모에서 테스트해 보는 것입니다.
- 100,000행
- 1,000,000행
- 10,000,000행
일반적으로 데이터 규모가 커질수록 도구 간의 성능 차이가 더욱 흥미롭게 나타납니다.
하지만 정확한 결과는 여러분의 CPU, 메모리, 디스크, 파일 포맷, 데이터 타입, 그리고 실행하는 쿼리에 따라 달라집니다.
그래서 재현 가능한 코드 없이 제시되는 벤치마크 글들은 대부분 오락용 콘텐츠에 가깝습니다.
가장 중요한 차이점: 실행 모델(Execution Model)
이들의 차이는 단순히 문법(Syntax)의 차이가 아닙니다.
진짜 차이는 실행 모델(Execution Model) 에 있습니다.
- Pandas는 훌륭한 노트북 기반 분석 도구와 같습니다.
- Polars는 빠른 DataFrame 엔진과 같습니다.
- DuckDB는 분석용 SQL에 초점을 맞춘, 분석 환경을 위한 SQLite와 같습니다. 다만 트랜잭션 처리 애플리케이션이 아니라 분석 쿼리를 위해 설계되었다는 점이 다릅니다.
물론 이런 비교는 상당히 단순화한 설명입니다.
하지만 도구들의 특성을 이해하는 데는 매우 유용한 비유입니다.
여전히 Pandas를 사용할 경우
여전히 많은 상황에서 pandas를 사용할 것입니다.
예를 들어 다음과 같은 경우입니다.
- 데이터셋이 작은 경우
- 팀이 이미 pandas에 익숙한 경우
- 빠른 시각화와 노트북 기반 탐색 분석이 필요한 경우
- 사용하는 라이브러리가 pandas DataFrame을 요구하는 경우
- 병목 지점이 데이터 처리 과정이 아닌 경우
마지막 항목은 특히 중요합니다.
만약 여러분의 스크립트가 실행 시간의 95%를 외부 API 호출에 사용한다면, pandas를 Polars로 바꾼다고 해서 문제가 해결되지는 않습니다.
먼저 측정하고, 그 다음에 최적화하라.
— Yang Zhou
반면 Polars와 DuckDB에 대해서는 다음과 같이 추천합니다.
Polars는 워크플로가 DataFrame 중심이고, 성능이 중요하며, 파이프라인에 많은 필터링(Filter), 컬럼 선택(Projection), 조인(Join), 집계(Aggregation) 작업이 포함되어 있을 때 사용하세요.
DuckDB는 워크플로가 SQL 중심이고, 데이터가 로컬 파일에 저장되어 있으며, 데이터베이스 서버를 운영하지 않고도 데이터베이스 수준의 분석 기능을 원할 때 사용하세요. 또는 DBA처럼 pandas보다 SQL에 훨씬 더 익숙한 경우에도 훌륭한 선택입니다.
핵심 정리
Pandas는 여전히 많은 Python 데이터 분석 작업에서 가장 쉽고 자연스러운 출발점입니다.
Polars는 DataFrame 파이프라인이 느려지거나 메모리 사용량이 부담스러워지기 시작할 때 고려할 수 있는 강력한 다음 단계입니다.
DuckDB는 분석 작업이 자연스럽게 SQL로 표현되고, 데이터를 파일에 그대로 유지한 채 분석할 수 있을 때 매우 뛰어난 선택입니다.
결국 진정한 생산성은 특정 도구를 맹목적으로 사용하는 것이 아니라, 문제를 더 자연스럽게 해결할 수 있는 실행 모델(Execution Model)을 언제 선택해야 하는지 아는 데서 나옵니다.
'폴라스로 시작하는 데이터 분석' 카테고리의 다른 글
| 아무도 이야기하지 않는 Polars와 SQL의 차이점 (0) | 2026.09.04 |
|---|---|
| 초고속 연구 데이터베이스 구축: Polars와 DuckDB (0) | 2026.06.16 |
| Polars를 사용한 EDA: Pandas 사용자를 위한 단계별 가이드 - Part 1 (0) | 2026.05.17 |
| [2024 영화 박스오피스 데이터 분석]2024년 박스오피스 100위 이내 영화의 '스크린수'의 히스토그램 시각화 (0) | 2026.05.09 |
| [2024 영화 박스오피스 데이터 분석]2024년 한국 영화이면서 전체관람가이자 관객 수가 10만 명 이상인 영화 (0) | 2026.05.06 |
댓글