북극곰은 판다를 찢어

본 글은 Cluade 4 Sonnet으로 작성 후 수정했습니다.

데이터 처리 작업에서 Pandas는 오랫동안 Python 개발자들의 필수 도구였다. 하지만 데이터 규모가 커지고 성능에 대한 요구가 높아지면서 새로운 대안이 필요해졌다. 바로 Polars다.

Polars의 핵심 장점

테스트에 사용한 하드웨어: Ryzen5, 32G RAM

1. 성능 최적화

Polars는 Rust로 구현되어 C/C++ 수준의 성능을 제공한다. 가장 큰 차이점은 멀티스레드 병렬 처리벡터화 연산이다.

import pandas as pd
import polars as pl
import time

# 대용량 데이터셋 생성 (1천만 행)
df_pandas = pd.DataFrame({
    'A': range(10_000_000),
    'B': range(10_000_000, 20_000_000),
    'C': ['group_' + str(i % 1000) for i in range(10_000_000)]
})

df_polars = pl.DataFrame({
    'A': range(10_000_000),
    'B': range(10_000_000, 20_000_000),
    'C': ['group_' + str(i % 1000) for i in range(10_000_000)]
})

# Pandas 성능 측정
start = time.time()
result_pandas = df_pandas.groupby('C').agg({'A': 'mean', 'B': 'sum'})
pandas_time = time.time() - start

# Polars 성능 측정
start = time.time()
result_polars = df_polars.group_by('C').agg([
    pl.col('A').mean(),
    pl.col('B').sum()
])
polars_time = time.time() - start

print(f"Pandas: {pandas_time:.2f}초")
print(f"Polars: {polars_time:.2f}초")
print(f"성능 향상: {pandas_time/polars_time:.1f}배")
  • Pandas: 0.38초
  • Polars: 0.10초
  • 성능 향상: 3.7배

2. 메모리 효율성

Polars는 Apache Arrow의 컬럼 지향 저장 방식을 사용한다. 이는 메모리 사용량을 크게 줄이고 Zero-Copy 연산을 가능하게 한다.

# 메모리 사용량 비교
import psutil
import os

def get_memory_usage():
    """현재 프로세스의 메모리 사용량 반환 (MB)"""
    process = psutil.Process(os.getpid())
    return process.memory_info().rss / 1024 / 1024

# Pandas 메모리 사용량
memory_before = get_memory_usage()
df_pandas_large = pd.DataFrame({
    'col1': range(5_000_000),
    'col2': [f'string_{i}' for i in range(5_000_000)]
})
pandas_memory = get_memory_usage() - memory_before

# Polars 메모리 사용량
memory_before = get_memory_usage()
df_polars_large = pl.DataFrame({
    'col1': range(5_000_000),
    'col2': [f'string_{i}' for i in range(5_000_000)]
})
polars_memory = get_memory_usage() - memory_before

print(f"Pandas 메모리 사용량: {pandas_memory:.1f}MB")
print(f"Polars 메모리 사용량: {polars_memory:.1f}MB")
print(f"메모리 절약: {(1 - polars_memory/pandas_memory)*100:.1f}%")
  • Pandas 메모리 사용량: 384.2MB
  • Polars 메모리 사용량: 180.2MB
  • 메모리 절약: 53.1%

3. 지연 실행(Lazy Execution)

Polars의 가장 강력한 기능 중 하나는 지연 실행이다. 연산을 즉시 수행하지 않고 쿼리 플랜을 구성한 후 최적화하여 실행한다.

# Lazy 연산 예제
df = pl.scan_csv("large_dataset.csv")  # 파일을 즉시 로드하지 않음

# 연산 체인을 구성 (아직 실행되지 않음)
result = (
    df
    .filter(pl.col("age") > 25)         # 나이 25 이상 필터링
    .group_by("department")             # 부서별 그룹화
    .agg([
        pl.col("salary").mean().alias("avg_salary"),  # 평균 급여
        pl.col("employee_id").count().alias("count")  # 직원 수
    ])
    .sort("avg_salary", descending=True) # 평균 급여 기준 정렬
)

# 이때까지 실제 연산은 수행되지 않았음
print("쿼리 플랜:")
print(result.explain())

# collect() 호출 시 최적화된 연산 실행
final_result = result.collect()
print(final_result)

4. 직관적인 API

Pandas 사용자라면 Polars API를 쉽게 익힐 수 있다. 체이닝 방식으로 가독성 높은 코드 작성이 가능하다.

Polars 기본 사용법

설치 및 임포트

# 설치
# pip install polars

import polars as pl
import numpy as np

DataFrame 생성

# 딕셔너리로 생성
df = pl.DataFrame({
    "name": ["Alice", "Bob", "Charlie", "Diana"],
    "age": [25, 30, 35, 28],
    "salary": [50000, 60000, 75000, 55000],
    "department": ["Engineering", "Marketing", "Engineering", "Sales"]
})

# 파일에서 읽기
df_csv = pl.read_csv("data.csv")
df_parquet = pl.read_parquet("data.parquet")

# Pandas DataFrame에서 변환
pandas_df = pd.DataFrame({"a": [1, 2, 3], "b": [4, 5, 6]})
polars_df = pl.from_pandas(pandas_df)

기본 데이터 탐색

# 기본 정보 확인
print(df.shape)        # (행수, 열수)
print(df.columns)      # 컬럼명 리스트
print(df.dtypes)       # 데이터 타입
print(df.head())       # 상위 5행
print(df.describe())   # 기본 통계

