본문 바로가기
Data Science

대용량 CSV와 로그를 pandas 대신 Polars로 빠르게 처리하기: lazy API와 벤치마크

주말만기다려·2026년 8월 9일·조회 1

수 GB짜리 접속 로그나 CSV를 pandas로 열다가 read_csv에서 메모리가 꽉 차서 커널이 죽는 상황, 데이터 전처리를 해본 사람이라면 한 번쯤 겪는다. 필자도 로그 집계 스크립트를 돌릴 때마다 파일을 잘라서 나눠 읽거나, 인스턴스 메모리를 키우는 식으로 버텼다. 그러다 Polars로 갈아타면서 같은 작업이 훨씬 가벼워졌기에, 이관하면서 알게 된 지점을 정리한다.

결론부터 말하면, 대용량 파일 전처리에서는 Polars의 lazy API가 pandas보다 빠르고 메모리도 적게 쓴다. 핵심은 pl.scan_csv()로 파일을 즉시 읽지 않고 쿼리 계획만 세운 뒤, 필터와 집계까지 엮어서 마지막에 collect()로 한 번에 실행하는 방식이다. 이렇게 하면 Polars가 필요한 열과 행만 골라 읽고, 여러 코어를 동시에 쓴다. 아래에서 lazy API의 동작, 메모리 절감, group_by와 join 벤치마크, pandas 코드 이관 포인트를 차례로 살펴본다.

1. Polars와 lazy API란 무엇인가

Polars는 Rust로 작성된 데이터프레임 라이브러리다. 내부 데이터를 Apache Arrow 형식으로 열 단위로 저장하고, 연산을 여러 CPU 코어에 나눠 병렬로 처리한다. pandas가 기본적으로 단일 스레드로 도는 것과 다른 지점이다.

Polars에는 두 가지 실행 방식이 있다. eager API는 명령을 만나는 즉시 실행한다. pandas와 같은 방식이라 익숙하다. lazy API는 실행을 미룬다. 연산을 계속 쌓아 하나의 쿼리 계획(query plan)을 만들고, collect()를 호출하는 순간 그 계획 전체를 최적화한 뒤 한 번에 돌린다.

파일 입출력도 둘로 나뉜다. pl.read_csv()는 파일을 통째로 메모리에 올리는 eager 방식이고, pl.scan_csv()는 파일을 아직 읽지 않고 스캔 계획만 반환하는 lazy 방식이다. 대용량 파일에서는 scan_csv가 핵심이다.

2. 설치

Python 3.9 이상에서 pip로 설치한다. 런타임 요구사항은 저장소 상태에 따라 조금씩 다를 수 있으니 설치 후 버전을 확인한다.

pip install polars

python -c "import polars as pl; print(pl.__version__)"
1.x.x

💡 CPU가 AVX 명령어를 지원하지 않는 구형 환경이라면 pip install polars-lts-cpu로 호환 빌드를 설치한다. 클라우드 인스턴스 대부분은 그냥 polars로 된다.

3. eager와 lazy 비교: scan_csv와 collect

같은 작업을 두 방식으로 써 보면 차이가 분명하다. iris처럼 작은 데이터라면 결과는 같지만, 대용량에서는 실행 경로가 완전히 달라진다.

eager 방식은 파일 전체를 먼저 메모리에 올린 다음 필터와 집계를 순서대로 실행한다.

import polars as pl

df = pl.read_csv("access.csv")
df_small = df.filter(pl.col("status") == 500)
df_agg = df_small.group_by("path").agg(pl.len())

lazy 방식은 scan_csv로 시작해 연산을 엮은 뒤 collect()에서 실행한다.

import polars as pl

q = (
    pl.scan_csv("access.csv")
    .filter(pl.col("status") == 500)
    .group_by("path")
    .agg(pl.len())
)
df = q.collect()

공식 문서는 중간 결과가 필요하거나 탐색적으로 데이터를 훑어보는 경우가 아니면 lazy API를 권장한다. 이유는 다음 절의 쿼리 최적화에 있다.

4. 쿼리 최적화: 왜 lazy가 빠른가

collect()를 호출하면 Polars는 쌓인 계획을 그대로 돌리지 않고 먼저 다듬는다. 대용량에서 체감되는 최적화는 두 가지다.

