Polars 완벽 가이드: Pandas 사용자를 위한 차세대 데이터프레임 라이브러리

Polars는 Rust 기반의 차세대 데이터프레임 라이브러리로, pandas 대비 최대 20배 빠른 성능을 제공합니다. 표현식 시스템, 지연 평가, 스트리밍 엔진, GPU 가속 등 핵심 기능을 실전 코드와 함께 알아보세요.

서론: 왜 Polars인가?

솔직히 말하면, 저도 처음엔 "pandas면 충분하지 않나?"라고 생각했습니다. 수년간 pandas로 모든 걸 해결해왔으니까요. 그런데 데이터가 수천만 행을 넘기 시작하면서 상황이 달라졌습니다. 메모리가 터지고, 집계 하나에 몇 분씩 걸리고... pandas의 단일 스레드 아키텍처가 정말 발목을 잡더라고요.

그래서 만나게 된 것이 바로 Polars입니다.

Polars는 Rust로 작성된 고성능 데이터프레임 라이브러리로, Apache Arrow 메모리 모델을 기반으로 합니다. 단일 머신에서 수억 행의 데이터를 효율적으로 처리할 수 있고, 지연 평가(lazy evaluation), 자동 병렬 처리, 쿼리 최적화 같은 현대적인 데이터 처리 기법을 기본으로 지원하죠. 2026년 현재 Polars는 1.x 안정 버전에 도달했으며, GPU 가속, 새로운 스트리밍 엔진, 심지어 Polars Cloud까지 나오면서 데이터 엔지니어링의 핵심 도구로 확실히 자리 잡았습니다.

이 글에서는 pandas 경험이 있는 분들을 대상으로, Polars의 핵심 개념부터 실전 활용까지 하나씩 짚어보겠습니다. 자, 그럼 시작해 볼까요?

설치 및 기본 환경 구성

Polars 설치하기

설치는 놀라울 정도로 간단합니다. pip 한 줄이면 끝이에요.

# 기본 설치
pip install polars

# 모든 선택적 의존성 포함 설치
pip install 'polars[all]'

# 특정 기능만 선택 설치
pip install 'polars[numpy,pandas,pyarrow,fsspec]'

# GPU 엔진 지원 (NVIDIA GPU 필요)
pip install 'polars[gpu]'

개인적으로는 처음 시작할 때 polars[all]로 설치하는 걸 추천합니다. 나중에 이것저것 추가 설치하는 번거로움을 줄일 수 있거든요.

버전 확인 및 기본 임포트

import polars as pl
print(pl.__version__)  # 1.x.x

# pandas와 비교를 위한 임포트
import pandas as pd
import numpy as np

Polars의 관례적인 별칭은 pl입니다. pandas의 pd처럼요. 이 글 전체에서 이 관례를 따르겠습니다.

핵심 데이터 구조: DataFrame과 Series

DataFrame 생성

Polars의 DataFrame은 pandas와 비슷하게 딕셔너리로 만들 수 있는데, 몇 가지 중요한 차이가 있습니다. 직접 코드를 보시죠.

import polars as pl

# 딕셔너리로부터 DataFrame 생성
df = pl.DataFrame({
    "이름": ["김민수", "이지은", "박서준", "최유리", "정하윤"],
    "나이": [28, 34, 25, 31, 29],
    "부서": ["개발", "마케팅", "개발", "디자인", "마케팅"],
    "연봉": [5500, 6200, 4800, 5900, 5100],
    "입사일": ["2022-03-15", "2019-07-01", "2023-11-20", "2021-01-10", "2022-08-05"]
})

print(df)
# shape: (5, 5)
# ┌────────┬──────┬──────────┬──────┬────────────┐
# │ 이름   ┆ 나이 ┆ 부서     ┆ 연봉 ┆ 입사일     │
# │ ---    ┆ ---  ┆ ---      ┆ ---  ┆ ---        │
# │ str    ┆ i64  ┆ str      ┆ i64  ┆ str        │
# ╞════════╪══════╪══════════╪══════╪════════════╡
# │ 김민수 ┆ 28   ┆ 개발     ┆ 5500 ┆ 2022-03-15 │
# │ 이지은 ┆ 34   ┆ 마케팅   ┆ 6200 ┆ 2019-07-01 │
# │ …      ┆ …    ┆ …        ┆ …    ┆ …          │
# └────────┴──────┴──────────┴──────┴────────────┘

출력 형식이 pandas와 좀 다르죠? 이 박스 형태의 출력이 개인적으로 훨씬 깔끔하게 느껴집니다.

pandas와의 핵심 차이: 인덱스가 없다