# 특정 컬럼 선택
names = df.select("name")                    # 단일 컬럼
subset = df.select(["name", "age"])          # 여러 컬럼
numeric_cols = df.select(pl.col(pl.Int64))   # 타입별 선택

필터링

# 조건부 필터링
young_employees = df.filter(pl.col("age") < 30)

# 복합 조건
high_earners = df.filter(
    (pl.col("salary") > 55000) & 
    (pl.col("department") == "Engineering")
)

# 문자열 패턴 매칭
eng_dept = df.filter(pl.col("department").str.contains("Eng"))

# isin 조건
selected_names = df.filter(pl.col("name").is_in(["Alice", "Bob"]))

데이터 변환

# 새 컬럼 생성
df_with_bonus = df.with_columns([
    (pl.col("salary") * 0.1).alias("bonus"),           # 보너스 계산
    (pl.col("age") // 10).alias("age_group"),          # 연령대 그룹
    pl.col("name").str.to_uppercase().alias("name_upper")  # 대문자 변환
])

# 기존 컬럼 수정
df_modified = df.with_columns([
    pl.col("salary").cast(pl.Float64),     # 타입 변환
    pl.col("age").fill_null(0)             # null 값 채우기
])

그룹화 및 집계

# 부서별 집계
dept_stats = df.group_by("department").agg([
    pl.col("salary").mean().alias("avg_salary"),      # 평균 급여
    pl.col("salary").max().alias("max_salary"),       # 최대 급여
    pl.col("age").min().alias("min_age"),             # 최소 나이
    pl.count().alias("employee_count")                # 직원 수
])

# 복수 컬럼으로 그룹화
age_dept_stats = df.group_by(["department", "age_group"]).agg([
    pl.col("salary").median().alias("median_salary"),
    pl.col("name").count().alias("count")
])

정렬

# 단일 컬럼 정렬
sorted_by_salary = df.sort("salary", descending=True)

# 복수 컬럼 정렬
sorted_multiple = df.sort(["department", "salary"], descending=[False, True])

# null 값 처리 옵션
sorted_with_nulls = df.sort("salary", nulls_last=True)

조인

# 부서 정보 DataFrame
dept_info = pl.DataFrame({
    "department": ["Engineering", "Marketing", "Sales"],
    "budget": [1000000, 500000, 750000],
    "location": ["Seoul", "Busan", "Daegu"]
})

# Inner Join
merged = df.join(dept_info, on="department", how="inner")

# Left Join (기본값)
left_merged = df.join(dept_info, on="department")

# 다른 컬럼명으로 조인
# df.join(other_df, left_on="col1", right_on="col2")

윈도우 함수

# 부서별 급여 순위
df_with_rank = df.with_columns([
    pl.col("salary").rank("dense").over("department").alias("salary_rank")
])

# 이동 평균
df_with_ma = df.with_columns([
    pl.col("salary").rolling_mean(window_size=2).alias("salary_ma")
])

# 누적 합계
df_with_cumsum = df.with_columns([
    pl.col("salary").cumsum().alias("salary_cumsum")
])

파일 저장

# CSV 저장
df.write_csv("output.csv")

# Parquet 저장 (권장 - 압축률과 성능이 뛰어남)
df.write_parquet("output.parquet")

# JSON 저장
df.write_json("output.json")

# Pandas DataFrame으로 변환
pandas_result = df.to_pandas()

성능 최적화 팁

1. Lazy 연산 활용

# Eager 연산 (즉시 실행)
result_eager = (
    pl.read_csv("large_file.csv")
    .filter(pl.col("status") == "active")
    .group_by("category")
    .agg(pl.col("value").sum())
)

# Lazy 연산 (지연 실행) - 권장
result_lazy = (
    pl.scan_csv("large_file.csv")      # scan_ 사용
    .filter(pl.col("status") == "active")
    .group_by("category") 
    .agg(pl.col("value").sum())
    .collect()                         # 마지막에 collect()
)

2. 적절한 데이터 타입 사용

# 메모리 최적화를 위한 타입 지정
optimized_df = pl.DataFrame({
    "id": pl.Series([1, 2, 3], dtype=pl.UInt32),        # 작은 정수형
    "category": pl.Series(["A", "B", "C"], dtype=pl.Categorical),  # 카테고리형
    "value": pl.Series([1.1, 2.2, 3.3], dtype=pl.Float32)  # 단정도 실수
})

3. 조건부 연산 최적화

# when().then().otherwise() 사용
df_with_grade = df.with_columns([
    pl.when(pl.col("salary") > 70000)
    .then(pl.lit("Senior"))
    .when(pl.col("salary") > 55000) 
    .then(pl.lit("Mid"))
    .otherwise(pl.lit("Junior"))
    .alias("grade")
])

마무리

Polars는 현대적인 데이터 처리 환경에서 Pandas의 한계를 뛰어넘는 강력한 도구다. 특히 대용량 데이터 처리, 성능이 중요한 ETL 파이프라인, 그리고 메모리 효율성이 필요한 환경에서 그 진가를 발휘한다.

Pandas에 익숙한 개발자라면 비교적 쉽게 Polars로 전환할 수 있으며, 지연 실행과 병렬 처리의 이점을 누릴 수 있다. 다음 프로젝트에서는 Polars를 고려해보는 것이 어떨까?