Predicate pushdown

필터 조건을 데이터를 읽는 단계까지 끌어내린다. 위 예시에서 status == 500 조건이 파일을 읽는 시점에 적용되므로, 조건에 맞지 않는 행은 애초에 메모리에 올라오지 않는다.

Projection pushdown

쿼리에서 실제로 쓰는 열만 읽는다. 로그 CSV에 열이 30개 있어도 pathstatus 두 개만 참조한다면 나머지 28개는 디스크에서 읽지도 않는다. 이 지점이 대용량에서 메모리와 속도를 동시에 줄인다.

계획이 어떻게 최적화됐는지 확인하려면 explain()을 쓴다. 실행 전에 Polars가 무엇을 할지 미리 보여준다.

print(q.explain())

SELECTION: [(col("status")) == (500)]
    Csv SCAN [access.csv]
    PROJECT 2/30 COLUMNS

PROJECT 2/30 COLUMNS가 projection pushdown이 걸린 흔적이다. 30개 중 2개만 읽겠다는 뜻이다. eager로 짜면 이런 가지치기가 일어나지 않는다.

5. 메모리 절감과 streaming 엔진

데이터가 메모리보다 큰 경우, collect()engine="streaming"을 넘기면 Polars가 데이터를 배치로 나눠 처리한다. 전체를 한꺼번에 올리지 않으므로 메모리보다 큰 파일도 다룰 수 있다.

q = (
    pl.scan_csv("huge_access.csv")
    .filter(pl.col("status") == 500)
    .group_by("path")
    .agg(pl.len())
)
df = q.collect(engine="streaming")

결과를 DataFrame으로 모으지 않고 바로 파일에 쓰고 싶다면 sink_*() 계열을 쓴다. df.write_*()의 메모리 효율적 대안으로, 스트리밍하면서 디스크에 직접 기록한다.

q.sink_parquet("result.parquet")

⚠️ streaming 엔진은 계속 개선되는 중이라 지원하지 않는 연산이 섞이면 Polars가 기본 인메모리 엔진으로 되돌아갈 수 있다. 결과 정확성은 유지되지만, 메모리 절감을 기대했다면 explain()으로 실제 계획을 확인하는 편이 좋다.

6. group_by와 join 벤치마크

수치는 CPU 코어 수, 디스크, 데이터 분포에 따라 크게 갈리므로 직접 재는 것이 정확하다. 측정은 time.perf_counter로 간단히 한다.

import time, polars as pl, pandas as pd

# pandas
t = time.perf_counter()
pdf = pd.read_csv("access.csv")
res = pdf[pdf["status"] == 500].groupby("path").size()
print("pandas:", round(time.perf_counter() - t, 2), "s")

# Polars lazy
t = time.perf_counter()
res = (
    pl.scan_csv("access.csv")
    .filter(pl.col("status") == 500)
    .group_by("path")
    .agg(pl.len())
    .collect()
)
print("polars:", round(time.perf_counter() - t, 2), "s")

필자 환경(멀티코어 인스턴스, 수 GB CSV) 기준으로, group_by 집계는 환경에 따라 다르지만 대략 pandas의 몇 배 수준으로 빨라졌다. join도 비슷한 경향이다. Polars의 join은 pandas merge와 인자 이름이 다르니 주의한다.

left.join(right, on="user_id", how="inner")

속도 이득이 큰 쪽은 열이 많고 필터로 상당량이 걸러지는 로그 데이터다. projection과 predicate pushdown이 읽는 양 자체를 줄이기 때문이다. 반대로 열 몇 개짜리 작은 파일이라면 차이가 크지 않을 수 있다.

7. pandas 코드 이관 포인트

문법이 비슷해 보여도 사고방식이 다른 부분이 몇 군데 있다. 걸리기 쉬운 순서대로 정리한다.

인덱스가 없다

Polars에는 행 인덱스 개념이 없다. 각 행은 정수 위치로만 식별된다. 따라서 .loc, .iloc, reset_index가 존재하지 않는다. 인덱스에 의존하던 코드는 명시적인 열 기준 연산으로 바꿔야 한다.