여기서 한 가지 꼭 알아야 할 점이 있습니다. Polars에는 인덱스(index)가 존재하지 않습니다. pandas에서 인덱스가 얼마나 핵심적인 개념인지 생각하면 꽤 파격적이죠.

.loc, .iloc, .reset_index() 같은 메서드가 전혀 없습니다. 대신 표현식(expression) 기반의 API를 사용해요.

# pandas 방식 (인덱스 기반)
# pdf.loc[pdf["나이"] > 30, "이름"]

# Polars 방식 (표현식 기반)
df.filter(pl.col("나이") > 30).select("이름")

# 행 번호로 접근할 때
df.row(0)          # 첫 번째 행을 튜플로 반환
df.slice(1, 3)     # 1번째부터 3개 행 슬라이싱

처음에는 인덱스 없는 게 불편할 수 있는데, 써보면 오히려 코드가 명확해지는 걸 느낄 수 있습니다.

데이터 타입 시스템

Polars는 Apache Arrow의 타입 시스템을 사용합니다. pandas보다 훨씬 엄격하고 정밀한데, 이게 실제로는 장점입니다. 타입 관련 버그가 줄어들거든요.

# 명시적 타입 지정으로 DataFrame 생성
df = pl.DataFrame({
    "정수형": pl.Series([1, 2, 3], dtype=pl.Int32),
    "실수형": pl.Series([1.5, 2.7, 3.9], dtype=pl.Float64),
    "문자열": pl.Series(["a", "b", "c"], dtype=pl.Utf8),
    "날짜": pl.Series(["2026-01-01", "2026-02-01", "2026-03-01"]).str.to_date(),
    "불리언": pl.Series([True, False, True], dtype=pl.Boolean),
})

print(df.dtypes)
# [Int32, Float64, String, Date, Boolean]

주요 데이터 타입으로는 Int8~Int64, UInt8~UInt64, Float32, Float64, Boolean, Utf8(문자열), Date, Datetime, Duration, Categorical, List, Struct 등이 있습니다.

표현식 시스템: Polars의 심장

Polars를 제대로 쓰려면 표현식(Expression) 시스템을 이해해야 합니다. 이게 정말 Polars의 심장이에요. pandas가 메서드 체이닝과 인덱싱에 의존하는 반면, Polars는 선언적 표현식을 통해 데이터를 변환합니다.

세 가지 핵심 컨텍스트

표현식이 실행되는 세 가지 컨텍스트를 알아두면 Polars가 훨씬 쉬워집니다.

import polars as pl

df = pl.DataFrame({
    "이름": ["김민수", "이지은", "박서준"],
    "점수A": [85, 92, 78],
    "점수B": [90, 88, 95],
})

# 1. select(): 열을 선택하고 변환 (결과는 선택된 열만 포함)
result = df.select(
    pl.col("이름"),
    pl.col("점수A").mean().alias("평균_점수A"),
)

# 2. with_columns(): 기존 DataFrame에 열을 추가/수정
result = df.with_columns(
    총점=pl.col("점수A") + pl.col("점수B"),
    점수A_정규화=pl.col("점수A") / pl.col("점수A").max(),
)

# 3. filter(): 행을 필터링
result = df.filter(
    (pl.col("점수A") > 80) & (pl.col("점수B") > 85)
)

간단히 정리하면: select()는 지정된 열만 남기고, with_columns()는 기존 열은 유지하면서 새 열을 추가합니다. 이 차이를 헷갈리면 예상치 못한 결과를 얻게 되니 꼭 구분해 두세요.

pl.col() — 열 참조의 기본

# 단일 열 참조
pl.col("이름")

# 여러 열 동시 참조
pl.col("점수A", "점수B")

# 정규식으로 열 선택
pl.col("^점수.*$")

# 데이터 타입으로 열 선택
pl.col(pl.Int64)  # 모든 Int64 열

# 모든 열 선택
pl.all()

# 특정 열 제외
pl.all().exclude("이름")

정규식으로 열을 선택할 수 있다는 건 꽤 유용한 기능입니다. 비슷한 이름의 열이 수십 개일 때 특히요.

표현식 체이닝

Polars 표현식의 진짜 힘은 체이닝에 있습니다. 여러 변환을 줄줄이 연결할 수 있고, Polars가 내부적으로 알아서 최적화해줍니다.

df = pl.DataFrame({
    "제품": ["노트북", "태블릿", "스마트폰", "노트북", "태블릿"],
    "가격": [1500000, 800000, 1200000, 1800000, 650000],
    "할인율": [0.1, 0.15, 0.05, 0.2, 0.1],
    "카테고리": ["전자기기", "전자기기", "전자기기", "전자기기", "전자기기"],
})

