[Rust Wave] Day 1: Pandas는 죽었다, Polars의 Lazy Evaluation과 메모리 모델
서론: “RAM의 10배” 법칙의 종말
지난 10여 년간 Python 데이터 생태계에서 Pandas는 절대적인 지위를 누렸다. 하지만 데이터 엔지니어들에게는 암묵적인 룰이 있었다. “Pandas로 데이터를 처리하려면, 데이터 크기의 5배에서 10배에 달하는 RAM이 필요하다.”
이는 Pandas가 NumPy 기반으로 설계되었음에도 불구하고, 문자열(String) 처리나 결측치(Null) 관리에서 Python의 네이티브 객체(PyObject) 오버헤드를 그대로 떠안고 있었기 때문이다. 또한, Python의 GIL(Global Interpreter Lock)로 인해 최신 멀티코어 CPU의 성능을 전혀 활용하지 못하는 단일 스레드(Single-threaded) 방식은 2026년의 데이터 규모를 감당하기에 역부족이다.
Rust로 작성된 Polars는 단순한 라이브러리가 아니다. 이것은 인메모리(In-memory) 쿼리 엔진이다.
1. 메모리 모델: Apache Arrow와 Zero-Copy
Pandas와 Polars의 가장 근본적인 차이는 데이터를 메모리에 적재하는 방식, 즉 Memory Layout에 있다.
1.1 Pandas: 분절된 메모리와 포인터
Pandas(특히 2.0 이전)는 데이터를 메모리 상에 연속적으로 배치하지 못하는 경우가 많았다. 특히 문자열 데이터는 각 문자열 객체를 가리키는 포인터의 배열로 관리되었다.
- Cache Miss: CPU가 데이터를 읽을 때마다 포인터를 따라 점프(Pointer Chasing)해야 하므로, L1/L2 캐시 적중률(Cache Hit Rate)이 현저히 떨어진다.
- Overhead: Python 객체 헤더 정보로 인해 실제 데이터보다 더 많은 메모리를 점유한다.
1.2 Polars: Apache Arrow (Columnar)
Polars는 메모리 포맷으로 Apache Arrow를 채택했다. Arrow는 컬럼 기반(Columnar) 포맷으로, 데이터가 메모리 상에 물리적으로 연속되어 배치된다.
- SIMD 최적화: 데이터가 연속되어 있으므로 CPU의 벡터 연산 명령어(SIMD, Single Instruction Multiple Data)를 활용하여 한 번의 CPU 사이클로 여러 데이터를 동시에 처리할 수 있다. AVX-512 같은 최신 명령어 세트의 혜택을 온전히 받는다.
- Zero-Copy: Arrow 포맷을 지원하는 다른 도구(예: PyArrow, DuckDB)와 데이터를 주고받을 때, 직렬화/역직렬화(Serialization) 과정 없이 메모리 주소만 넘겨주면 된다. 이는 ETL 파이프라인의 병목을 획기적으로 줄여준다.
2. 실행 모델: Eager vs Lazy Evaluation
Polars가 Pandas를 압도하는 성능의 비결은 실행 시점을 지연시키는 Lazy Evaluation(지연 평가)에 있다.
2.1 Eager Execution (Pandas)
Pandas는 코드를 한 줄 읽을 때마다 즉시 실행한다.
1
2
3
4
5
# Pandas: 즉시 실행
df = df[df['category'] == 'A'] # 1. 전체 데이터를 스캔하여 필터링
df = df.sort_values('date') # 2. 필터링된 데이터를 정렬
result = df.head(10) # 3. 상위 10개 추출
위 코드에서 Pandas는 사용자의 의도(Top 10만 필요함)를 모른 채 전체 데이터를 정렬하는 비효율을 범한다.
2.2 Lazy Execution (Polars)
Polars는 .lazy() 메서드를 호출하는 순간, 즉시 실행하지 않고 Logical Plan(논리적 실행 계획)을 수립한다. 이후 .collect()가 호출되면 Query Optimizer가 개입하여 계획을 최적화한 뒤 실행한다.
1
2
3
4
5
6
7
8
9
# Polars: 실행 계획 수립 -> 최적화 -> 실행
result = (
df.lazy()
.filter(pl.col('category') == 'A')
.sort('date')
.limit(10)
.collect()
)
2.3 Query Optimizer의 역할
Polars의 옵티마이저는 다음과 같은 최적화를 자동으로 수행한다.
- Predicate Pushdown: 필터(
WHERE) 조건을 데이터를 읽는 시점(Scan)으로 최대한 밀어 넣는다. Parquet 파일을 읽을 때 필요한 행(Row)만 로드하여 I/O를 최소화한다. - Projection Pushdown: 필요한 컬럼(
SELECT)만 로드한다. 사용하지 않는 컬럼은 메모리에 올리지 않는다. - Common Subexpression Elimination: 중복되는 연산이 있으면 한 번만 계산하고 재사용한다.
3. 병렬 처리: Fearless Concurrency
Python 개발자들이 가장 고통받는 GIL(Global Interpreter Lock) 문제가 Polars에는 존재하지 않는다.
3.1 Rust의 소유권 모델 (Ownership)
Polars는 Rust로 작성되었기 때문에, Rust의 소유권 모델을 통해 스레드 안전(Thread Safety)을 컴파일 타임에 보장한다. 즉, 락(Lock)을 걸어 성능을 저하시키지 않고도 데이터 경합(Data Race) 없이 멀티 스레딩을 구현한다.
3.2 Parallel Execution
Polars는 쿼리를 실행할 때 시스템의 모든 CPU 코어를 적극적으로 활용한다.
- Work Stealing: 사용 가능한 스레드 풀에 작업을 분배하고, 특정 스레드가 먼저 끝나면 남은 작업을 가져와 처리하는 방식으로 CPU 유휴 자원을 최소화한다.
- Out-of-Core Processing: 메모리보다 큰 데이터를 처리할 때, 데이터를 청크(Chunk) 단위로 쪼개어 스트리밍 방식으로 처리(Streaming API)할 수 있어 OOM(Out of Memory) 오류를 방지한다.
4. 코드 비교 및 마이그레이션
문법적으로 Polars는 Pandas와 유사하면서도, SQL과 Spark의 함수형 스타일을 지향한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
# Pandas (Index 기반)
# df['new_col'] = df['col_a'] * 2
# df_filtered = df[df['col_b'] > 10]
# Polars (Expression 기반)
import polars as pl
df.with_columns(
(pl.col('col_a') * 2).alias('new_col')
).filter(
pl.col('col_b') > 10
)
Polars의 Expression API는 병렬 처리에 최적화된 구조로, 컬럼 간의 의존성을 파악하여 독립적인 연산은 동시에 실행한다.
5. 요약 및 시사점
| 특징 | Pandas | Polars |
|---|---|---|
| 언어 | Python / Cython | Rust |
| 메모리 포맷 | NumPy Array / Python Objects | Apache Arrow |
| 실행 방식 | Eager (즉시 실행) | Lazy (지연 평가) & Eager |
| 병렬 처리 | Single Thread (GIL 제한) | Multi-Thread (SIMD 활용) |
| 최적화 | 없음 | Predicate/Projection Pushdown |
결론적으로, 데이터 사이즈가 GB 단위를 넘어가거나, 복잡한 전처리 파이프라인의 속도가 병목이라면 Pandas를 고집할 이유는 사라졌다. Polars는 단순한 ‘빠른 Pandas’가 아니라, 로컬 환경에서 Spark의 최적화 기술을 구현한 경량 쿼리 엔진이다.
이제 우리는 JVM의 무거움 없이도, Python의 느림 없이도 대용량 데이터를 처리할 수 있는 도구를 손에 넣었다. 하지만 Rust 생태계의 혁신은 여기서 멈추지 않는다. 내일은 이 고성능 엔진을 분산 처리와 데이터 레이크로 확장해 본다.