대괄호 선택 대신 표현식

df["a"]나 불리언 마스킹 대신 표현식 API를 쓴다.

# pandas
df[df["a"] < 10][["a", "b"]]

# Polars
df.filter(pl.col("a") < 10).select("a", "b")

groupby가 아니라 group_by

메서드 이름이 group_by이고, 집계는 .agg() 안에 표현식으로 넘긴다. pandas의 groupby().transform().over() 윈도 함수로 대체한다.

# pandas: df.groupby("c")["v"].transform("count")
df.with_columns(pl.col("v").count().over("c").alias("size"))

파생 열은 with_columns로 한 번에

assign을 순차로 여러 번 부르는 대신 with_columns에 표현식 여러 개를 넣으면 병렬로 계산된다.

df.with_columns(
    ten_x=pl.col("value") * 10,
    upper=pl.col("name").str.to_uppercase(),
)

타입에 엄격하다

Polars는 타입을 엄격하게 다룬다. null이 섞였다고 정수를 실수로 자동 변환하지 않는다. pandas의 느슨한 타입 강제에 기대던 코드는 명시적으로 캐스팅해야 한다.

8. 정리

대용량 CSV와 로그 전처리에서 pandas가 메모리와 속도로 발목을 잡는다면 Polars의 lazy API가 실질적인 답이 된다. scan_csv로 계획만 세우고, 필터와 집계를 엮은 뒤 collect()로 실행하면 Polars가 필요한 열과 행만 읽고 여러 코어를 병렬로 쓴다. 메모리보다 큰 파일은 engine="streaming"sink_*()로 넘긴다. pandas 코드를 옮길 때는 인덱스가 없다는 점, 대괄호 대신 표현식을 쓴다는 점, group_bywith_columns 문법만 손에 익히면 대부분 무리 없이 이관된다.

자주 묻는 질문

scan_csv와 read_csv 중 무엇을 써야 하나?

대용량 파일이나 뒤에 필터, 집계가 이어지는 전처리라면 scan_csv를 쓴다. scan_csv는 파일을 즉시 읽지 않고 쿼리 계획만 만든 뒤 collect() 시점에 predicate/projection pushdown으로 필요한 부분만 읽는다. 반대로 작은 파일을 바로 눈으로 확인하려는 탐색적 작업이라면 read_csv가 편하다.

메모리보다 큰 CSV도 Polars로 처리할 수 있나?

가능하다. collect()에 engine="streaming"을 넘기면 데이터를 배치로 나눠 처리하므로 전체를 한꺼번에 메모리에 올리지 않는다. 결과를 DataFrame으로 모을 필요가 없다면 sink_parquet 같은 sink 계열로 디스크에 바로 기록해 메모리를 더 아낄 수 있다.

Polars가 pandas보다 정확히 몇 배 빠른가?

코어 수, 디스크 속도, 데이터 분포, 쿼리 형태에 따라 크게 달라져 하나의 숫자로 말하기 어렵다. 열이 많고 필터로 상당량이 걸러지는 로그 집계에서는 이득이 크고, 열 몇 개짜리 작은 파일에서는 차이가 작을 수 있다. time.perf_counter로 두 방식을 같은 데이터에 직접 재 보는 것이 정확하다.

pandas 코드에서 가장 먼저 걸리는 이관 지점은?

인덱스가 없다는 점이다. Polars는 .loc, .iloc, reset_index가 없고 행을 정수 위치로만 다룬다. 그다음으로 df["a"] 대괄호 선택이 select/filter 표현식으로 바뀌고, groupby가 group_by로, transform이 .over() 윈도 함수로 바뀐다.

explain()은 언제 쓰나?

collect() 전에 Polars가 세운 최적화된 쿼리 계획을 확인할 때 쓴다. 출력에서 PROJECT 2/30 COLUMNS처럼 몇 개 열만 읽는지, 필터가 스캔 단계로 내려갔는지를 볼 수 있어 pushdown이 실제로 걸렸는지 점검할 때 유용하다. streaming 엔진이 인메모리로 되돌아갔는지 확인할 때도 도움이 된다.

관련 글

댓글 0

로그인 후 댓글을 남길 수 있습니다.

아직 댓글이 없습니다.