result = df.with_columns(
    # 체이닝된 표현식: 할인 적용 후 반올림
    최종가격=pl.col("가격")
        .mul(1 - pl.col("할인율"))
        .round(0)
        .cast(pl.Int64),

    # 조건부 표현식
    가격대=pl.when(pl.col("가격") > 1000000)
        .then(pl.lit("고가"))
        .when(pl.col("가격") > 700000)
        .then(pl.lit("중가"))
        .otherwise(pl.lit("저가")),
)

print(result)

pl.when().then().otherwise() 구문은 SQL의 CASE WHEN과 비슷한데, 개인적으로 pandas의 np.where()보다 읽기 훨씬 편하다고 생각합니다.

pandas vs Polars: API 대조표

pandas에서 Polars로 넘어올 때 가장 먼저 찾게 되는 게 바로 이 대조표입니다. 저도 처음에 이런 표 옆에 두고 작업했어요.

데이터 읽기/쓰기

# === CSV 읽기 ===
# pandas
pdf = pd.read_csv("data.csv")
# Polars (즉시 실행)
df = pl.read_csv("data.csv")
# Polars (지연 실행 — 권장)
lf = pl.scan_csv("data.csv")

# === Parquet 읽기 ===
# pandas
pdf = pd.read_parquet("data.parquet")
# Polars
df = pl.read_parquet("data.parquet")
lf = pl.scan_parquet("data.parquet")  # 지연 실행

# === CSV 쓰기 ===
# pandas
pdf.to_csv("output.csv", index=False)
# Polars
df.write_csv("output.csv")

# === Parquet 쓰기 ===
# pandas
pdf.to_parquet("output.parquet")
# Polars
df.write_parquet("output.parquet")

눈치채셨겠지만, Polars에서는 read_ 대신 scan_을 쓰는 게 권장됩니다. 이유는 바로 다음 섹션에서 다룰 지연 평가 때문이에요.

데이터 선택 및 필터링

# === 열 선택 ===
# pandas
pdf[["이름", "나이"]]
# Polars
df.select("이름", "나이")

# === 행 필터링 ===
# pandas
pdf[pdf["나이"] > 30]
# Polars
df.filter(pl.col("나이") > 30)

# === 조건부 열 생성 ===
# pandas
pdf["등급"] = pdf["점수"].apply(lambda x: "A" if x >= 90 else "B")
# Polars (람다 함수 불필요 — 훨씬 빠름!)
df.with_columns(
    등급=pl.when(pl.col("점수") >= 90)
        .then(pl.lit("A"))
        .otherwise(pl.lit("B"))
)

람다 함수를 안 써도 된다는 건 성능 면에서 정말 큰 차이를 만듭니다. pandas에서 apply()가 느린 이유가 바로 Python 레벨의 루프를 돌기 때문이거든요.

그룹화 및 집계

# === 그룹별 집계 ===
# pandas
pdf.groupby("부서").agg({"연봉": ["mean", "max"], "나이": "count"})

# Polars
df.group_by("부서").agg(
    평균연봉=pl.col("연봉").mean(),
    최대연봉=pl.col("연봉").max(),
    인원수=pl.col("나이").count(),
)

# === 정렬 ===
# pandas
pdf.sort_values("연봉", ascending=False)
# Polars
df.sort("연봉", descending=True)

# === 결측치 처리 ===
# pandas
pdf.fillna(0)
pdf.dropna()
# Polars
df.fill_null(0)
df.drop_nulls()

# === 조인 (병합) ===
# pandas
pd.merge(pdf1, pdf2, on="키", how="left")
# Polars
df1.join(df2, on="키", how="left")

Polars의 group_by().agg()이 pandas보다 직관적이라는 느낌을 받으실 겁니다. 각 집계 결과에 바로 이름을 붙일 수 있으니까요.

지연 평가(Lazy Evaluation): 성능의 비밀

자, 이제 Polars의 가장 강력한 무기를 꺼내볼 차례입니다. 지연 평가(Lazy Evaluation)야말로 pandas와 Polars의 성능 차이를 만드는 핵심이에요.

즉시 실행 vs 지연 실행

# === 즉시 실행 (Eager) — pandas와 유사 ===
df = pl.read_csv("sales.csv")
result = df.filter(pl.col("금액") > 10000).select("고객명", "금액")

# === 지연 실행 (Lazy) — 권장 방식 ===
result = (
    pl.scan_csv("sales.csv")           # LazyFrame 생성
    .filter(pl.col("금액") > 10000)    # 쿼리 계획에 추가
    .select("고객명", "금액")           # 쿼리 계획에 추가
    .collect()                          # 여기서 실제 실행!
)

