- Published on
Python Polars로 100GB CSV 메모리 오류 해결
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
대용량 CSV를 다루다 보면 가장 먼저 마주치는 벽이 메모리입니다. 특히 100GB급 CSV를 한 번에 로드하려고 하면, 파일 크기보다 훨씬 큰 메모리가 필요해져 프로세스가 Killed 되거나 MemoryError 로 종료됩니다. 이유는 간단합니다. CSV는 텍스트 포맷이라 파싱 과정에서 임시 버퍼가 필요하고, 문자열 컬럼은 객체/가변 길이 데이터로 확장되며, 타입 추론과 결측치 처리까지 겹치면 메모리 사용량이 폭증합니다.
Polars는 이 문제를 현실적으로 해결할 수 있는 도구입니다. 핵심은 다음 3가지입니다.
- Lazy API + 최적화: 필요한 컬럼만 읽고, 필터를 앞당겨(pushdown) 처리
- Streaming 실행: 가능한 연산을 스트리밍으로 처리해 전체 데이터를 메모리에 올리지 않음
- 스키마 고정과 문자열 관리: 타입 추론 비용과 문자열 메모리 폭발을 줄임
이 글에서는 100GB CSV에서 메모리 오류를 줄이는 실전 패턴을 코드 중심으로 정리합니다.
왜 Pandas는 100GB CSV에서 쉽게 터질까
Pandas read_csv 는 기본적으로 전체 데이터를 메모리의 DataFrame으로 구성합니다. CSV 파싱은 다음 단계에서 메모리를 크게 씁니다.
- 타입 추론: 일부/전체 샘플을 보고 dtype을 결정하며 임시 객체가 늘어남
- 문자열 컬럼: Python object 기반으로 저장되면 오버헤드가 큼
- 결측치 처리: NaN/None 변환 과정에서 추가 배열 생성
- 연산 중간 결과: 필터/조인/그룹바이 중간 테이블이 복제되기 쉬움
반면 Polars는 Arrow 기반 컬럼형 메모리 모델을 사용하고, Lazy 실행으로 연산 계획을 최적화할 수 있어 같은 작업도 훨씬 적은 메모리로 처리할 여지가 큽니다.
Polars로 접근할 때의 기본 전략
100GB CSV를 “읽어서 메모리에 올린 뒤 처리”가 아니라, “읽는 단계부터 처리 계획을 세워서 필요한 만큼만 통과”시키는 방식으로 바꿔야 합니다.
- Lazy로 스캔:
pl.scan_csv를 사용해 즉시 로드하지 않고 쿼리 계획을 만든다 - Projection pushdown: 필요한 컬럼만 선택한다
- Predicate pushdown: 가능한 필터를 읽기 단계로 내린다
- Streaming collect:
collect(streaming=True)로 스트리밍 실행을 시도한다 - 스키마/인코딩/널 처리 명시: 추론 비용과 예외를 줄인다
아래부터는 이 전략을 코드로 구체화합니다.
1) read_csv 대신 scan_csv 로 시작하기
가장 흔한 실수는 pl.read_csv 로 바로 읽는 것입니다. 100GB면 이 단계에서 이미 위험합니다. 먼저 Lazy로 스캔하세요.
import polars as pl
path = "./data/events_100gb.csv"
lf = pl.scan_csv(
path,
has_header=True,
ignore_errors=True,
)
# 아직 읽지 않았고, 실행 계획만 만들어진 상태
print(lf.explain())
scan_csv 는 “파일을 스캔할 준비”만 하고, 실제로는 collect 시점에 읽습니다. 이게 중요한 이유는, 이후에 컬럼 선택/필터를 붙이면 Polars가 읽기 단계에서부터 최적화할 수 있기 때문입니다.
2) 필요한 컬럼만 읽기: Projection pushdown
100GB CSV에서 컬럼이 50개인데 5개만 필요하다면, 나머지 45개를 읽는 순간 메모리/CPU/IO가 낭비됩니다.
cols = ["event_time", "user_id", "event_type", "amount", "country"]
result = (
lf.select(cols)
.filter(pl.col("event_type") == "purchase")
.group_by("country")
.agg(
pl.len().alias("cnt"),
pl.col("amount").sum().alias("revenue"),
)
.collect(streaming=True)
)
print(result)
여기서 포인트는 select 를 최대한 앞에 두는 것입니다. Lazy 최적화가 해주긴 하지만, 의도를 명확히 하면 디버깅도 쉬워집니다.
3) 필터를 최대한 빨리: Predicate pushdown
필터가 읽기 단계로 내려가면, 불필요한 행을 파싱하기 전에 버릴 수 있습니다.
from datetime import datetime
start = datetime(2025, 1, 1)
end = datetime(2025, 2, 1)
result = (
lf.select(["event_time", "user_id", "amount"])
.with_columns(
pl.col("event_time").str.strptime(pl.Datetime, strict=False)
)
.filter(
(pl.col("event_time") >= pl.lit(start)) &
(pl.col("event_time") < pl.lit(end))
)
.select([
pl.col("user_id"),
pl.col("amount").cast(pl.Float64),
])
.group_by("user_id")
.agg(pl.col("amount").sum().alias("amount_sum"))
.collect(streaming=True)
)
주의할 점은 문자열 날짜를 strptime 하는 순간, 필터 푸시다운이 제한될 수 있다는 것입니다. 가능하면 원본 데이터가 이미 ISO-8601 정렬 가능한 형태이거나, 파티션(예: 날짜별 파일)로 나뉘어 있는 구조가 더 유리합니다.
4) 스키마를 고정해 타입 추론 비용과 메모리 폭발 막기
대용량 CSV에서 타입 추론은 느릴 뿐 아니라, 컬럼 일부가 숫자/문자 혼합이면 Utf8 로 승격되어 메모리가 커질 수 있습니다. 스키마를 명시하면 예측 가능성이 올라갑니다.
schema = {
"event_time": pl.Utf8,
"user_id": pl.Utf8,
"event_type": pl.Categorical,
"amount": pl.Float64,
"country": pl.Categorical,
}
lf = pl.scan_csv(
path,
schema=schema,
has_header=True,
ignore_errors=True,
)
out = (
lf.select(["country", "amount"])
.filter(pl.col("amount").is_not_null())
.group_by("country")
.agg(pl.col("amount").mean().alias("avg_amount"))
.collect(streaming=True)
)
Categorical은 반복 값이 많은 문자열 컬럼에서 메모리를 크게 절약합니다.- 숫자 컬럼은 가능하면
Float64또는Int64로 고정하세요.
5) 청크/배치 처리: 결과를 파일로 흘려보내기
메모리 오류가 나는 또 다른 이유는 “읽기”가 아니라 “결과를 한 번에 만들기”입니다. 예를 들어 전체 데이터를 정렬해서 한 번에 CSV로 저장하려 하면 결과 테이블이 커져 OOM이 납니다.
이럴 때는 중간 결과를 Parquet로 떨어뜨리는 패턴이 안정적입니다.
import polars as pl
lf = pl.scan_csv(path, has_header=True, ignore_errors=True)
# 필요한 전처리만 수행하고 Parquet로 저장
(
lf.select(["event_time", "user_id", "event_type", "amount", "country"])
.with_columns([
pl.col("amount").cast(pl.Float64, strict=False),
pl.col("country").cast(pl.Categorical),
])
.filter(pl.col("amount").is_not_null())
.sink_parquet("./out/events_cleaned.parquet")
)
sink_parquet 는 Lazy 파이프라인의 결과를 파일로 바로 흘려보내는 방식이라, “메모리에 다 모아서 write” 하는 것보다 안전합니다.
6) 조인/그룹바이에서 OOM이 나면: 카디널리티와 키 설계를 먼저 의심
100GB CSV에서 가장 위험한 연산은 보통 join 과 group_by 입니다.
group_by(user_id)에서user_id가 거의 유니크면, 그룹 수가 행 수에 가까워져 메모리 사용량이 커집니다.- 대형 테이블 두 개를 조인하면 해시 테이블이 커져 OOM이 날 수 있습니다.
대응책:
- 조인 전에 필터/컬럼 축소를 최대한 적용
- 조인 키를 정규화하거나 필요 시
Categorical로 변환 - 큰 테이블과 작은 테이블 조인이라면 작은 쪽만 메모리에 올리는 구조로 설계
users = pl.read_parquet("./dim/users.parquet").select(["user_id", "segment"]) # 상대적으로 작다고 가정
lf = pl.scan_csv(path, has_header=True, ignore_errors=True)
out = (
lf.select(["user_id", "amount"])
.with_columns(pl.col("amount").cast(pl.Float64, strict=False))
.filter(pl.col("amount").is_not_null())
.join(users.lazy(), on="user_id", how="left")
.group_by("segment")
.agg(pl.col("amount").sum().alias("revenue"))
.collect(streaming=True)
)
7) 인코딩/디코딩 문제로 파이프라인이 깨질 때
대용량 CSV는 종종 일부 라인에 잘못된 바이트가 섞여 파싱이 중단됩니다. 이 경우 메모리 오류처럼 보이지만 실제로는 디코딩 예외가 트리거가 되어 재시도/로그 폭증 등으로 리소스가 무너질 수 있습니다.
- 파일 인코딩을 확인하고
- 문제가 되는 라인을 격리하거나
- ETL 단계에서 정규화하는 것이 좋습니다.
관련해서 UTF-8 디코딩 예외를 다룬 글은 여기에서 함께 참고할 수 있습니다.
8) 컨테이너/EKS에서 OOM이 난다면: Polars 이전에 리소스 상한 확인
로컬에서는 버티는데 EKS에서만 OOMKilled 가 반복된다면, 코드 최적화 이전에 파드 메모리 limit이 너무 낮거나, 노드 메모리 압박으로 축출되는 상황일 수 있습니다. 이때는 애플리케이션 레벨에서 streaming=True 를 넣어도 한계가 있습니다.
- limit/requests 설정
- 노드 여유 메모리
- 임시 파일 저장 공간
- 로그 폭증
같은 운영 요소를 함께 점검해야 합니다.
9) 실전 체크리스트: 100GB CSV를 “안 터지게” 만드는 순서
아래 순서를 그대로 적용하면 대부분의 메모리 오류를 빠르게 줄일 수 있습니다.
pl.read_csv대신pl.scan_csvselect로 컬럼 최소화filter를 최대한 앞단에 배치schema를 명시해 타입 추론 제거- 반복 문자열은
Categorical고려 - 결과를 한 번에 만들지 말고
sink_parquet같은 방식으로 흘려보내기 group_by키의 카디널리티 점검- 조인은 “작은 테이블을 붙인다”는 설계로 재구성
- 컨테이너 환경이면 limit/requests부터 확인
마무리
100GB CSV에서 메모리 오류를 해결하는 핵심은 “더 큰 메모리”가 아니라 “처리 모델 변경”입니다. Polars의 Lazy/Streaming을 기본으로 두고, 스키마를 고정하며, 컬럼/행을 읽기 단계에서 최대한 줄이면 대용량 CSV도 안정적으로 처리할 수 있습니다.
마지막으로, CSV는 분석/ETL 관점에서 장기 보관 포맷으로 비효율적입니다. 한 번 안정적으로 읽어 정제한 뒤 Parquet로 변환해 파이프라인의 표준 입력으로 삼으면, 이후 비용(시간/메모리/장애율)이 크게 줄어듭니다.