핵심은 이겁니다: .collect()를 호출하기 전까지 아무 연산도 실행되지 않습니다. 대신 Polars는 전체 쿼리 계획을 먼저 세우고, 최적화한 다음 한 번에 쫙 실행합니다. (데이터베이스의 쿼리 옵티마이저와 비슷한 개념이라고 보시면 됩니다.)

쿼리 최적화의 위력

Polars의 쿼리 옵티마이저가 자동으로 해주는 일들을 보면 꽤 놀랍습니다.

# 최적화 예시: 대용량 CSV 처리
lf = (
    pl.scan_csv("large_data.csv")  # 전체 파일을 메모리에 로드하지 않음
    .filter(pl.col("연도") == 2026)
    .select("고객ID", "매출액")
    .group_by("고객ID")
    .agg(pl.col("매출액").sum())
)

# 최적화된 쿼리 계획 확인
print(lf.explain())
# 출력 예시:
# AGGREGATE
#   [col("매출액").sum()] BY [col("고객ID")]
#     CSV SCAN large_data.csv
#       PROJECT 3/10 COLUMNS   <-- 프로젝션 푸시다운: 필요한 열만 읽음
#       SELECTION: [(col("연도")) == (2026)]  <-- 프레디킷 푸시다운

# 실제 실행
result = lf.collect()

여기서 Polars가 자동으로 수행하는 두 가지 핵심 최적화를 짚고 넘어가겠습니다.

  • 프로젝션 푸시다운(Projection Pushdown): CSV 파일에 10개 열이 있어도 필요한 3개 열만 읽습니다. 메모리가 확 줄어들죠.
  • 프레디킷 푸시다운(Predicate Pushdown): 필터 조건을 파일 읽기 단계로 밀어넣어서, 조건에 안 맞는 행은 아예 메모리에 올리지 않습니다.

솔직히, 이 최적화들이 pandas에서는 수동으로 해야 할 작업들이라 정말 편합니다.

DataFrame을 LazyFrame으로 변환

# 기존 DataFrame을 LazyFrame으로 변환
df = pl.DataFrame({"a": [1, 2, 3], "b": [4, 5, 6]})
lf = df.lazy()

# LazyFrame에서 작업 수행 후 수집
result = lf.filter(pl.col("a") > 1).with_columns(
    합계=pl.col("a") + pl.col("b")
).collect()

print(result)
# shape: (2, 3)
# ┌─────┬─────┬──────┐
# │ a   ┆ b   ┆ 합계 │
# │ --- ┆ --- ┆ ---  │
# │ i64 ┆ i64 ┆ i64  │
# ╞═════╪═════╪══════╡
# │ 2   ┆ 5   ┆ 7    │
# │ 3   ┆ 6   ┆ 9    │
# └─────┴─────┴──────┘

.lazy() 한 줄이면 즉시 실행 모드에서 지연 실행 모드로 전환됩니다. 기존 코드를 마이그레이션할 때 아주 유용해요.

실전 데이터 처리 파이프라인

이론은 충분히 다뤘으니, 이제 실제로 Polars가 어떻게 쓰이는지 살펴보겠습니다.

예제 1: 매출 데이터 분석 파이프라인

import polars as pl
from datetime import date

# 샘플 데이터 생성
sales = pl.DataFrame({
    "주문일": pl.date_range(date(2025, 1, 1), date(2025, 12, 31), eager=True)
              .sample(1000, seed=42),
    "고객ID": [f"C{i:04d}" for i in range(1, 1001)],
    "제품": ["노트북", "태블릿", "스마트폰", "이어폰", "키보드"] * 200,
    "수량": [1, 2, 1, 3, 2, 1, 4, 1, 2, 1] * 100,
    "단가": [1500000, 800000, 1200000, 150000, 80000] * 200,
})

# 분석 파이프라인
result = (
    sales.lazy()
    .with_columns(
        매출액=pl.col("수량") * pl.col("단가"),
        월=pl.col("주문일").dt.month(),
        분기=pl.col("주문일").dt.quarter(),
    )
    .group_by("제품", "분기")
    .agg(
        총매출=pl.col("매출액").sum(),
        평균주문액=pl.col("매출액").mean(),
        주문건수=pl.col("매출액").count(),
        최대주문액=pl.col("매출액").max(),
    )
    .sort("제품", "분기")
    .with_columns(
        # 분기별 매출 비중 계산
        매출비중=(pl.col("총매출") / pl.col("총매출").sum()
                  .over("제품") * 100).round(1)
    )
    .collect()
)

print(result)

이 정도 파이프라인이 pandas에서는 중간 변수를 여러 개 만들어야 하는데, Polars에서는 체이닝 한 방에 끝납니다. 깔끔하죠?

예제 2: 윈도우 함수 활용

Polars의 윈도우 함수(.over())는 pandas의 groupby().transform()을 대체합니다. 그리고 솔직히... 훨씬 직관적입니다.

import polars as pl

employees = pl.DataFrame({
    "이름": ["김민수", "이지은", "박서준", "최유리", "정하윤",
            "한소희", "강도현", "윤서연", "임재범", "송지아"],
    "부서": ["개발", "개발", "마케팅", "마케팅", "개발",
            "디자인", "디자인", "개발", "마케팅", "디자인"],
    "연봉": [5500, 6200, 4800, 5900, 5100,
            5300, 4900, 7200, 5600, 5000],
})

result = employees.with_columns(
    # 부서별 평균 연봉
    부서평균=pl.col("연봉").mean().over("부서"),

    # 부서 내 연봉 순위
    부서내순위=pl.col("연봉").rank(descending=True).over("부서"),

    # 부서별 연봉 편차 (개인 연봉 - 부서 평균)
    연봉편차=(pl.col("연봉") - pl.col("연봉").mean().over("부서")).round(0),

    # 부서별 연봉 비중
    부서내비중=(pl.col("연봉") / pl.col("연봉").sum().over("부서") * 100).round(1),
)

print(result)

.over("부서") 한 줄로 "부서별로 계산해줘"라는 의미를 전달할 수 있습니다. SQL의 OVER (PARTITION BY ...)를 써보신 분이라면 바로 감이 올 거예요.

예제 3: 문자열 및 날짜 처리

실무에서 가장 많이 하는 작업 중 하나가 로그 파싱이죠. Polars로 어떻게 하는지 볼게요.

import polars as pl
from datetime import datetime

logs = pl.DataFrame({
    "타임스탬프": [
        "2026-02-01 09:30:15", "2026-02-01 14:22:33",
        "2026-02-02 08:15:42", "2026-02-02 16:45:08",
        "2026-02-03 11:00:00",
    ],
    "메시지": [
        "ERROR: 연결 실패 - DB_HOST_01",
        "WARNING: 메모리 사용량 85% 초과",
        "ERROR: 타임아웃 발생 - API_ENDPOINT",
        "INFO: 백업 완료",
        "ERROR: 디스크 공간 부족 - /dev/sda1",
    ],
    "서버": ["web-01", "web-02", "api-01", "db-01", "web-01"],
})

result = logs.with_columns(
    # 문자열을 datetime으로 변환
    pl.col("타임스탬프").str.to_datetime("%Y-%m-%d %H:%M:%S"),
).with_columns(
    # 로그 레벨 추출
    로그레벨=pl.col("메시지").str.extract(r"^(\w+):"),

    # 시간대 분류
    시간대=pl.when(pl.col("타임스탬프").dt.hour() < 12)
        .then(pl.lit("오전"))
        .otherwise(pl.lit("오후")),

    # 요일 추출
    요일=pl.col("타임스탬프").dt.weekday(),
).filter(
    pl.col("로그레벨") == "ERROR"
)

print(result)

문자열에서 정규식으로 패턴을 추출하고, 날짜 파싱하고, 필터링까지 — 한 번의 체이닝으로 다 처리됩니다.

스트리밍 엔진: 메모리보다 큰 데이터 처리

여기서부터가 정말 흥미로운 부분입니다. Polars 1.31 이후에 도입된 새로운 스트리밍 엔진은 메모리보다 큰 데이터셋도 처리할 수 있게 해줍니다. 데이터를 배치 단위로 쪼개서 처리하기 때문에, 전체를 한 번에 메모리에 올릴 필요가 없거든요.

# 스트리밍 모드로 대용량 데이터 처리
result = (
    pl.scan_parquet("huge_dataset/*.parquet")
    .filter(pl.col("지역") == "서울")
    .group_by("카테고리")
    .agg(
        총매출=pl.col("매출").sum(),
        건수=pl.col("매출").count(),
    )
    .sort("총매출", descending=True)
    .collect(engine="streaming")  # 스트리밍 엔진 사용
)

# 결과를 파일로 직접 저장 (메모리에 최종 결과도 로드하지 않음)
(
    pl.scan_parquet("huge_dataset/*.parquet")
    .filter(pl.col("연도") == 2026)
    .select("고객ID", "제품", "매출")
    .sink_parquet("filtered_2026.parquet")  # 결과를 직접 파일로 출력
)

sink_parquet()를 쓰면 최종 결과조차 메모리에 안 올리고 바로 파일로 쏩니다. 덕분에 수십 GB 데이터도 일반 노트북에서 처리할 수 있어요. (Spark 없이도요!)

GPU 가속: collect(engine="gpu")

NVIDIA GPU가 있다면? 코드 한 줄도 안 바꾸고 GPU 가속을 쓸 수 있습니다.

# GPU 엔진으로 수집 (NVIDIA GPU 필요)
result = (
    pl.scan_parquet("large_data.parquet")
    .filter(pl.col("값") > 100)
    .group_by("카테고리")
    .agg(pl.col("값").mean())
    .collect(engine="gpu")  # GPU에서 실행
)

# CPU 대비 최대 13배 성능 향상 가능

GPU 엔진을 쓰려면 NVIDIA Volta 이상의 GPU(컴퓨트 능력 7.0+)와 CUDA 12가 필요합니다. 현재 Polars 단위 테스트의 99.2%를 통과하고 있고, GPU 메모리를 초과하는 데이터도 CUDA Unified Memory를 통해 처리할 수 있습니다.

성능 비교: Polars vs pandas

"정말 그렇게 빠른가요?"라고 물으실 수 있는데, 직접 벤치마크를 돌려보면 확실히 체감됩니다.

벤치마크 테스트

import polars as pl
import pandas as pd
import numpy as np
import time

# 테스트 데이터 생성: 1000만 행
n_rows = 10_000_000
np.random.seed(42)

data = {
    "id": np.random.randint(1, 100000, n_rows),
    "value": np.random.randn(n_rows),
    "category": np.random.choice(["A", "B", "C", "D", "E"], n_rows),
}

pdf = pd.DataFrame(data)
pldf = pl.DataFrame(data)

# === 필터링 벤치마크 ===
start = time.time()
pdf[pdf["value"] > 0]
pandas_filter = time.time() - start

start = time.time()
pldf.filter(pl.col("value") > 0)
polars_filter = time.time() - start

print(f"필터링 - pandas: {pandas_filter:.3f}s, Polars: {polars_filter:.3f}s")
print(f"Polars가 {pandas_filter/polars_filter:.1f}배 빠름")

# === 그룹화 집계 벤치마크 ===
start = time.time()
pdf.groupby("category")["value"].agg(["mean", "std", "count"])
pandas_groupby = time.time() - start

start = time.time()
pldf.group_by("category").agg(
    pl.col("value").mean().alias("mean"),
    pl.col("value").std().alias("std"),
    pl.col("value").count().alias("count"),
)
polars_groupby = time.time() - start

print(f"그룹화 - pandas: {pandas_groupby:.3f}s, Polars: {polars_groupby:.3f}s")
print(f"Polars가 {pandas_groupby/polars_groupby:.1f}배 빠름")

일반적인 벤치마크 결과를 정리하면 이렇습니다:

  • 필터링: pandas 대비 3~5배 빠름
  • 그룹화 집계: pandas 대비 2~10배 빠름
  • 조인: pandas 대비 5~20배 빠름 (이건 진짜 압도적입니다)
  • 메모리 사용량: pandas 대비 50~80% 절감
  • 에너지 소비: pandas 대비 약 8배 적은 에너지 소비

특히 조인 성능은 Polars가 압도적인데, 이건 Rust 기반의 해시 조인 구현 덕분입니다.

pandas와의 상호 운용

현실적으로, pandas를 하루아침에 버릴 수는 없습니다. 기존 코드도 있고, pandas 전용 라이브러리도 많으니까요. 다행히 Polars는 pandas와의 변환을 아주 쉽게 지원합니다.

import polars as pl
import pandas as pd

# pandas DataFrame → Polars DataFrame
pdf = pd.DataFrame({"a": [1, 2, 3], "b": ["x", "y", "z"]})
pldf = pl.from_pandas(pdf)

# Polars DataFrame → pandas DataFrame
pdf_back = pldf.to_pandas()

# NumPy 배열과의 변환
import numpy as np
arr = pldf["a"].to_numpy()

# pandas Series → Polars Series
ps = pd.Series([1, 2, 3], name="values")
pls = pl.from_pandas(ps)

# PyArrow Table과의 제로카피 변환
import pyarrow as pa
arrow_table = pldf.to_arrow()
pldf_from_arrow = pl.from_arrow(arrow_table)

특히 PyArrow를 통한 변환은 제로카피(zero-copy)로 이루어집니다. 대용량 데이터에서도 변환 비용이 거의 0에 가깝다는 뜻이에요.

고급 기능: 실전에서 빛나는 활용법

1. 동적 열 생성 (concat_list 활용)

import polars as pl

df = pl.DataFrame({
    "이름": ["김민수", "이지은", "박서준"],
    "국어": [85, 92, 78],
    "수학": [90, 88, 95],
    "영어": [82, 96, 85],
})

# 여러 열을 동시에 집계
subject_cols = ["국어", "수학", "영어"]

result = df.with_columns(
    평균=pl.concat_list(subject_cols).list.mean(),
    최고점=pl.concat_list(subject_cols).list.max(),
    최저점=pl.concat_list(subject_cols).list.min(),
)

print(result)

과목이 늘어나도 subject_cols 리스트만 수정하면 되니 유지보수가 편합니다.

2. Struct 타입으로 복합 데이터 처리

import polars as pl

df = pl.DataFrame({
    "이름": ["김민수", "이지은"],
    "주소": [
        {"시": "서울", "구": "강남", "동": "역삼"},
        {"시": "부산", "구": "해운대", "동": "우동"},
    ],
})

# Struct 필드 접근
result = df.with_columns(
    도시=pl.col("주소").struct.field("시"),
    상세주소=pl.col("주소").struct.field("시")
        + " " + pl.col("주소").struct.field("구")
        + " " + pl.col("주소").struct.field("동"),
)

print(result)

JSON 같은 중첩 데이터를 다룰 때 Struct 타입이 정말 유용합니다. pandas에서는 이런 작업이 상당히 번거롭죠.

3. SQL 인터페이스

SQL이 더 편한 분들을 위해 (저도 가끔 그렇습니다), Polars는 SQL 인터페이스도 제공합니다.

import polars as pl

df = pl.DataFrame({
    "이름": ["김민수", "이지은", "박서준", "최유리"],
    "부서": ["개발", "마케팅", "개발", "디자인"],
    "연봉": [5500, 6200, 4800, 5900],
})

# SQL 컨텍스트 생성
ctx = pl.SQLContext(직원=df)

# SQL 쿼리 실행
result = ctx.execute("""
    SELECT 부서,
           AVG(연봉) AS 평균연봉,
           COUNT(*) AS 인원수
    FROM 직원
    GROUP BY 부서
    ORDER BY 평균연봉 DESC
""").collect()

print(result)

SQL과 표현식 API를 섞어 쓸 수도 있어서, 상황에 따라 편한 방식을 골라 쓰면 됩니다.

실전 ETL 파이프라인: 종합 예제

마지막으로, 실제 업무에서 바로 활용할 수 있는 종합 ETL 파이프라인을 만들어 보겠습니다. 좀 길지만, 실전 감각을 잡기에 좋은 예제입니다.

import polars as pl
from datetime import datetime, timedelta
import random

# === 1단계: 데이터 소스 정의 ===
# 실제로는 pl.scan_csv(), pl.scan_parquet() 등으로 파일을 읽음
random.seed(42)

# 주문 데이터
orders = pl.DataFrame({
    "주문ID": [f"ORD-{i:06d}" for i in range(1, 10001)],
    "고객ID": [f"C{random.randint(1, 500):04d}" for _ in range(10000)],
    "제품코드": [f"P{random.randint(1, 50):03d}" for _ in range(10000)],
    "수량": [random.randint(1, 10) for _ in range(10000)],
    "주문일": [
        datetime(2025, 1, 1) + timedelta(days=random.randint(0, 364))
        for _ in range(10000)
    ],
})

# 제품 마스터
products = pl.DataFrame({
    "제품코드": [f"P{i:03d}" for i in range(1, 51)],
    "제품명": [f"제품_{i}" for i in range(1, 51)],
    "카테고리": random.choices(["전자기기", "의류", "식품", "도서", "생활용품"], k=50),
    "단가": [random.randint(5000, 500000) for _ in range(50)],
})

# === 2단계: 지연 실행 파이프라인 구축 ===
pipeline = (
    orders.lazy()
    # 제품 정보 조인
    .join(products.lazy(), on="제품코드", how="left")
    # 파생 변수 생성
    .with_columns(
        매출액=pl.col("수량") * pl.col("단가"),
        월=pl.col("주문일").dt.month(),
        분기=pl.col("주문일").dt.quarter(),
    )
    # 월별/카테고리별 집계
    .group_by("월", "카테고리")
    .agg(
        총매출=pl.col("매출액").sum(),
        주문건수=pl.col("매출액").count(),
        평균주문액=pl.col("매출액").mean().round(0),
        고유고객수=pl.col("고객ID").n_unique(),
    )
    # 매출 순위 계산
    .with_columns(
        월별순위=pl.col("총매출").rank(descending=True).over("월").cast(pl.Int32),
    )
    # 정렬
    .sort("월", "월별순위")
)

# === 3단계: 쿼리 계획 확인 및 실행 ===
print("=== 최적화된 쿼리 계획 ===")
print(pipeline.explain())

print("\n=== 실행 결과 ===")
result = pipeline.collect()
print(result.head(15))

# === 4단계: 추가 분석 — 상위 카테고리 추출 ===
top_categories = (
    result.lazy()
    .filter(pl.col("월별순위") == 1)
    .select("월", "카테고리", "총매출", "고유고객수")
    .collect()
)

print("\n=== 월별 1위 카테고리 ===")
print(top_categories)

이 파이프라인의 포인트는 .lazy()로 시작해서 .collect()로 끝나는 구조입니다. 중간 단계는 모두 쿼리 계획으로만 존재하고, 최종 실행 시 Polars가 알아서 최적화해 줍니다.

언제 Polars를 선택해야 할까?

자, 그러면 현실적인 질문으로 넘어가 볼게요. "그래서 pandas 대신 Polars를 써야 하나?"

Polars가 확실히 좋은 경우

  • 대용량 데이터: 수백만 행 이상이면 Polars가 확실히 유리합니다
  • ETL 파이프라인: 복잡한 데이터 변환 파이프라인을 만들 때
  • 성능이 중요할 때: 실시간 처리나 빠른 응답이 필요한 서비스
  • 메모리가 빠듯할 때: 제한된 환경에서 큰 데이터를 다뤄야 하는 경우
  • 새 프로젝트: 기존 pandas 코드에 얽매이지 않아도 될 때

pandas를 유지해도 괜찮은 경우

  • 소규모 데이터: 수만 행 이하라면 pandas도 충분히 빠릅니다
  • 기존 생태계 활용: scikit-learn, statsmodels 같은 라이브러리와 긴밀히 연동할 때
  • 대규모 레거시 코드: 이미 pandas 기반 코드가 산더미인 경우
  • 탐색적 분석: Jupyter에서 작은 데이터로 이것저것 만져볼 때

두 라이브러리 병행 사용

실무에서 가장 현실적인 접근법은 둘을 병행하는 겁니다. 무거운 전처리는 Polars로 하고, ML 학습은 pandas로 넘기는 식이죠.

import polars as pl
from sklearn.model_selection import train_test_split
from sklearn.ensemble import RandomForestClassifier

# 1단계: Polars로 대용량 데이터 전처리
features = (
    pl.scan_parquet("raw_data.parquet")
    .filter(pl.col("유효").eq(True))
    .with_columns(
        pl.col("날짜").dt.weekday().alias("요일"),
        pl.col("금액").log().alias("로그금액"),
    )
    .select("요일", "로그금액", "카테고리", "타겟")
    .collect()
)

# 2단계: scikit-learn 용 pandas 변환
pdf = features.to_pandas()
X = pdf.drop("타겟", axis=1)
y = pdf["타겟"]

# 3단계: 머신러닝 학습
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2)
model = RandomForestClassifier(n_estimators=100)
model.fit(X_train, y_train)
print(f"정확도: {model.score(X_test, y_test):.4f}")

이렇게 각 라이브러리의 강점을 살리는 게 가장 실용적인 방법입니다.

결론: Polars, 한번 써보면 돌아가기 어렵다

Polars는 단순히 "빠른 pandas"가 아닙니다. 표현식 기반의 선언적 API, 자동 병렬 처리, 지연 평가와 쿼리 최적화, 스트리밍 엔진, GPU 가속까지 — 현대 데이터 엔지니어링에 필요한 거의 모든 걸 갖추고 있습니다.

pandas 3.0이 Copy-on-Write와 PyArrow 통합으로 크게 발전한 건 사실이지만, Polars는 태생부터 고성능을 위해 설계된 아키텍처 위에 서 있습니다. 더 빠르고, 메모리를 덜 쓰고, 더 큰 규모의 데이터를 다룰 수 있죠.

이미 pandas를 잘 쓰고 계신다면, 새 프로젝트나 성능이 중요한 파이프라인에 Polars를 점진적으로 도입해 보세요. 두 라이브러리 간의 상호 변환이 워낙 쉬워서 부담이 적습니다.

데이터의 규모와 복잡성이 계속 커지는 시대에, Polars는 Python 데이터 과학자와 엔지니어에게 꼭 필요한 도구가 됐습니다. 오늘 바로 pip install polars로 시작해 보세요. 후회하지 않으실 겁니다.

저자 소개 Editorial Team

Our team of expert writers and editors.